10 minutes
PBCTF 2023 : JAZZY x VIE CHALS
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.
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 alol.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
- 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).
-
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.
-
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. -
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. -
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
-
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.
-
Open a window of the home page, you’ll need it for later, and give it a unique name (“windowA”).
-
CSRF another note, which contains your iframe that has srcdoc which DOM-clobbers
body
andsearch_result
(note name doesn’t matter). Highlight this note. -
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. -
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.
-
Repeat until flag:
pbctf{V_5w33p1ng_n0t3s_und3r_4_r4d10_s1l3nT_RuG}