Jan's Blog!

(Draft) Smallest Echo, Part 2: x86 Assembly

Notice: Draft

I’ll publish it once its finished thanks :)


Hi all, last time we left off on our journey toward the smallest implementation of an echo binary for linux we where using Zig! Check out Part 1 if you haven’t already. At the end of the post I concluded at 277 bytes. It was at this point I concluded that we needed to leave the final niceties from the realm of mortal programming languages behind. But before we start venturing deep into the pages of the x86 Software Developers Manual lets see if we can cling on to the final shreds of hope Zig can provide for us. By shreds of hope I meant assembly and by providing I meant flexing with your radare2 skills and running r2 -qc'af' -c'pdf' on your binary. You could also go the convienient way by running your Zig code through Godbolt1 and going from there. The radare2 way comes with the advantage of printing you some gourgeous comments for every line of assembly. After cleaning the assembly up a bit (naming things and commenting) I ended up with the following file:

182 bytes

[bits 32]
global _start
section .text

_start:                                         ; bytes      ; r2 comment
        mov     esi, dword [esp]                ; 8b3424     ; 005-32bit.zig:4 pub export fn _start() callconv(.Naked) noreturn {
        xor     ebp, ebp                        ; 31ed       ;
        mov     ebx, 1                          ; bb01000000 ; i386.zig:31     return asm volatile ("int $0x80"
        lea     edi, [esi - 1]                  ; 8d7eff     ; 005-32bit.zig:11     for (argv[1..argc]) |arg, i| {
        add     esi, -2                         ; 83c6fe     ; 005-32bit.zig:0
.loop:  ; for every argv
        cmp     ebp, edi                        ; 39fd       ; 005-32bit.zig:11     for (argv[1..argc]) |arg, i| {
        je      .exit                           ; 7422       ;
        mov     ecx, dword [esp + 4*ebp + 8]    ; 8b4cac08   ;
        xor     edx, edx                        ; 31d2       ; 005-32bit.zig:0
.strlen: ; increment edx until we find a 0 byte
        cmp     byte [ecx + edx], 0             ; 803c1100   ; mem.zig:710     while (ptr[i] != sentinel) {
        lea     edx, [edx + 1]                  ; 8d5201     ; mem.zig:711         i += 1;
        jne     .strlen                         ; 75f7       ; mem.zig:710     while (ptr[i] != sentinel) {
        mov     byte [ecx + edx - 1], ' '       ; c64411ff20 ; 005-32bit.zig:13         arg[len] = ' '; ; [0x20:1]=255 ; 32
        mov     eax, 4                          ; b804000000 ; i386.zig:31     return asm volatile ("int $0x80"
        cmp     ebp, esi                        ; 39f5       ; 005-32bit.zig:15         if (i == argc - 2) break;
        lea     ebp, [ebp + 1]                  ; 8d6d01     ; 005-32bit.zig:11     for (argv[1..argc]) |arg, i| {
        int     0x80                            ; cd80       ; i386.zig:31     return asm volatile ("int $0x80"
        jne     .loop                           ; 75da       ; 005-32bit.zig:15         if (i == argc - 2) break;
.exit:	; Write newline and call exit(0)
        mov     eax, 4                          ; b804000000 ; i386.zig:31     return asm volatile ("int $0x80"
        mov     ebx, 1                          ; bb01000000 ;
        mov     ecx, newline                    ; b9b4004000 ; 0x4000b4 ; "\n"
        mov     edx, 1                          ; ba01000000 ;
        int     0x80                            ; cd80       ;
        mov     eax, 1                          ; b801000000 ; i386.zig:12     return asm volatile ("int $0x80"
        xor     ebx, ebx                        ; 31db       ;
        int     0x80                            ; cd80       ;

newline:
        db      0x0a

If you can build the above code as follows yasm -felf32 -o echo.o 006-start.asm && ld -m elf_i386 -s -n -Ttext-segment=0x10000 -o echo echo.o && sstrip -z echo. This will leave you with a binary 182 bytes in size. But wait you ask, since you so nicely provided the dissassembled bytes next to the instructions, when I add those all up I only get 86 bytes? Good question! Let’s inspect the binary! (xxd echo)

00000000: 7f45 4c46 0101 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 0300 0100 0000 6000 0100 3400 0000  ........`...4...
00000020: 0000 0000 0000 0000 3400 2000 0100 2800  ........4. ...(.
00000030: 0000 0000 0100 0000 6000 0000 6000 0100  ........`...`...
00000040: 6000 0100 5600 0000 5600 0000 0500 0000  `...V...V.......
00000050: 1000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 8b34 2431 edbb 0100 0000 8d7e ff83 c6fe  .4$1.......~....
00000070: 39fd 7422 8b4c ac08 31d2 803c 1100 8d52  9.t".L..1..<...R
00000080: 0175 f7c6 4411 ff20 b804 0000 0039 f58d  .u..D.. .....9..
00000090: 6d01 cd80 75da b804 0000 00bb 0100 0000  m...u...........
000000a0: b9b5 0001 00ba 0100 0000 cd80 b801 0000  ................
000000b0: 0031 dbcd 800a                           .1....

As you can see, the first instruction mov esi, dword [esp] hex 8b3424 appears at address 0x60, that means that there are 96 bytes before it that are not cpu instructions but that still contribute to the binary size. If we want the smallest binary possible, we need to be able to controll every byte in our binary and letting our assembler and linker make the elf header for us is just not feasable anymore. Luckily yasm has the option to emit binaries directly via the -f bin flag. This also omits the need for a linker and our sstrip utility. On the other hand, this tasks us with reading the elf specification (Wikipedia has a good summary). The smallest elf header I could find online was described in this excellent essay coincedentally written by the same talented author as the ELFKicker tools. (from which I used the sstrip utility). I adapted his findings slightly to fit my program. I also made a template with just a program that returns 42 if you want to give size coding a try. You can find the template here:


  1. Or as some prefer to call it: Compiler Explorer ↩︎