Backup and restore features often sit outside the application's normal request path. They accept large, trusted-looking files, run with broad access, and are tested less often than the endpoints users touch every day. That makes them unusually valuable places to look for broken assumptions.
The affected company and product are intentionally omitted. Product names, versions, routes, archive names, table names, and environment-specific commands have been generalized while preserving the vulnerability chain.
The restore parser wrote SQL by hand
The application stored historical statistics as CSV inside its backup archive. During restoration, it read each row as text and concatenated the values into one large INSERT statement.
// Simplified restore logic
const values = "(" + rows.join("), (") + ")";
await db.query(
`INSERT INTO ${table} (${columns}) VALUES ${values};`
);
No SQL parameterization occurred at the point where the archive crossed into the database. A crafted CSV field could close the current value list, terminate the statement, and append another PostgreSQL command.
..., 'timestamp');
COPY (SELECT 1) TO PROGRAM 'benign-proof-command'; --
The proof used a harmless marker command. The important detail was that the database accepted multiple statements through the driver's simple-query path, so this was not limited to changing restored rows.
A database permission became host execution
PostgreSQL restricts COPY ... TO PROGRAM because it starts a process as the operating-system account running the database. The application's database user could invoke it because that role had been granted superuser privileges.
The injection therefore crossed two boundaries at once: data became SQL, and SQL became an operating-system command inside the database container. A least-privileged role would not have removed the injection, but it would have prevented this particular escalation.
Input validation failed in the restore worker; excessive database privilege converted that failure into code execution.
The path did not require an account
The same platform exposed installation and recovery functions while the server was in its initial-setup state. A remote caller could upload a backup archive and request its restoration before the first administrator account existed.
The restore was staged and then processed as part of the server's startup sequence. That left a compact chain:
- Reach a fresh, factory-reset, or otherwise unconfigured instance.
- Upload an archive containing a modified statistics CSV.
- Trigger the pre-setup restore workflow.
- On startup, the restore worker builds and submits the unsafe query.
- The appended database command executes under the database operating-system user.
The chain used two HTTP requests and required no login, paired device, or action from an operator. On an already configured server, authenticated backup and restore paths still reached the same parser, so the unsafe sink remained relevant beyond first-time setup.
Impact
An unauthenticated attacker could execute commands in the database container and access everything available to that service account: application data, database credentials, internal network access, and any mounted files or secrets. The practical blast radius depended on container and deployment isolation, but the application had already lost both confidentiality and integrity.
Fixing the chain at every boundary
Parse data; do not turn it into query text
Restore rows should use parameterized statements or PostgreSQL's structured bulk-import facilities. Table and column identifiers should come from fixed internal mappings, not archive contents.
Treat backup archives as hostile packages
Validate the archive manifest, file set, types, sizes, and row schemas before staging a restore. For product-generated backups, authenticity protection can also distinguish known exports from arbitrary archives, but signatures are not a replacement for safe parsing.
Protect recovery operations
A server lacking its first administrator is not the same as an unauthenticated public service. Recovery should require a local bootstrap secret, console access, or another explicit proof of control.
Remove database superuser
The application role should own only the objects and operations it needs. It should not hold superuser or program-execution privileges. Separate migration or maintenance roles can handle narrowly scoped administrative work.
Trace backups in both directions. Export logic tells you the expected structure; import logic tells you which fields become filenames, commands, templates, or queries. Then ask who can reach restoration before and after setup.
Closing thought
None of the individual mistakes had to be fatal. Safe query construction would have stopped the injection. Authentication would have removed the remote pre-setup path. Least privilege would have blocked operating-system execution. Their composition is what turned a CSV row into unauthenticated RCE.
