Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active June 18, 2022 11:58
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save terjanq/458d8ec1148e96f7ccbdccfd908c56f6 to your computer and use it in GitHub Desktop.
Save terjanq/458d8ec1148e96f7ccbdccfd908c56f6 to your computer and use it in GitHub Desktop.
A TL;DR solution to Security Driven by @terjanq

A TL;DR solution to Security Driven by @terjanq

For this year's Google CTF, I prepared a challenge that is based on a real-world vulnerability. The challenge wasn't solved by any team during the competition so here is the proof that the challenge was in fact solvable! :)

The goal of the challenge was to send a malicious file to the admin and leak their file with a flag. The ID of the file was embedded into the challenge description (/file?id=133711377731) and only admin had access to it, because the file was private.

Disclamer: The write-up is written on airplane therefore the quality of it is poor, mostly to showcase the required steps to solve the challenge

SVG to XSS

It was possible to upload an SVG file which when provided with &preview=1 parameter would render it instead of downloading. But the XSS would be executed on doc-XX-YYYY.secdrivencontent.dev domain.

Domain collision

When visiting any site from secdrivencontent.dev, e.g. https://doc-foo.secdrivencontent.dev/ a message with the algorithm is presented:

Invalid URL format. The URL should be in the below form.
    doc-<doc_hash>-<user_hash>.secdrivencontent.dev/<signature>/<nonce>/<timestamp>/<owner_id>/<user_id>/<file_id>
where <doc_hash>  ~ HASH(SECRET || <file_id> || <user_id> || <owner_id> || <timestamp>) MODULO 17,
and   <user_hash> ~ HASH(SECRET || <user_id> || <owner_id> || <timestamp>) MODULO 100000

nonce cookie is signed by ~ HASH(SECRET || <random_int> || <user_id>)

It can be noticed that most users will have a different <user_hash> part of the domain because it's dependant on <user_id> and <owner_id>. The latter ensures that the same file shared to different accounts will also be different. However, it's clear that collisions will be possible. With having enough accounts one could cover the whole space of possible domains. With 160,000 accounts the coverage for all domains should be around 80% which is more than enough to solve the challenge.

It is worth noticing that the URL is signed with <signature> that prevents URL tampering.

Session less sandbox domain

secdrivencontent.dev has 0 information about the currently logged-in user because there were no session cookies or whatsoever. But having the URL of the file is not enough to access it, the nonce cookie is also required and if it's not present the app generates a new nonce, retrieves <user_id> from the URL, signs it with a secret, and redirects to /file?id=XXX&nonce=YYY&sgn=ZZZ that will generate a new URL with updated <nonce> in the URL.

If the <nonce> from the URL matches the nonce from the cookie, then the file will be retrieved.

File shares

One can notice that <user_hash> is dependant on two variables <user_id> and <owner_id> that are controlled by the player. That means that having only 400 accounts and sharing one file with each other it generates 400*400 different pairs of (<user_id>, <owner_id>) which means different hashes. This gave my exploit around 80% coverage of all possible <user_hash> parts.

Leaking admin's <user_hash> and <doc_hash>

A very important part of the challenge was to somehow leak the domain for the admin's file to later load an XSS on a collision domain and read its content because of same-origin relation. There are two ways known to me of achieving this:

  • through CSP violation
  • through CSP rules.

The CSP violation is an instant leak. All that needs to be done is to load an iframe pointing to https://chall.secdriven.dev/file?id=133711377731 and the following CSP rule: frame-src https://chall.secdriven.dev and listen to securitypolicyviolation event which contains blockedURI property containing the domain of the blocked URI. That is because the https://chall.secdriven.dev/file?id=133711377731 (allowed by CSP) redirects to https://doc-XX-YYYY.secdrivencontent.dev (blocked by CSP). This makes use of undefined behavior of how to handle iframes with CSP. Chrome and Firefox behave differently regarding this.

The leak through CSP rules is well defined and is about checking when the CSP blocked the resource and when not. To increase the performance, binary search can be used. By creating the following ruleset

img-src https://chall.secdriven.dev https://doc-1-3213.secdrivencontent.dev https://doc-2-3213.secdrivencontent.dev ... https://doc-17-3213.secdriven.dev

depending on the final domain either it gets blocked or not. If it's blocked that means that the domain is present in the specified ruleset, if not, not.

Forging custom domain

