Post

GHSA-8p88-j43q-rrp9

GHSA-8p88-j43q-rrp9

GHSA-8p88-j43q-rrp9 — Jellystat Backup-Restore SQL Injection to RCE

  • Infinit3i

This is a second SQL-injection-to-RCE bug I found in Jellystat, and it’s a sibling to GHSA-gjqm-fhgh-rrgv. Same end result — arbitrary OS commands as a Postgres superuser — but this time the way in is the backup restore feature. If you can get someone to restore a backup file you crafted, you own the database host.

It’s published as GHSA-8p88-j43q-rrp9 with a CVSS v4.0 score of 9.4 (critical), tagged under OS command injection (CWE-78) and SQL injection (CWE-89). It affects Jellystat up to and including 1.1.11, with no patched release available yet. No CVE was assigned.

Where it goes wrong

Jellystat lets you back up its data to a JSON file and restore it later. When it restores, it walks the JSON and builds INSERT statements to put the rows back — and the table name for each insert comes straight out of the uploaded file with no validation or escaping. It just gets interpolated into the SQL string as-is.

So the trust problem is simple: the restore code assumes the table names in a backup file are safe because Jellystat wrote them. But nothing stops me from writing my own backup file with a table name that isn’t a table name at all — it’s SQL.

The unsafe code lives in backend/routes/backup.js: the table name is pulled from the JSON at line 120, interpolated into the INSERT at line 144, and executed at line 145. And just like the other Jellystat bug, that execution uses the parameter-less query method, so stacked statements are allowed — I can end the intended statement and append my own. The upload and restore entry points sit at lines 78, 183, and 268.

From a backup file to command execution

Because I control a value that lands raw inside the SQL, and stacked statements are on the table, I can close out the INSERT and tack on whatever I want. The app connects to Postgres as a superuser, so the payoff is the same COPY ... TO PROGRAM trick — PostgreSQL running a shell command on the database host.

Here’s the shape of it. My malicious backup uses a table-name key that’s really a chunk of SQL:

1
jf_logging (...) VALUES (...) ON CONFLICT DO NOTHING; CREATE TABLE pwned_marker(z int); --

When Jellystat restores that file, the stacked statement runs and a pwned_marker table appears — clean proof the injection fired. Swap that harmless CREATE TABLE for a COPY (SELECT 1) TO PROGRAM 'bash -c "..."' and you’ve upgraded from “I can run SQL” to “I can run shell commands.”

Proof of concept

The PoC uploads a crafted backup via POST /backup/upload, then triggers the restore with GET /backup/restore/poc.json. The marker variant creates the pwned_marker table to confirm arbitrary SQL execution; the reverse-shell variant swaps in the COPY ... TO PROGRAM payload to land command execution on the database host.

Why it matters

Any authenticated user — or an admin who restores a backup they were handed — ends up executing arbitrary SQL and arbitrary OS commands as the Postgres superuser (confirmed as uid=999(postgres) in testing). That’s full control of the database and everything in it, plus a foothold on the host.

What makes this one worth calling out separately from the purge-endpoint bug is the delivery. A backup file feels like inert data — you’d restore one without thinking twice, especially if a “helpful” person sent it to you. But here the file is executable the moment it’s parsed, because a field everyone treats as a label (the table name) is actually flowing straight into a superuser SQL connection. Same root cause as its sibling — unparameterized SQL over a protocol that allows stacked statements — just wearing a more innocent-looking disguise.

This post is licensed under CC BY 4.0 by the author.