gp_sec
Back to all findings

SQL injection that writes its way to code execution

SQL injection is usually filed under "read data you shouldn't." This one was more fun, because reading wasn't the interesting part. The app had a feature that could run a custom script, guarded by a safety flag. The injection let me write to the tables behind that feature, so I planted my own command, flipped the safety flag off, and triggered it. A low-privilege user turned a search box into code execution on the server. It's a good reminder that a write-capable SQLi is often much worse than the data leak it looks like at first.

The injection

One of the reporting features had a search parameter that went into a query built by string concatenation rather than a parameterised statement. Classic SQL injection. A low-privilege user (an operator) had access to the report, intercepted the request, and the search parameter carried straight into the SQL.

The first thing anyone does with SQLi is prove it reads data. A UNION-based payload let me pull user records out of the auth tables, IDs, names, domains, the usual. That alone is a serious leak. But the database user the app connects as could do more than read, and that's where it got interesting.

SEARCH = validvalue' UNION ALL SELECT ... FROM users_table -- 

From reading to writing

Because I can inject arbitrary SQL, and the app's database account had write permission, I wasn't limited to SELECT. I could end the intended query and run an UPDATE against any table the account could touch. That turns "leak some rows" into "change the application's stored configuration," which is a completely different level of impact.

The product had a feature for running a custom script, used for legitimate automation. Two things in the database controlled it: a row holding the actual command to run, and a system parameter that said whether scripts must pass a safety check before executing. Both were just rows in tables. And I could write to rows.

The chain to RCE

Three writes, then a trigger.

Plant the command. Update the row that holds the custom script command, setting it to whatever I want the server to run. For a proof of concept, the classic harmless choice:

...; UPDATE script_config SET command = 'calc.exe' WHERE id = 1; --

Disable the safety check. There was a system parameter that forced scripts through a safe-execution guard. Set it to false, and that guard is gone:

...; UPDATE system_params SET value = 'false'
      WHERE name = 'script_safe_execution'; --

Trigger it. The custom script runs as part of a normal product action, in this case a password-reset flow. Perform that action and the planted command executes on the server. In a real test that means calc.exe popping up, the polite way of proving you have code execution without doing anything destructive.

The safety flag and the command were both just rows in a table. Once the injection could write rows, the feature's guard was one UPDATE away from off, and the command was one UPDATE away from mine.

The bonus: account takeover

The same write primitive gives an easier win too. The password hashes live in a table. Update the admin's row to a hash I know the password for, clear any secondary auth data tied to that account, and log in as admin with a password I set. No script feature needed, just writing to the auth tables directly. That it can do either full RCE or a clean admin takeover shows how much a write-capable SQLi hands you.

Why it's a 9.9

Start as a low-privilege operator, end with a command running on the server, or with the admin account. No prior admin access, no separate vulnerability needed for the escalation, the SQLi carries the whole thing. When an injection can write, "SQL injection" and "remote code execution" can be the same finding, and that's why a write-capable one sits near the top of the severity scale.

The fix

The root fix is the boring, correct one: parameterised queries. Don't build SQL by pasting user input into a string; use a prepared statement so the input can only ever be a value, never query structure.

// vulnerable
"SELECT * FROM products WHERE category = '" + input + "'"

// fixed
PreparedStatement ps =
    conn.prepareStatement("SELECT * FROM products WHERE category = ?");
ps.setString(1, input);

Worth adding as defence in depth: the application's database account shouldn't have more rights than it needs. If that account couldn't write to the config and auth tables in the first place, an injection would still be a leak, but it wouldn't have been an RCE. Least privilege on the DB account doesn't fix the bug, but it caps how bad the bug can get.

What to take from it

When I find SQL injection now, "can it read" is only the first question. The one that decides severity is "can it write, and what can it write to." A write primitive against configuration or auth tables is often a path to code execution or account takeover, not just disclosure. And any time a security control is stored as a value in the database, a boolean flag, an allowlist, a role, ask what happens if an attacker can flip it. Here, one of the guards protecting a dangerous feature was a single word in a table, and the whole exploit turned on being able to change it.

Back to all findings