2025 winter challenge writeup

Written by Denis Aouir, Timothée Schneider-Maunoury - 24/02/2026 - in Challenges - Download

Creating quines is a game that has always fascinated computer scientists. The journal Software: Practice and Experience dedicated an article to the subject in 1972—well before Intel released its first 32-bit x86 processor (1985). Even today, many enthusiasts continue to explore the intriguing universe of quines, such as Amy Burnett with her impressive JPEG Hash Quine or Yusuke Endoh’s legendary Uroboros Quine. In 2025, Synacktiv carried on this tradition by proposing two new variations of this type of puzzle: OCInception and Quinindrome. In this article, you will find the results of this latest winter challenge, alongside a breakdown of the winning solution and the most creative approaches.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Final leaderboard

Below is the final ranking of those who managed to validate at least one solution. Ties are broken based on the date the binary was received.
  • #1 - 81ioonag - 2025/12/08 10:21 PM (write-up)
  • #2 - 81XeR - 2025/12/17 1:58 PM (solution)
  • #3 - 81toby - 2025/12/29 11:41 PM (write-up)
  • #4 - 81 - 00BL1X - 2025/12/30 1:02 AM
  • #5 - 83 - Leon Noel - 2025/12/27 12:30 AM
  • #6 - 89 - doegox - 2025/12/07 2:37 AM
  • #7 - 89 - Lion - 2025/12/12 6:41 PM
  • #8 - 95 - Swissky - 2025/12/08 6:13 PM
  • #9 - 99 - alph4kam - 2025/12/11 7:47 PM
  • #10 - 101 - 3akev - 2025/12/19 8:43 AM
  • #11 - 103 - Nicolas - 2025/12/23 10:16 AM
  • #12 - 105 - itszn - 2025/12/03 1:28 PM 
  • #13 - 107 - matt - 2025/12/28 11:45 PM
  • #14 - 111 - Sk4r - 2025/12/12 12:26 AM (write-up)
  • #15 - 114 - mirisme - 2025/12/12 7:46 PM
  • #16 - 114 - rick - 2025/12/29 6:02 PM
  • #17 - 127 - rl0x01 - 2025/12/16 4:58 PM
  • #18 - 128 - Gerfaut - 2025/12/27 10:58 AM
  • #19 - 145 - Cortex - 2025/12/02 8:45 PM
  • #20 - 148 - Aker - 2025/12/22 7:33 AM
  • #21 - 165 - hendo - 2025/12/09 5:49 PM 
  • #22 - 188 - n0x - 2025/12/14 2:46 PM
  • #23 - 201 - Youssef - 2025/12/28 3:16 PM
  • #24 - 226 - CupOfCoffee - 2025/12/05 8:01 AM
  • #25 - 422 - Noé - 2025/12/13 5:11 PM

We would like to thank and congratulate all 25 participants for the quality of their submissions.
A special mention goes to the top four, who successfully outperformed Synacktiv’s own solution of 83 bytes:

$ hexdump -C synacktiv.bin 
00000000  7f 45 4c 46 01 ed eb 53  b2 58 41 04 6a 43 04 72  |.ELF...S.XA.jC.r|
00000010  02 00 03 00 3d 4b 80 cd  45 00 2c 00 2c 00 00 00  |....=K..E.,.,...|
00000020  00 00 00 00 00 00 01 00  20 00 20 00 01 00 00 00  |........ . .....|
00000030  00 00 00 00 00 00 2c 00  2c 00 45 cd 80 4b 3d 00  |......,.,.E..K=.|
00000040  03 00 02 72 04 43 6a 04  41 58 b2 53 eb ed 01 46  |...r.Cj.AX.S...F|
00000050  4c 45 7f                                          |LE.|

$ sha256sum synacktiv.bin 
8af9fdd50a4b5df623d59b4fde2d8c01d88c27a9badb09e2fdb1b24ca475e111  synacktiv.bin

 

IooNag's solution

We chose to publish IooNag's write-up here, firstly because he won this challenge with a better score than Synacktiv, but also because his report is very detailed and clearly written!
This entire section has therefore been copied from IooNag's write-up, available on his GitHub.


🎅 1. A new challenge appears!

Following the 2025 Summer Challenge, Synacktiv published a new challenge in December 2025: 2025 Winter Challenge: Quinindrome.

The idea is to design a quinindrome, which is an ELF binary that meets these two requirements:

  1. be a palindrome, meaning it's totally symmetrical,
  2. and be a byte-wise quine: print its own file on stdout when executed.

Of course, the process must end without a segfault, and the return code has to be set to 0.

[...] the winner will be the one who manages to obtain the lowest score [...]

On a Linux system, it is quite easy to build a program which fits both constraints, thanks to shebang interpreter directive:

#!/bin/cat
tac/nib/!#

This 21-byte program is a palindrome and when it is executed, Linux launches cat with the file name. This displays the content of the file and exits normally.

However, this program fails the test script provided by Synacktiv. This script actually creates a container (based on the scratch image) that only contains the program under test. As cat is not included in the container, it cannot be executed.

Now that this easy solution is not possible, what can actually work?

 

🚚 2. Compiling a fat solution

One of the simplest way of building a Quine consists in opening the program file given in argv[0], reading it to a buffer and writing the buffer to the standard output:

Compiling this C program in a Debian 13 virtual machine leads to a 737 KiB executable:

$ gcc -Os -static -o quine quine.c
$ stat --format=%s quine
754208

To make this program a palindrome, the naive way would be to add a byte-reversed copy to its end. A less naive way consists in omitting the last byte in the copy, as it can behave like a pivot between the original part and its mirror.

$ cat quine > quinindrome
$ python3 -c 'import sys;sys.stdout.buffer.write(sys.stdin.buffer.read()[:-1][::-1])' \
    < quine >> quinindrome
$ chmod +x quinindrome
$ ./test_script.sh quinindrome
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 1508415

After several minutes, the test script validated this program was a valid solution!

But the score is very large. How can the score be reduced as much as possible?

 

⛳ 3. Code-golfing the executable again

As the score of a program fitting the requirements is its size, the aim is to produce a program as small as possible. This is a domain called "code golfing" and my write-up for the previous challenge included a whole section with some tips: Write-up for Synacktiv's 2025 Summer Challenge: OCInception, 6. (Bonus) Code-golfing the executable

Instead of going through the explanations of all the tricks, let's study a solution to the problem of printing Hello world\n that was given in 98 bytes in https://codegolf.stackexchange.com/questions/5696/shortest-elf-for-hello-world-n:

7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00
02 00 03 00 01 00 00 00 35 40 B3 04 2C 00 00 00
00 00 00 00 00 00 00 00 34 00 20 00 01 00 00 00
00 00 00 00 00 40 B3 04 B2 0C EB 1C 62 00 00 00
62 00 00 00 05 00 00 00 00 10 00 00 48 65 6C 6C
6F 20 77 6F 72 6C 64 0A B9 4C 40 B3 04 93 CD 80
EB FB

This program is neither a quine nor a palindrome, so why is it interesting to study it? A quine can be crafted by writing the content of the program out instead of the string "Hello world". This makes it possible to modify this program to a working solution for the quinindrome challenge.

This StackExchange answer also provided some kind of description of the bytes:

            org     0x04B34000
            db      0x7F, "ELF", 1, 1, 1, 0 ; e_ident
            dd      0, 0
            dw      2                       ; e_type
            dw      3                       ; e_machine
            dd      1                       ; e_version
            dd      _start                  ; e_entry
            dd      phdr - $$               ; e_phoff
            dd      0                       ; e_shoff
            dd      0                       ; e_flags
            dw      0x34                    ; e_ehsize
            dw      0x20                    ; e_phentsize
phdr:       dd      1                       ; e_phnum       ; p_type
                                            ; e_shentsize
            dd      0                       ; e_shnum       ; p_offset
                                            ; e_shstrndx
            db      0                                       ; p_vaddr
_start:     inc     eax
            mov     bl, 4
            mov     dl, 12                                  ; p_paddr
            jmp     short part2
            dd      filesize                                ; p_filesz
            dd      filesize                                ; p_memsz
            dd      5                                       ; p_flags
            dd      0x1000                                  ; p_align
str:        db      'Hello world', 10
part2:      mov     ecx, str
again:      xchg    eax, ebx
            int     0x80
            jmp     short again
filesize    equ     $ - $$EM_486

This syntax is the one used by a well-known assembler named NASM. It enables writing x86 assembler statements (such as inc eax or mov ecx, str) interleaved with some integers with statements db (for bytes), dw (for 16-bit integers, "words") and dd (for 32-bit integers, "double-words"). The comments in the description map the fields used by the ELF headers to the content of the answer.

Indeed ELF executable files usually contain several headers, documented in man 5 elf:

  • an "ELF header" called Ehdr
  • a "program header" called Phdr
  • a "section header" called Shdr

To run a program, the section header is not used so it can be omitted. The program header contains information describing how the program is loaded. It has to contain at least one entry which tells how the file is mapped to memory. Finally, the ELF header is mandatory and always appear in the first bytes of the file.

Here are the 32-bit versions of C structures defining the ELF header and the program header, with some comments:

A well-known trick to craft small ELF files consists in making these two headers overlap, using the fact that bytes 01 00 00 00 00 00 00 00 can be used to represent both:

  • e_phnum = 1; e_shentsize = 0; e_shnum = 0; e_shstrndx = 0 in the ELF header
  • and p_type = PT_LOAD; p_offset = 0 in the program header.

This trick was used in the StackExchange solution.

Then comes the assembly code. What is happening? To invoke operating system functions through syscalls in x86 (in 32-bit mode) on Linux, a software interrupt can be triggered thanks to instruction int 0x80. The parameters of the syscall are provided in registers: eax to identify which system function is actually called ; and ebx, ecx, edx, esi, edi and ebp to provide the arguments. The program is using two syscalls:

Let's modify the code to output the content of the program. To do so, the address where the program is loaded in memory needs to be put in register ecx. A mov instruction with a 32-bit immediate value usually requires 5 bytes. Using some tricks, it is possible to go down to 3 bytes when the address is 0xffff0000, using 2 instructions: dec %ecx to set the register to 0xffffffff and inc %cx to increment the low 16 bits (by the way, this address needs to be at least 0x10000, to be above /proc/sys/vm/mmap_min_addr, and when running on a 64-bit system, userspace addresses can be above 0xc0000000 as there is no space reserved for the kernel).

This enables crafting a 14-byte quine in assembly code (using AT&T syntax):

49      dec %ecx
66 41   inc %cx         ; set ecx = 0xffff0000 (address)
b2 ab   mov $0xab,%dl   ; set edx = 171 (file size)
b0 04   mov $0x4,%al    ; set eax = 4
43      inc %ebx        ; set ebx = 1
cd 80   int $0x80       ; write(1, 0xffff0000, 171)

4b      dec %ebx        ; set ebx = 0
58      pop %eax        ; set eax = 1 by using argc from the stack
cd 80   int $0x80       ; exit(0)

When Linux launches a program, the stack is initialized with several values: the number of arguments, the arguments, the environment variables... (in C: {argc, argv[0], argv[1], ..., argv[argc-1], NULL, envp[0], ..., NULL}). As the test program is always run without any command-line argument in test_script.sh, argc = 1 and this value can be pop-ed from the stack to set a register to 1.

To test such a solution more easily, I wrote a Python script: solution_1_headers_code.py. This script defines some functions helping to define the content of the headers and the code while ensuring overlapping fields use the same values:

# ELF header
place_bytes(0, b"\x7fELF", "Elf32_Ehdr.e_ident = ELF magic")
place_u16(0x10, 2, "Elf32_Ehdr.e_type = ET_EXEC")
place_u16(0x12, 3, "Elf32_Ehdr.e_machine = EM_386")  # can also be EM_486 = 6
place_u32(0x18, image_base + code_offset, "Elf32_Ehdr.e_entry")
place_u32(0x1c, phdr_offset, "Elf32_Ehdr.e_phoff")
place_u16(0x2a, 0x20, "Elf32_Ehdr.e_phentsize")
place_u16(0x2c, 1, "Elf32_Ehdr.e_phnum")

# Program header
place_u32(phdr_offset + 0x00, 1, "Elf32_Phdr.p_type = PT_LOAD")
place_u32(phdr_offset + 0x04, 0, "Elf32_Phdr.p_offset")
place_u32(phdr_offset + 0x08, image_base, "Elf32_Phdr.p_vaddr")
place_u32(phdr_offset + 0x10, final_file_size, "Elf32_Phdr.p_filesz")
place_u32(phdr_offset + 0x14, final_file_size, "Elf32_Phdr.p_memsz")
place_u32(phdr_offset + 0x18, 5, "Elf32_Phdr.p_flags = PF_R | PF_X")

Knowing which bytes are defined enables displaying a hexadecimal dump of the quine with ?? to represent the undefined ones:

 000000:  7f45 4c46 ???? ???? ???? ???? ???? ????
 000010:  0200 0300 ???? ???? 4800 ffff 2c00 0000
 000020:  ???? ???? ???? ???? ???? 2000 0100 0000
 000030:  0000 0000 0000 ffff ???? ???? ab00 0000
 000040:  ab00 0000 0500 0000 4966 41b2 abb0 0443
 000050:  cd80 4b58 cd80

This first solution produces the following quinindrome (solution_1_headers_code.bin):

$ xxd solution_1_headers_code.bin
00000000: 7f45 4c46 0000 0000 0000 0000 0000 0000  .ELF............
00000010: 0200 0300 0000 0000 4800 ffff 2c00 0000  ........H...,...
00000020: 0000 0000 0000 0000 0000 2000 0100 0000  .......... .....
00000030: 0000 0000 0000 ffff 0000 0000 ab00 0000  ................
00000040: ab00 0000 0500 0000 4966 41b2 abb0 0443  ........IfA....C
00000050: cd80 4b58 cd80 cd58 4b80 cd43 04b0 abb2  ..KX...XK..C....
00000060: 4166 4900 0000 0500 0000 ab00 0000 ab00  AfI.............
00000070: 0000 00ff ff00 0000 0000 0000 0000 0100  ................
00000080: 2000 0000 0000 0000 0000 0000 0000 2cff   .............,.
00000090: ff00 4800 0000 0000 0300 0200 0000 0000  ..H.............
000000a0: 0000 0000 0000 0046 4c45 7f              .......FLE.

$ ./test_script.sh solution_1_headers_code.bin
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 171

There are still several undefined bytes in this file. Let's go further down!

 

💻 4. A very lax kernel loader

When analyzing the produced solution, somethings feels weird: the ELF header actually is invalid. Command file reports:

$ file solution_1_headers_code.bin
solution_1_headers_code.bin: ELF invalid class invalid byte order (SYSV), unknown class 0

A 32-bit ELF file usually starts with 0x7f, "ELF", 1, 1, 1 to indicate:

  • e_ident[EI_CLASS] = ELFCLASS32 (32-bit instruction set architecture)
  • e_ident[EI_DATA] = ELFDATA2LSB (data in little endian)
  • e_ident[EI_VERSION] = EV_CURRENT (version of the ELF specification)

Nevertheless these 3 bytes are not verified by the piece of code in Linux kernel responsible for loading ELF programs. Where is this code?

In Linux kernel, when a program is launched (through syscalls such as execve or execveat), several loaders may be used. They are implemented in files binfmt_....c in directory fs/:

binfmt_elf.c            for ELF files
binfmt_elf_fdpic.c      for FDPIC ELF files
binfmt_flat.c           for Flat files
binfmt_misc.c           to use /proc/sys/fs/binfmt_misc to invoke helper loaders
binfmt_script.c         for script files with shebang
compat_binfmt_elf.c     for 32-bit ELF files on 64-bit systems

The kernel actually supports other formats than ELF! But on x86-based systems, FDPIC ELF and Flat are not available (they are used on some ARM systems and some systems without MMU, according to Kconfig). This is confirmed in a Debian 13 virtual machine by inspecting the configuration of the kernel:

$ grep BINFMT /boot/config-6.12.57+deb13-amd64
CONFIG_BINFMT_ELF=y
CONFIG_COMPAT_BINFMT_ELF=y
CONFIG_BINFMT_SCRIPT=y
CONFIG_BINFMT_MISC=m

On a 64-bit system, loading 32-bit ELF programs is handled by fs/compat_binfmt_elf.c, which mostly defines some C macros and includes the main parser (cf. line 144):

This file implements function load_elf_binary, starting with:

Here is the code which checks the first 4 bytes of ELF programs, as well as fields e_type and e_machine!

In the same file, function load_elf_phdrs verifies some constraints related to fields e_phentsize and e_phnum:

So e_phentsize needs to be 0x20, e_phnum needs to actually counts the number of entries in the program header and e_phoff is used to locate the program header.

Fields e_entry is used to define where the execution starts and hence needs to be a valid memory address containing valid x86 instructions.

Would there be other constraints? To check, the Python script was modified to use random bytes where no constraints were defined:

Running ./solution_1_headers_code.py --random-elf --run helps confirming other fields in the ELF header are not actually used.

What about the program header?

  • Field p_type has to be PT_LOAD = 1.
  • Field p_paddr is not used.
  • Field p_align is only used when the program is getting relocated to another address. With fixed-position executable (e_type = ET_EXEC instead of ET_DYN), it is not used.
  • Only the 3 lowest significant bits of p_flags are used, to define the permissions of the segment (PF_X = 1 ; PF_W = 2 ; PF_R = 4). The 29 other bits can have any value.

Moreover, fields p_offset, p_vaddr, p_filesz and p_memsz follow some complex constraints. While the intuitive rules would be "p_offset = 0 to map the start of the file ; p_vaddr = base address to map the file at a given address ; p_filesz = p_memsz = size of file to map the whole file", in practice the implementation of functions load_elf_binary and elf_load is much more lenient:

  • p_filesz <= p_memsz: this ensures the memory region contains at least enough content from the file (the remaining content is filled with zeros).
  • eppnt->p_filesz != 0: this ensures the memory region is loaded from the file (with elf_map on line 408) instead of being filled with zeros.

This enables crafting an ELF program where the program header is located 4 bytes after the start of the file: solution_2_phdr4.py:

place_u32(phdr_offset + 0x00, 1, "Elf32_Phdr.p_type = PT_LOAD")
place_u32(phdr_offset + 0x04, 0, "Elf32_Phdr.p_offset")
place_u32(phdr_offset + 0x08, image_base, "Elf32_Phdr.p_vaddr")
place_u32(phdr_offset + 0x10, final_file_size, "Elf32_Phdr.p_filesz")
# e_entry and p_memsz are located at the same place
place_u32(phdr_offset + 0x14, image_base + code_offset, "Elf32_Phdr.p_memsz")
# e_phoff and p_flags are located at the same place
place_u8(phdr_offset + 0x18, 4, "Elf32_Phdr.p_flags = PF_R")

But this forces p_flags to be 4, so the file is no longer mapped with the execution bit set... or is it? Actually, with 32-bit x86 programs, when there is no PT_GNU_STACK entry in the program header, all segments are loaded with the executable bit! This is the magic of elf_read_implies_exec.

The ELF header takes 0x2e bytes and there is not much space to fit some code in it:

Hexdump:
 000000:  7f45 4c46 0100 0000 0000 0000 <vaddr  >
 000010:  0200 0300 <filesz > <entry  > 0400 0000
 000020:  ???? ???? ???? ???? ???? 2000 0100

To craft a quinindrome, the assembly code can be added right after and mirrored. Another way consists in directly considering the mirrored bytes and using them in the code. Using this strategy, I crafted a solution with 96 bytes (solution_2_phdr4.bin), using this 16-byte code:

04 04       add $4, %al           ; eax = 4
b9 00010020 mov $0x20000100, %ecx ; ecx = 0x20000100
b2 60       mov $0x60, %dl        ; edx = 96 (file size)
86 dd       xchg %bl, %ch         ; ebx = 1, ecx = 0x20000000
cd 80       int $0x80             ; write(1, 0x20000000, 96)
58          pop %eax              ; eax = 1
eb f9       jmp . - 5             ; jump to "xchg %bl, %ch"
            ; xchg %bl, %ch       ; ebx = 0
            ; int $0x80           ; exit(0)

This code involves several tricks, such as using xchg and jmp to reuse instructions to invoke syscalls (this saves one byte). This trick is documented for example on StackExchange. Moreover, the first mov instruction uses the mirrored bytes of the ELF header, and constraints the address where the file is loaded to 0x20000000.

$ xxd solution_2_phdr4.bin
00000000: 7f45 4c46 0100 0000 0000 0000 0000 0020  .ELF...........
00000010: 0200 0300 6000 0000 2f00 0020 0400 0000  ....`.../.. ....
00000020: 00f9 eb58 80cd dd86 60b2 2000 0100 b904  ...X....`. .....
00000030: 04b9 0001 0020 b260 86dd cd80 58eb f900  ..... .`....X...
00000040: 0000 0004 2000 002f 0000 0060 0003 0002  .... ../...`....
00000050: 2000 0000 0000 0000 0000 0001 464c 457f   ...........FLE.

$ ./test_script.sh solution_2_phdr4.bin
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 96

This solution has a single byte without any constraint (the one at 0x20, being p_align and e_shoff). This does not leave much room for improvement. How can the score be reduced even more?

 

📃 5. Gaining more freedom through page alignment

The implementation of function elf_map reveals something very interesting:

The call to vm_mmap does not use addr and size directly, but aligns values according to the size of a memory page, which is 4096 bytes on x86. The reason to do so is likely related to the way the MMU (Memory Management Unit) works in pages and needs to operate on addresses multiple of 4096.

In practice, this means it is possible to craft an ELF program with:

  • p_offset = 0x123 (this value needs to be 0 <= p_offset <= 0xfff)
  • p_vaddr = 0x20000123 (this value needs to be image_base + p_offset)
  • p_filesz = 1 (this value needs to be 1 <= p_filesz <= 0xfff and p_filesz <= p_memsz)

This can be tested with ./solution_2_phdr4.py --123 --run, which produced solution_2_phdr4_0x123.bin.

$ xxd solution_2_phdr4_0x123.bin
00000000: 7f45 4c46 0100 0000 2301 0000 2301 0020  .ELF....#...#..
00000010: 0200 0300 0100 0000 2f00 0020 0400 0000  ......../.. ....
00000020: 00f9 eb58 80cd dd86 60b2 2000 0100 b904  ...X....`. .....
00000030: 04b9 0001 0020 b260 86dd cd80 58eb f900  ..... .`....X...
00000040: 0000 0004 2000 002f 0000 0001 0003 0002  .... ../........
00000050: 2000 0123 0000 0123 0000 0001 464c 457f   ..#...#....FLE.

$ readelf --program-headers solution_2_phdr4_0x123.bin
readelf: Warning: The e_shentsize field in the ELF header is larger than the size of an ELF section header
readelf: Error: Reading 57263076 bytes extends past end of file for section headers

Elf file type is EXEC (Executable file)
Entry point 0x2000002f
There is 1 program header, starting at offset 4

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000123 0x20000123 0x00030002 0x00001 0x2000002f R   0x58ebf900

$ ./test_script.sh solution_2_phdr4_0x123.bin
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 96

Even though this program does not follow the ELF specification, it still managed to be loaded by Linux.

Attentive readers might wonder: even though p_filesz = 1, why was the whole file loaded in memory and not filled with zeros? Indeed, in usual ELF files, a memory segment is loaded and if p_filesz < p_memsz, some bytes are set to zero. This mechanism is used for example to define a single read-write segment with both sections .data and .bss and make the kernel initialize .bss with zeros.

However, function elf_load only set bytes to zero after p_vaddr + p_filesz, so using a value of p_vaddr which comes after the actual content (like 0x20000123, where the content stops at 0x20000060) makes the kernel not perform any zeroing. And even if p_offset and p_vaddr stayed reasonable (p_offset = 0, p_vaddr = 0x20000000), the actual logic of setting bytes to zero would have failed because the memory is mapped without the write permission (bit PF_W = 2 is missing from p_flags = 4). Therefore it is actually possible to use p_filesz = 1, or even any value between 1 and 0xfff, and have the ELF program still be executed.

With these new degrees of freedom, is it possible to shrink the solution even further? To answer this, I wrote a Python script which tried every file size (starting from 0x2e, the size of the ELF header) and every possible offset of the program header: search_size_phoff.py. For each combination, it crafted an ELF program and tried to validate all constraints required by Linux.

This program found a quinindrome pattern with 65 bytes and e_phoff = 4, displaying the possible values of some bytes:

Q(65=0x41.0x04) possible with holes [1, 4, 1, 2, 1, 2, 1, 4, 1]:
    0000:  7f45 4c46 0100 0000 ..v0 0000 .... ....
    0010:  0200 v100 0100 20.. v2v3 .... 0400 0000
    0020:  ..00 0000 04.. ..v3 v2.. 2000 0100 v100
    0030:  02.. .... ..00 00v0 ..00 0000 0146 4c45
    0040:  7f
    - v0 at 0x9 in {00,01,02,03,04,05,06,07,08,09,0a,0b,0c,0d,0e,0f}
    - v1 at 0x12 in {03,06}
    - v2 at 0x18 in {00,01,02,03,04,05,06,07,08,09,0a,0b,0c,0d,0e,0f,10,11,12,13,14,15,16,17,18,19,1a,1b,1c,1d,1e,1f,20,21,22,23,24,25,26,27,28,29,2a,2b,2c,2d,2e,2f,30,31,32,33,34,35,36,37,38,39,3a,3b,3c,3d,3e,3f,40}
    - v3 at 0x19 in {00,10,20,30,40,50,60,70,80,90,a0,b0,c0,d0,e0,f0}
    - e_entry  at 0x18 is 0x....v3v2
    - p_offset at 0x08 is 0x0000v0..
    - p_vaddr  at 0x0c is 0x........
    - p_filesz at 0x14 is 0x..200001
    - p_memsz  at 0x18 is 0x....v3v2
    - p_flags  at 0x1c is 0x00000004

The assembly code has to fit all the holes marked with dots in this representation. This seems impossible to achieve, as the largest hole is 4-byte wide. A new constraint was therefore added: there must be at least 8 consecutive bytes not covered by any constraints from the headers.

This led to finding a pattern with 77 bytes and e_phoff = 0x2c:

Q(77=0x4d.0x2c) possible with holes [12, 1, 1, 7, 1, 1, 12]:
    0000:  7f45 4c46 .... .... .... .... .... ....
    0010:  0200 v000 ..v1 00.. 2c00 00v2 2c00 0000
    0020:  0100 20.. .... .... .... 2000 0100 0000
    0030:  2cv2 0000 2c.. 00v1 ..00 v000 02.. ....
    0040:  .... .... .... .... ..46 4c45 7f
    - v0 at 0x12 in {03,06}
    - v1 at 0x15 in {00,01,02,03,04,05,06,07,08,09,0a,0b,0c,0d,0e,0f}
    - v2 at 0x1b in {00,01,02,03,04,05,06,07,08,09,0a,0b,0c,0d,0e,0f}
    - e_entry  at 0x18 is 0xv200002c
    - p_offset at 0x30 is 0x0000v22c
    - p_vaddr  at 0x34 is 0xv100..2c
    - p_filesz at 0x3c is 0x......02
    - p_memsz  at 0x40 is 0x........
    - p_flags  at 0x44 is 0x........

Having 12 bytes to craft some assembly code seems doable. However e_entry = 0x...002c, which means the execution has to start executing the bytes 01 00 00 00 2c ... (at file offset 0x2c). In 32-bit x86, 01 00 is decoded as instruction add %eax,(%eax), which loads some value from the memory location targeted by register eax. This register is initialized to zero, so the program starts by dereferencing a NULL pointer, which makes it crash. This is not a good way towards finding a working quinindrome.

Once the constraint "e_entry must not target bytes required to be 01 00" was added, the Python script identified a third pattern using 81 bytes:

Q(81=0x51.0x2c) possible with holes [12, 4, 1, 3, 1, 4, 12]:
    0000:  7f45 4c46 .... .... .... .... .... ....
    0010:  0200 v000 .... .... v1v2 ..v3 2c00 0000
    0020:  2c00 0000 0100 20.. .... 2000 0100 0000
    0030:  2c00 0000 2cv3 ..v2 v1.. .... ..00 v000
    0040:  02.. .... .... .... .... .... ..46 4c45
    0050:  7f
    - v0 at 0x12 in {03,06}
    - v1 at 0x18 in {00,01,02,03,04,05,06,07,08,09,0a,0b,0c,0d,0e,0f,10,11,12,13,14,15,16,17,18,19,1a,1b,1c,1d,1e,1f,20,21,22,23,24,25,26,27,28,29,2a,2b,2c,2d,2e,2f,30,31,32,33,34,35,36,37,38,39,3a,3b,3c,3d,3e,3f,40,41,42,43,44,45,46,47,48,49,4a,4b,4c,4d,4e,4f,50}
    - v2 at 0x19 in {00,10,20,30,40,50,60,70,80,90,a0,b0,c0,d0,e0,f0}
    - v3 at 0x1b in {00,10,20,30,40,50,60,70,80,90,a0,b0,c0,d0,e0,f0}
    - e_entry  at 0x18 is 0xv3..v2v1
    - p_offset at 0x30 is 0x0000002c
    - p_vaddr  at 0x34 is 0xv2..v32c
    - p_filesz at 0x3c is 0x00v000..
    - p_memsz  at 0x40 is 0x......02
    - p_flags  at 0x44 is 0x........

This time, e_entry no longer has any constraint and can target unconstrained bytes. This pattern imposes some relationships between e_entry and p_vaddr, which seem manageable. solution_3_minimal.py produces a quinindrome following this pattern and using this 20-byte code:

43          inc %ebx              ; ebx = 1
04 04       add $4, %al           ; eax = 4
b9 00030002 mov $0x02000300, %ecx ; ecx = 0x02000300
c1 e1 08    shl $8, %ecx          ; ecx = 0x00030000
45          inc %ebp              ; Have p_flags&7 = PF_R | PF_X
b2 51       mov $0x51, %dl        ; edx = 81 (file size)
cd 80       int $0x80             ; write(1, 0x00030000, 81)
4b          dec %ebx              ; ebx = 0
58          pop %eax              ; eax = 1
cd 80       int $0x80             ; exit(0)
Hexdump:
 000000:  7f45 4c46 80cd 584b 80cd 51b2 4508 e1c1
 000010:  0200 0300 b904 0443 3900 0300 2c00 0000
 000020:  2c00 0000 0100 20?? ???? 2000 0100 0000
 000030:  2c00 0000 2c00 0300 3943 0404 b900 0300
 000040:  02c1 e108 45b2 51cd 804b 58cd 8046 4c45
 000050:  7f

In this configuration, 3 bytes in the middle of the file do not have any constraint (because e_flags and e_ehsize are not used by the kernel). To make the solution a bit fun, I wrote a smiley.

$ xxd solution_3_minimal.bin
00000000: 7f45 4c46 80cd 584b 80cd 51b2 4508 e1c1  .ELF..XK..Q.E...
00000010: 0200 0300 b904 0443 3900 0300 2c00 0000  .......C9...,...
00000020: 2c00 0000 0100 205e 5f5e 2000 0100 0000  ,..... ^_^ .....
00000030: 2c00 0000 2c00 0300 3943 0404 b900 0300  ,...,...9C......
00000040: 02c1 e108 45b2 51cd 804b 58cd 8046 4c45  ....E.Q..KX..FLE
00000050: 7f                                       .

$ ./test_script.sh solution_3_minimal.bin
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 81

 

💥 6. (Bonus) It works on my machine!

Before submitting any solution to the challenge organizers, I made sure the test script was successful in a virtual machine running Debian 13. This should have prevented any situation where a solution worked for me but not on the organizers' side.

Unfortunately, this happened.

Here is a solution I sent which was problematic:

$ xxd solution_3_with_bug.bin
00000000: 7f45 4c46 80cd 584b 80cd 4308 e1c1 51b2  .ELF..XK..C...Q.
00000010: 0200 0300 b904 0490 3900 0300 2c00 0000  ........9...,...
00000020: 2c00 0000 0100 2000 0000 2000 0100 0000  ,..... ... .....
00000030: 2c00 0000 2c00 0300 3990 0404 b900 0300  ,...,...9.......
00000040: 02b2 51c1 e108 43cd 804b 58cd 8046 4c45  ..Q...C..KX..FLE
00000050: 7f                                       .

$ ./test_script.sh solution_3_with_bug.bin
[+] First check passed: binary is a byte-wise palindrome.
[+] Second check passed: binary is a true quine, its output matches itself.
[+] Both checks passed: your binary is a very nice quinindrome!
[+] Your score: 81

This uses similar code as the one presented as the minimal solution:

90          nop
04 04       add $4, %al           ; eax = 4
b9 00030002 mov $0x02000300, %ecx ; ecx = 0x02000300
b2 51       mov $0x51, %dl        ; edx = 81 (file size)
c1 e1 08    shl $8, %ecx          ; ecx = 0x00030000
43          inc %ebx              ; ebx = 1
cd 80       int $0x80             ; write(1, 0x00030000, 81)
4b          dec %ebx              ; ebx = 0
58          pop %eax              ; eax = 1
cd 80       int $0x80             ; exit(0)

Why would this program not work? readelf reports:

$ readelf --program-headers solution_3_with_bug.bin
...
Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x00002c 0x0003002c 0x04049039 0x300b9 0xc151b202   E 0xcd584b80

Field flags is displayed E, not R E. Indeed, in this program, p_flags = 0xe1 only has bit PF_X = 1 set, not PF_R = 4 (among the 3 least significant bits). On x86 processors, the Memory Management Engine (MMU) historically only had 2 bits to define permissions in page tables: W to enable write access and NX to prevent execution. The read access was always granted so long as the memory page was present in the page table of a process. This means there was no mechanism to implement "Execute-Only" or "Write-Only" memory in programs: memory regions were either Read-Only, Read-Write, Read-Execute, Read-Write-Execute or inaccessible.

This changed a few years ago where Intel VT-x introduced the ability to define Execute-Only pages in Extended Page Tables (EPT). However I was not aware of a mechanism enabling a mainline Linux kernel to use such a feature to mark userspace pages as Execute-Only.

Searching a bit what other feature could be involved, I stumbled upon PKU (Memory Protection Keys Userspace), documented on https://docs.kernel.org/core-api/protection-keys.html.

Memory Protection Keys provide a mechanism for enforcing page-based protections, but without requiring modification of the page tables when an application changes protection domains.

This feature enables making two threads of the same process able to access a different set of memory regions (a restricted thread can be configured in such a way it cannot access some regions ; a privileged thread can be configured to be the only one allowed to access some sensitive regions). It can also be used to disable the read permission of some memory regions.

In 2016, a feature was integrated in Linux 4.6 to make Execute-Only memory regions possible: https://github.com/torvalds/linux/commit/62b5f7d013fc455b8db26cf01e421f4c0d264b92

Protection keys provide new page-based protection in hardware. But, they have an interesting attribute: they only affect data accesses and never affect instruction fetches. That means that if we set up some memory which is set as "access-disabled" via protection keys, we can still execute from it.

This patch uses protection keys to set up mappings to do just that. If a user calls:

mmap(..., PROT_EXEC);

or

mprotect(ptr, sz, PROT_EXEC);

(note PROT_EXEC-only without PROT_READ/WRITE), the kernel will notice this, and set a special protection key on the memory. It also sets the appropriate bits in the Protection Keys User Rights (PKRU) register so that the memory becomes unreadable and unwritable.

Experimenting with QEmu shows that PKU is actually the reason the solution worked for me (on a virtual machine without PKU) but not for the organizers.

Here are some steps showing how to experimentally reproduce the issue.

  1. Download file debian-live-13.2.0-amd64-standard.iso from a Debian mirror server (for example https://debian.obspm.fr/debian-cd/13.2.0-live/amd64/iso-hybrid/)
  2. Create a directory named share and put the quinindrome in it: q.bin.
  3. Launch a QEMU virtual machine with a processor enabling every features (option -cpu max):
    qemu-system-x86_64 -enable-kvm -m 8G -cpu max -smp cores=4 -boot d \
        -drive file=debian-live-13.2.0-amd64-standard.iso,media=cdrom \
        -drive file=fat:rw:share/,format=raw,media=disk
  4. In the virtual machine, confirm that PKU is available:
    grep ' pku' /proc/cpuinfo && echo 'PKU is available'
  5. In the virtual machine, mount the shared directory and run the program with strace:
    sudo mount /dev/sda1 /mnt
    sudo apt install -y strace
    strace /mnt/q.bin
  6. Observe that syscall write fails with error EFAULT and that the program bytes are not actually written to the output: 
    execve("/mnt/q.bin", ["/mnt/q.bin"], 0x7ffce8c1a540 /* 20 vars */) = 0
    [ Process PID=1468 runs in 32 bit mode. ]
    write(1, 0x30000, 81)                   = -1 EFAULT (Bad address)
    exit(0)                                 = ?
    +++ exited with 0 +++
  7. Launch another virtual machine without PKU (option -cpu max,-pku):
    qemu-system-x86_64 -enable-kvm -m 8G -cpu max,-pku -smp cores=4 -boot d \
        -drive file=debian-live-13.2.0-amd64-standard.iso,media=cdrom \
        -drive file=fat:rw:share/,format=raw,media=disk
  8. Repeat instructions 4 and 5 and observe that syscall write now succeeds.

Moreover, using -cpu kvm64 on my test machine did not enable PKU and using -cpu kvm64,+pku did. Mystery solved! 🔍

 

🏁 Conclusion

Crafting an ELF file as small as possible is a pleasant way to explore how lenient the Linux kernel is, when parsing ELF files. This enabled discovering new tricks to craft programs which still get successfully loaded and executed but make usual tools fail:

$ file ./solution_3_minimal.bin
./solution_3_minimal.bin: ELF, unknown class 128

$ objdump -x ./solution_3_minimal.bin
objdump: ./solution_3_minimal.bin: file format not recognized

$ gdb ./solution_3_minimal.bin
...
"/vagrant/./solution_3_minimal.bin": not in executable format: file format not recognized

$ ./solution_3_minimal.bin | base64
f0VMRoDNWEuAzVGyRQjhwQIAAwC5BARDOQADACwAAAAsAAAAAQAgXl9eIAABAAAALAAAACwAAwA5
QwQEuQADAALB4QhFslHNgEtYzYBGTEV/

Thanks to the challenge authors and organizers for having organized such a fun event!

 

Synacktiv's solution

1. First Step: Excluding the Wrong Leads

There are two parts in this challenge. The first is to create an ELF palindrome that the Linux kernel will happily load; the second is to make it a quine. As described in IooNag's write‑up above, the Linux kernel does not check every field in the binary. The kernel loads the first page of the binary and pads the rest of the page with zeros. It then reads the ELF header at offset 0 and iterates over the program headers to determine what it needs to load.

Assuming the proposed solution will consist of a single segment containing the code, we can search for which program‑header offset will produce a potentially valid program. For example, only a handful of values are accepted for the `p_type` field of the program header, and 0x7f454c46 ('\x7fELF', the ELF magic bytes) is not one of them. Therefore, offset 0 cannot be used to place the program header.

By using this logic, we can construct a basic ELF header, a basic program header, and binary masks for each that indicate which bits are fixed. With these masks, we can determine whether a (binary size, program‑header offset) pair is potentially valid or certainly invalid.

 

elf_value = bytes.fromhex("7f454c46 010000000000000000000000 0200 0300 00000000 00000000 00000000 00000000 00000000 0000 2000 0100")
elf_mask = bytes.fromhex("ffffffff ff0000000000000000000000 ffff ffff 00000000 000f0000 00ffffff 00000000 00000000 0000 ffff ffff")
phr_value = bytes.fromhex("01000000 00000000 00000000 00000000 00000000 00000000 04000000 00000000")
phr_mask = bytes.fromhex("ffffffff ffffffff ff0f0000 00000000 00000000 00000000 04000000 00000000")

def test(val, mask, index, byte, bytemask):
    if index >= len(val) or index < 0:
        assert (byte & bytemask) == 0
    else:
        ov = val[index]
        om = mask[index]
        mask[index] |= bytemask
        val[index] |= bytemask & byte
        assert (val[index] & om) == (ov & om)
        assert (val[index] & bytemask) == (byte & bytemask)

def bind_palindrome(val, msk, index, bv, bm):
    for i, (x, m) in enumerate(zip(bv, bm)):
        test(val, msk, index + i, x, m)
        test(val, msk, len(val) - 1 - index - i, x, m)

def build_pal(full_size, hdr_off):
    m_elf_value = bytearray(elf_value)
    m_elf_mask = bytearray(elf_mask)
    m_elf_value[28] = hdr_off
    m_elf_mask[28] = 0xff

    res =  bytearray([0] * full_size)
    mask = bytearray([0] * full_size)

    bind_palindrome(res, mask, 0, m_elf_value, m_elf_mask)
    bind_palindrome(res, mask, hdr_off, phr_value, phr_mask)

    bind_palindrome(res, mask, hdr_off + 9, res[25:28], mask[25:28])
    bind_palindrome(res, mask, 25, res[hdr_off + 9:hdr_off + 12], mask[hdr_off + 9:hdr_off + 12])

    return res, mask

Using that code, we discover that offset 4 is potentially suitable for binary sizes from 65 bytes up to 69 bytes inclusive, and also for sizes 77 through 85. Offset 44 works for sizes 83 and 84, and offset 46 works for size 85.

The next plausible sizes are 89 and then 91.

2. Offset 44 in an 83‑byte ELF

The script above also lets us extract a global mask for the final file as well as a rough skeleton of the binary. Offset 44 together with a file size of 83 bytes has the nice property of having many consecutive unconstrained bytes starting from offset 5 (or offset 67 in the upper half).

7f454c46010000000000000000000400020003000000000000002c002c000000000000000000010020002000010000000000000000002c002c00000000000000030002000400000000000000000001464c457f
ffffffffff0000000000000000000400ffffffff0000000000ffff00ffffffffffffffffffffffffff00ffffffffffffffffffffffffff00ffff0000000000ffffffff0004000000000000000000ffffffffff

# ELF header
magic        7f454c46   ffffffff   [0, 1, 2, 3]         [82, 81, 80, 79]
padding1     01000000   ff000000   [4, 5, 6, 7]         [78, 77, 76, 75]
padding2     00000000   00000000   [8, 9, 10, 11]       [74, 73, 72, 71]
padding3     00000400   00000400   [12, 13, 14, 15]     [70, 69, 68, 67]
e_type       0200       ffff       [16, 17]             [66, 65]
e_machine    0300       ffff       [18, 19]             [64, 63]
e_version    00000000   00000000   [20, 21, 22, 23]     [62, 61, 60, 59]
e_entry      00002c00   00ffff00   [24, 25, 26, 27]     [58, 57, 56, 55]
e_phoff      2c000000   ffffffff   [28, 29, 30, 31]     [54, 53, 52, 51]
e_shoff      00000000   ffffffff   [32, 33, 34, 35]     [50, 49, 48, 47]
e_flags      00000100   ffffffff   [36, 37, 38, 39]     [46, 45, 44, 43]
e_ehsize     2000       ff00       [40, 41]             [42, 41]
e_phentsize  2000       ffff       [40, 39]             [42, 43]
e_phnum      0100       ffff       [38, 37]             [44, 45]

# Program header
p_type       01000000   ffffffff   [38, 37, 36, 35]     [44, 45, 46, 47]
p_offset     00000000   ffffffff   [34, 33, 32, 31]     [48, 49, 50, 51]
p_vaddr      00002c00   ffffff00   [30, 29, 28, 27]     [52, 53, 54, 55]
p_paddr      2c000000   ffff0000   [26, 25, 24, 23]     [56, 57, 58, 59]
p_filesz     00000000   000000ff   [22, 21, 20, 19]     [60, 61, 62, 63]
p_memsz      03000200   ffffff00   [18, 17, 16, 15]     [64, 65, 66, 67]
p_flags      04000000   04000000   [14, 13, 12, 11]     [68, 69, 70, 71]
p_align      00000000   00000000   [10, 9, 8, 7]        [72, 73, 74, 75]

3. The Quine Code

The available space in the chosen configuration consists of 11 free bytes (except for one byte that has a constrained bit), followed by 4 bytes after 02000300. The next byte, at offset 24, is free but corresponds to the least‑significant byte of the entry point. The proposed solution was originally positioned in the lower part of the file and was later moved to the upper part where the 11 free bytes are located after the constant 00030002.

A drawback of the chosen point is that it forces the load offset of our page because of the byte 2c at offset 26.

The shellcode that will be embedded must first set eax to 4, ebx to 1 (stdout), ecx to the start of the current page (0x2c0000, unless we change byte 27), edx to 83 (the size of the shellcode) perform a syscall, then set eax to 1, ebx to 0, and perform another syscall.

After initial attempts to fit this solution into the ELF, the shellcode's design was changed.

Midway through our free region we have the constant 00030002. We would like to ignore it by inserting as few bytes as possible before it. This can be done in a single byte by adding a push instruction (6a) before it or a cmp instruction. A push is not convenient as the 1 at the top of the stack could be handy later. A cmp is more convenient and would allow us to perform a conditional jump.

The loaded ELF file contains only one loadable segment, but during loading the kernel adds a memory area for the stack and a memory area for the vdso. The vdso area is always located at a very high address, and the stack is usually placed above our executable page.

This means that the lowest page loaded by the program is our ELF file. Any call to write with a non‑zero size and an offset lower than our ELF's one will therefore return -EINVAL in eax. The comparison performed with 00030002 allows us to brute‑force the address of our ELF file by comparing eax after the syscall. This enables us not not to have code to set the offset in ecx, which proved to be quite byte‑phage.

We do not yet know where our conditional jump will land, but we can start a stub shellcode:

.before_gap:
    ; 1 free byte
    int 0x80             # cd80
    cmp eax, 0x02000300  # 3d00030002
.after_gap:
    jc somewhere         # 72xx
    ; 9 free bytes

We will have to jump to int 0x80 if the conditional jump is not taken.

.before_gap:
    ; 1 free byte
    int 0x80             # cd80
    cmp eax, 0x02000300  # 3d00030002
.after_gap:
    jc somewhere         # 72xx
    jmp .before_gap      # ebxx
    ; 7 free bytes

eax is cleared during the write; the simplest way to restore it is to push 4 onto the stack (costing 2 bytes) and then pop it back into eax (1 byte).
We also need to add the instructions
mov 83, dl and inc ecx.

    ; 1 free byte
.before_gap:
    int 0x80             # cd80
    cmp eax, 0x02000300  # 3d00030002
.after_gap:
    jc somewhere         # 72xx
    push 4               # 6a04
    inc ecx              # 43
    pop eax              # 58
    mov dl, 83           # b253
    jmp .before_gap      # ebxx
    ; 1 free byte

We could try to use only a single int 0x80 instruction to save two bytes, and use that same call for the exit.

We can arrange for the conditional jump to be taken only if the write succeeded. In that case eax should contain the value 1 (instead of 4) and ebx should be zero. By skipping over the push 4 we can pop the 1 that's on the top of the stack rather than the 4, thus restoring the correct value in eax. The resulting code looks like:

    ; 1 free byte
.before_gap:
    int 0x80             # cd80
    cmp eax, 0x02000300  # 3d 00030002
.after_gap:
    jc somewhere         # 72xx
    push 4               # 6a04
.somewhere:
    inc ecx              # 43
    pop eax              # 58
    mov dl, 83           # b253
    jmp .before_gap      # ebxx
    ; 1 free byte

ebx is zero at the start of execution and must be 1 for the write call and 0 for the exit call. mov ebx, 1 takes 5 bytes, but we only have 2 bytes left to spend. inc ebx takes just one, as does dec ebx. By carefully arranging these instructions, we can ensure that ebx has the correct value at the points where the system calls are made:

.before_gap:
    int 0x80             # cd80
    dec ebx              # 4b
    cmp eax, 0x02000300  # 3d 00030002
.after_gap:
    jc somewhere         # 72xx
    push 4               # 6a04
    inc ebx              # 43
.somewhere:
    inc ecx              # 43
    pop eax              # 58
    mov dl, 83           # b253
    jmp .before_gap      # ebxx

The constrained bit 0x04 lands on the second byte of the jc somewhere instruction. By rearranging a few instructions, we can obtain the following code:

.before_gap:
    int 0x80             # cd80
    dec ebx              # 4b
    cmp eax, 0x02000300  # 3d 00030002
.after_gap:
    jc end               # 7204
    inc ebx              # 43
    push 4               # 6a04
    inc ecx              # 43
.end:
    pop eax              # 58
    mov dl, 83           # b253
    jmp .before_gap      # ebed

The entry point must be positioned on the inc ebx instruction so that ebx ends up with the intended values.

The final shellcode works and yields a solution that is not only valid but also deliberately slow, which is fun.

 

Alternative ranking

1. Command injection by XeR

The initial test script, published alongside the article introducing this challenge, contained a vulnerability that did not escape XeR's keen eye. The user-supplied filename was incorporated, without adequate safeguards, into the construction of the Podman image used to containerize the binary's execution:

binary=$1

[...]

cat > "$containerfile" <<EOF
FROM scratch
COPY $binary /binary
CMD ["/binary"]
EOF

As a result, command injection into the Containerfile was achievable by defining a specific filename. To leverage this flaw, XeR submitted an archive containing the following file:

*# b\nENV PATH=.\nENTRYPOINT ["b", "", "", "", "", "", "",  "j@X4AP[h@@@@X%  @ PYjCZjDX4@<0340>K4B<0340>"]\n#.

Here is the resulting Containerfile, where lines 2 through 5 were injected: :

1. FROM scratch
2. COPY *# b
3. ENV PATH=.
4. ENTRYPOINT ["b", "", "", "", "", "", "",  "j@X4AP[h@@@@X%  @ PYjCZjDX4@<0340>K4B<0340>"]
5. #/binary
6. CMD ["/binary"]

The COPY *# b command on line 2 targets the binary, identified by a trailing '#', and copies it to the image's root directory as 'b'.

Since the '/' character is prohibited in filenames due to its role as a path separator, specifying an ENTRYPOINT with an absolute path ('/b') was unfeasible. Consequently, line 3 defines the PATH environment variable as the current directory to ensure the binary is discoverable.

Line 4 specifies the ENTRYPOINT, where the 7th argument is the shellcode. As with the other solutions, this shellcode invokes the write and exit syscalls to satisfy the challenge criteria. By implementing these clever workarounds, XeR achieved a score of 67, validating the test script (cf. his solution):

$ hexdump -C \*\#\ b$'\n'ENV\ PATH=.$'\n'ENTRYPOINT\ \[\"b\",\ \"\",\ \"\",\ \"\",\ \"\",\ \"\",\ \"\",\ \ \"j@X4AP\[h@@@@X%\ \ @\ PYjCZjDX4@<0340>K4B<0340>\"\]$'\n'\#
00000000  7f 45 4c 46 01 00 00 00  00 00 00 00 00 00 40 00  |.ELF..........@.|
00000010  02 00 03 00 00 00 01 00  20 00 40 00 04 00 00 00  |........ .@.....|
00000020  61 c3 61 00 00 00 04 00  40 00 20 00 01 00 00 00  |a.a.....@. .....|
00000030  03 00 02 00 40 00 00 00  00 00 00 00 00 00 01 46  |....@..........F|
00000040  4c 45 7f                                          |LE.| 

The test script was subsequently updated to fix this vulnerability.

 

2. Mapping the segment at address 0x00 by matt

matt shared this interesting solution with us:

$ hexdump -C matt.bin 
00000000  7f 45 4c 46 01 db eb 39  43 8a 4d b2 02 8a 4d b2  |.ELF...9C.M...M.|
00000010  02 00 03 00 5b 00 00 00  2c 00 00 00 2c 00 00 00  |....[...,...,...|
00000020  01 00 20 40 cd 80 5b 80  cd 40 20 00 01 00 00 00  |.. @..[..@ .....|
00000030  2c 00 00 00 2c 00 00 00  5b 00 03 00 02 b2 4d 8a  |,...,...[.....M.|
00000040  02 b2 4d 8a 43 39 eb db  01 46 4c 45 7f           |..M.C9...FLE.|

For this to work, the --privileged flag must be added to the podman run command, and the script must be executed with sudo. Indeed, Matt noticed that within a sufficiently privileged context, the base memory address for the segment could be mapped to 0x00 (with a 0x2c offset), allowing the binary size to be shrunk to a mere 77 bytes.
Although the submission wasn't officially accepted, since it requires changes to the test script, we still congratulate him for such a score!

 

3. Nighttime Haiku by Rick

To conclude this alternative ranking, here is a poem from rick, which seems to have been inspired by the late hours spent tackling this quinindrome:

Au fond de la nuit,
J'écris des octets sur Vim,
Un petit binaire.

 

Conclusion

Thanks again to all entrants, for the time and creativity they shared during this competition.

See you this summer for the next challenge!

 

Engage le jeu, que je le gagne !