I was a guest challenge writer for perfect blue’s CTF held mid February. We play-tested each others chals :P

MAKIMA

Challenge Description

Makima simps check in here

I’m actually a Power stan. I just needed an excuse for people to look at my Makima drawing.

Makima

TL;DR

  • Hide php in image, making a PHP/image polyglot
  • X-Accel-Header redirection through CDN to reach internal .php files and access /uploads/your_img.png/lol.php to execute PHP

The important parts

The default.conf, which is the config used for the nginx proxy server, has an RCE vulnerability:

#default.conf
    location ~ \.php$ {
        internal;
        include fastcgi_params;
        fastcgi_intercept_errors on;
        fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
    }

Any path ending in .php is passed to the fastcgi PHP interpreter which executes the file (with quirks). If such a site allows you to upload your own files, you can upload an arbitrary .php file and navigate to it in order to pass it to the interpreter and achieve RCE. This is especially bad due to fastcgi’s well-known “fix path” quirk:

For instance, if a request is made for /forum/avatar/1232.jpg/file.php which does not exist but if /forum/avatar/1232.jpg does, the PHP interpreter will process /forum/avatar/1232.jpg instead. If this contains embedded PHP code, this code will be executed accordingly.

Additionally, .php locations are all internal meaning that they’re only accessible via internal (within server system) redirects.

The reason we can access index.php is due to this tiny snippet:

    location / {
        index index.php;
    }

index is an internal rewrite of the index file index.php.


The PHP code that handles uploading and file submission actually makes requests through a CDN proxy in file_get_contents:

if(isset($_POST["url"])){ 
    $cdn_url = 'http://localhost:8080/cdn/' . $_POST["url"];
    $img = @file_get_contents($cdn_url);
    $f = finfo_open();
    $mime_type = finfo_buffer($f, $img, FILEINFO_MIME_TYPE);
    $fileName = 'uploads/' . substr(md5(rand()), 0, 13);
    $success = makeimg($img, $fileName, $mime_type);
    if ($success !== 0) {
        $msg = $success;
    }
} 

And in the default.conf all /cdn/ requests are proxied to a flask application running on port 8081:

   location /cdn/ {
        allow 127.0.0.1/32;
        deny all;
        proxy_pass http://cdn;
    }

The CDN handler is defined as follows:

@app.route("/cdn/<path:url>")
def cdn(url):
    mimes = ["image/png", "image/jpeg", "image/gif", "image/webp"]
    r = requests.get(url, stream=True)
    if r.headers["Content-Type"] not in mimes:
        return "????", 400
    img_resp = make_response(r.raw.read(), 200)
    for header in r.headers:
        if header == "Date" or header == "Server":
            continue
        img_resp.headers[header] = r.headers[header]
    return img_resp

This verifies that the URL supplied actually resolves to a filestream with the correct mimetype. The raw bytes are sent back as the response.

So we have 3 things to consider:

  • Unsafe fast-cgi handling of .php files - if /uploads/img.jpg/lol.php is accessed and a lol.php doesn’t exist in that path but /uploads/img.jpg does, fastcgi will attempt to interpret the image as code.
  • Ability to upload only images through a CDN - the CDN verifies the requested URL leads to an image, before passing it back the image uploader.
  • The fastcgi is behind an internal directive so it’s not publicly accessible.

Hiding code in an image // PHP image polyglot

The code only accepts (and, ostensibly, recieves) images, and attempts to compress given bytestreams as a specific image format (.png, .jpg, .webp, .gif) before saving it to the filesystem. But are we able to add arbitrary data to the image?

This still needs to be an image to pass all the previous checks in the CDN, and needs to be a valid image. Otherwise, the PHP GD library will complain and fail to save to filesystem.

We know that FastCGI will happily process an image into its interpreter, so encoding a shell into such a picture would be the way to go.

Note that I gave 4 options. Let me know which image format you used :>

  • PNG - You can hide your PHP code in either the PLTE chunks or IDAT chunks of the image, PLTE being important header data and IDAT being the literal pixels comprising the picture. I believe the PLTE chunks were easier to embed into, as a Synacktiv blog post will outline.

  • JPEG - You can encode a minishell which, using Huffman tables, decodes into valid code. Embed it at the beginning of the image, and pad your payload with JPEG MCU bits (Minimum Code Unit) then cut off any strangler bits at the end.

  • GIF - You can embed a minishell in the small space of null bytes that are always present in the gif header, which persists across compressions/resizes. This is the easiest way, although the margin is small and may require some PHP golfing.

I believe most people either chose the png route or the gif route, the former because of the Synacktiv article and the latter because it was simpler to do. I thought about restricting format to just jpeg, the hardest one imo, but decided not to.

X-Accel-Redirect through the cdn

The internal directive is, at first glance, problematic for us since that means we can’t directly access /uploads/pic.png/doesntexist.php. It’ll be an external request so nginx will simply 404 us. We instead need it to instead force a internal redirect on a succesful response.

This is where the CDN proxy comes into play, as it is an upstream (through nginx’s proxy-pass directive) and therefore able to do internal re-routing.

Luckily for us, nginx features a fancy header called X-Accel-Redirect - a response header which tells the recieving server that the request should be re-routed to whatever the value is in that header (usually a URI). If nginx sees this response header, it will cause an internal redirect to the specified resource. So we can instead specify X-Accel-Redirect since the CDN copies all response headers into the resulting response thrown back to the upload server:

    img_resp = make_response(r.raw.read(), 200)
    for header in r.headers:
        if header == "Date" or header == "Server":
            continue
        img_resp.headers[header] = r.headers[header]

And have its value be that of /uploads/imgyouuploaded.png/doesntexist.php to execute the code within your image!

NOTE: In this way, the CDN proxy gets redirected, not us - so we won’t actually see the results of that redirect. But that’s fine, we don’t need to see our code being run, it just needs to run. :>

Step-by-step

  1. Create an image, with PHP code hidden in the bytes, and host it on a server you control (below I have attached a gif example here that has <?php touch("/tmp/lol")?> embedded within).
gif/php
  1. Send over your server url to Makima (the image upload server), the CDN proxy will recieve and pass it back so she can save it to disk. Take note of the provided filename.

  2. Your PHP/image polyglot is on the server now. Have your server return an X-Accel-Redirect response header with the value /uploads/your_img.gif/owa.php, and request it again so the CDN copies it, and nginx will redirect the proxy to that URI which triggers FastCGI.

  3. FastCGI won’t find /uploads/your_img.gif/owa.php but it will find /uploads/your_img.gif so it interprets that instead. Your PHP code will run.

  4. FLAG! pbctf{actually_power_is_the_better_character}

XSPS

XSPS was Jazzy’s challenge which was the last web released in pbctf 2023.

Challenge Description

The Notes app is back again

TL;DR

  • CSRF admin to change notes for HTML injection
  • DOM-clobber body
  • Control href to note with iframes
  • Detect frame count for xs-leak

The important parts

The general idea of the app:

  • Classic notes app, you can make note with a title and “highlight” one to be featured on the home page
  • Notes are stored via cookies
  • You can search for notes based on the content of the body

There is a restrictive CSP, plus even further restricting scripts to only nonce:

@app.after_request
def add_CSP(response):
    response.headers['Content-Security-Policy'] = f"default-src 'self'; script-src 'nonce-{g.nonce}'"
    return response

And the bot in the challenge has HTTPOnly cookies. So maybe no XSS - but hey, there’s no CSRF protections. So…


There is a static/main/js file which handles some client-side events, but the most notable one being what occurs when the window loads up:

window.onload = async function(){
    //init
    document.body.highlighted_note = await get_higlighted_note();
    document.body.search_result = document.getElementById('search_result');
    document.body.search_content = document.getElementById('search_content')
    document.body.search_open = document.getElementById('search_open')

    //highlight note
    document.getElementById('highlighted').innerHTML = document.body.highlighted_note;

    //search handler
    document.getElementById('search_button').onclick = search_click;
}

There is functionality that allows you to search the content of submitted notes, based on the client-side JS above.

async function search_name(search_data){
    let should_open = search_data['open']
    let query = search_data['query']

    let notes = await get_all_notes();

    let found_note = notes.find((val) => val.note.toString().startsWith(query));
    if(found_note == undefined){
        document.body.search_result.href = '';
        document.body.search_result.text = 'NOT FOUND'
        document.body.search_result.innerHTML += '<br>'
    }

    document.body.search_result.href = `note/${found_note.name}`;
    document.body.search_result.text = 'FOUND'
    document.body.search_result.innerHTML += '<br>'
    if(should_open)document.body.search_result.click();
}

async function search_click(){
    search_name({'query':document.body.search_content.value, 'open' : document.body.search_open.checked})
}

CSRF -> HTML injection

First and foremost, the CSP is annoying but it allows for iframes. There is additionally 0 CSRF protection - use this to your advantage to make the admin bot navigate to your site and CSRF them to change/update notes where the content contains iframes. How do we leverage this to leak the flag?

DOM-Clobbering window.body

You can inject an iframe with a srcdoc element - combine this with the client-side functionality above and see that you can DOM-clobber the body element of window through your iframe’s srcdoc. This leads to overriding document.body.

Doing so clobbers all the variables of document.body, allowing you greater control over the variables that were set in window.onload’s function in main.js. More specifically - search_result.

When the search button is clicked, search_click() is called which subsequently calls search_name() with an object as the argument - consisting of 2 properties: query being the value of document.body.search_content.value and open being the value of document.body.search_open.checked.

async function search_name(search_data){
    let should_open = search_data['open'] \\document.body.search_open.checked`
    let query = search_data['query'] \\document.body.search_content.value

    let notes = await get_all_notes();

    let found_note = notes.find((val) => val.note.toString().startsWith(query));
    if(found_note == undefined){
        document.body.search_result.href = '';
        document.body.search_result.text = 'NOT FOUND'
        document.body.search_result.innerHTML += '<br>'
    }

    document.body.search_result.href = `note/${found_note.name}`;
    document.body.search_result.text = 'FOUND'
    document.body.search_result.innerHTML += '<br>'
    if(should_open)document.body.search_result.click();
}

Note that the document.body.search_result.href is set to a relative path (if found) of note/${found_note.name}. Then, it’s navigated to via if(should_open)document.body.search_result.click();. Since we can inject iframes of our choosing, we are able to set the base URI within the iframe’s srcdoc, giving us control over what note is pointed to.

Ifr<Iframes>ames

The ability to control the href that the user navigates to sets up our XS-leak - allowing us to navigate to notes of our choosing. We could point the admin to a note containing more iframes, giving us our preferred gadget to leak the flag character by character. We do this by setting up a base href attribute in the srcdoc of the iframe that’s also DOM-clobbering the body element, letting us control what note an admin visits.

When if(should_open)document.body.search_result.click(); occurs, the click() would set _target to that of an iframe (‘iframe B’) that you control within the srcdoc of your first iframe (‘iframe A’). Iframe B can then be detected to have more subframes within, giving you an XS-Leak.

Step-by-step

  1. Create CSRF to POST a new note with multiple iframes in it - set it to the name of the flag’s note (which should just be “flag”). Do NOT highlight this note.

  2. Open a window of the home page, you’ll need it for later, and give it a unique name (“windowA”).

  3. CSRF another note, which contains your iframe that has srcdoc which DOM-clobbers body and search_result (note name doesn’t matter). Highlight this note.

  4. Open a window, but use the same name as before (“windowA”) with the location being xsps.chal.perfect.blue#the_b64'ed_JSON_object (the JSON object having properties open=true and query=the substring of the flag you’re guessing) to call the search function.

  5. Check that the number of frames present in that window are equal to the number of frames in the first flag note that you made. If the condition is true, the character you guessed is in the flag. If not, the character isn’t in the flag.

  6. Repeat until flag: pbctf{V_5w33p1ng_n0t3s_und3r_4_r4d10_s1l3nT_RuG}