Even if we manage to get the collision domain for a file then we somehow need to force the admin to visit the file on this specific domain. If we tried /file?id=<xss_id> then the admin will end up in a different domain because of <user_id> difference. Because it is possible to write cookies from doc-12-321.secdrivencontent.dev to the parent domain .secdrivencontent.dev, we can create a cookie on a specific path on .secdrivencontent.dev which will be used when requesting any doc-XX-YYYY. That way, we can ensure that the malicious code ends up on the same domain.

Escalating XSS on same-origin

Even though we have an XSS on the same domain as the admin's file it's not trivial to leak the contents of it. That is because we can't predict the final URL and we have to generate the final domain through https://chall.secdriven.dev/file?id=133711377731. We can't use fetch then because the first request is cross-origin. What we could potentially do is to load it into the iframe and then read its contents.

But there is another caveat. The file triggers download and loading it in the iframe won't have its contents stored inside the page but downloaded instead. We somehow need to intercept the request before downloading. This can be done with cookie bombing or CSP rules.

The previous trick with reading blockedURI from the securitypolicyviolation event, when same-origin, returns the whole URL instead of only the origin. With that it's possible to leak the first URL after redirection but there is one problem - /file?id=XXX always redirects first with nonce equal to 0 because of 0 knowledge about nonce in secdrivencontent.dev, then secdrivencontent.dev redirects back with the nonce, and then the real URL is returned.

The flow is summarized below:

1. chall.secdriven.dev/file?id=133711377731 -> doc-XX-YYYY.secdrivencontent.dev/<signature>/0/<..rest>
2. doc-XX-YYYY.secdrivencontent.dev/<signature>/0/<...rest> -> chall.secdriven.dev/file?id=133711377731&nonce=<nonce>&sgn=<sgn>
3. chall.secdriven.dev/file?id=133711377731&nonce=<nonce>&sgn=<sgn> -> doc-XX-YYYY.secdrivencontent.dev/<signature>/<nonce>/<..rest>`
4. File is retrieved

What in theory could be done is to leak the URL with nonce equal to 0, load it again, race-condition the CSP (it'd be required because we assign the secdrivencontent.dev which is set to be blocked in CSP), and leak the final URL. But this doesn't work. It's impossible to race-condition CSP.

What can be race conditioned is cookie bombing though. After retrieving the first URL with nonce 0, we can at the same time request doc-XX-YYYY.secdrivencontent.dev/<signature>/0/<..rest> and create a lot of cookies so the first request goes without them, but when chall.secdriven.dev/file?id=133711377731&nonce=<nonce>&sgn=<sgn> resolves, the final URL will come with lots of cookies. That way, the final URL will cause the server to throw an error due to overly long headers and then we can read the final URL from the iframe because of the same-origin relation. After that, we clear the cookies and call fetch to retrieve the flag.

Domain lifetime

It could be noticed that <user_hash> also depends on <timestamp> which changes every 5 minutes it could be deduced from the 0's in the timestamp, e.g. 1626690000000. That means, that we only have 5 minutes to execute the exploit which is more than enough for efficient exploits and is very tight for unintended solutions, such as bruteforcing all 100,000 accounts.

Summary

  1. Send SVG to the admin and leak flags's domain via CSP leaks
  2. Find domain collision
  3. Send SVG to the admin that will execute malicious code on the same domain
  4. Retrieve the flag because of same-origin

CTF{One_puzzle_after_another_until_it_is_doner}

@ulasacikel
Copy link

ulasacikel commented Jul 19, 2021

Amazing challenge! Eventhough I was unable to solve it, I learned a lot in the process of trying.

I am sorry if it's a stupid question but can someone help me understand the math behind the following statement?

With 160,000 accounts the coverage for all domains should be around 80% which is more than enough to solve the challenge.

I gave up on this challange because I thought it would be impossible to find a domain collusion in 5 mins. Thanks!

@terjanq
Copy link
Author

terjanq commented Jul 19, 2021

@ulas-anil hashing functions aren't perfect - the goal of them is to convert data into a number. Because the maximum number in the challenge was 100,000 (modulus from the algorithm) it means that with 100,001 accounts you are certain that you have at least one collision because there are more users ids than available possibilities. With perfect hashing function the distribution would be equal meaning no collisions before reaching the maximum number, but those functions are slow. I used https://github.com/google/highwayhash and experimentally calculated that with 160,000 different pairs (user_id, owner_id) I get 80,000 unique domains and 80,000 collisions.

If I remember correctly from the algorithms lectures, with 100,000*log2(100,000) it should be almost 100% coverage with high probability.

@ulasacikel
Copy link

@terjanq Understood! Thank you so much for the clarification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment