/evm.elf

Primary LanguageAssembly

evm.elf

What if one could write code which works on-chain and off-chain without changes?

This snippet works both on Linux and Ethereum EVM:

7f454c46010000000000000000004305020003001a0043051a00430504000000eb15604556000000000020000100000000000000000000b947004305b20dcd80340d93cd805b6c68656c6c6f2c20776f726c640a3d52600c6013f3

On Linux (x86) it prints:

$ ./evm.elf
hello, world

On EVM it returns hello, world, too.

Try it out on jslinux in the browser by uploading the file (use xxd -ps -r 7f454c... to make it a binary). Remember not to trust random binaries from a website on your machine.

What?!

So, how on earth did I end up doing this? Optimizing binary output size has a long history. For shortest "Hello World" this thread is a good way to get thrown into the rabbit hole. Or another specific one for ELF files, the format used on most POSIX systems, such as Linux (but not macOS). A well known resource on optimizing ELF files, with a lot of good explainers, is The Teensy Files by breadbox.

How?

I started off with this version:

;; hello.asm: Copyright (C) 2001 Brian Raiter <breadbox@muppetlabs.com>
;; Licensed under the terms of the GNU General Public License, either
;; version 2 or (at your option) any later version.
;;
;; To build:
;;	nasm -f bin -o hello hello.asm && chmod +x hello

BITS 32

	org	0x05430000

	db	0x7F, "ELF"
	dd	1
	dd	0
	dd	$$
	dw	2
	dw	3
	dd	_start
	dw	_start - $$
_start:		inc	ebx			; 1 = stdout file descriptor
	add	eax, strict dword 4	; 4 = write system call number
	mov	ecx, msg		; Point ecx at string
	mov	dl, 13			; Set edx to string length
	int	0x80			; eax = write(ebx, ecx, edx)
	and	eax, 0x10020		; al = 0 if no error occurred
	xchg	eax, ebx		; 1 = exit system call number
	int	0x80			; exit(ebx)
msg:		db	'hello, world', 10

;; This is how the file looks when it is read as an (incomplete) ELF
;; header, beginning at offset 0:
;;
;; e_ident:	db	0x7F, "ELF"			; required
;;		db	1				; 1 = ELFCLASS32
;;		db	0				; (garbage)
;;		db	0				; (garbage)
;;		db	0				; (garbage)
;;		db	0x00, 0x00, 0x00, 0x00		; (unused)
;;		db	0x00, 0x00, 0x43, 0x05
;; e_type:	dw	2				; 2 = ET_EXE
;; e_machine:	dw	3				; 3 = EM_386
;; e_version:	dd	0x0543001A			; (garbage)
;; e_entry:	dd	0x0543001A			; program starts here
;; e_phoff:	dd	4				; phdrs located here
;; e_shoff:	dd	0x430031B9			; (garbage)
;; e_flags:	dd	0xCD0DB205			; (unused)
;; e_ehsize:	dw	0x2580				; (garbage)
;; e_phentsize:	dw	0x20				; phdr entry size
;; e_phnum:	dw	1				; one phdr in the table
;; e_shentsize:	dw	0xCD93				; (garbage)
;; e_shnum:	dw	0x6880				; (garbage)
;; e_shstrndx:	dw	0x6C65				; (garbage)
;;
;; This is how the file looks when it is read as a program header
;; table, beginning at offset 4:
;;
;; p_type:	dd	1				; 1 = PT_LOAD
;; p_offset:	dd	0				; read from top of file
;; p_vaddr:	dd	0x05430000			; load at this address
;; p_paddr:	dd	0x00030002			; (unused)
;; p_filesz:	dd	0x0543001A			; too big, but ok
;; p_memsz:	dd	0x0543001A			; equal to file size
;; p_flags:	dd	4				; 4 = PF_R
;; p_align:	dd	0x430031B9			; (garbage)
;;
;; Note that the top two bytes of the file's origin (0x43 0x05)
;; correspond to the instructions "inc ebx" and the first byte of "add
;; eax, IMM".
;;
;; The fields marked as unused are either specifically documented as
;; not being used, or not being used with 386-based implementations.
;; Some of the fields marked as containing garbage are not used when
;; loading and executing programs. Other fields containing garbage are
;; accepted because Linux currently doesn't examine then.

(Quick explainer: db refers to verbatim bytes, dw to verbatim words (16-bits), and dd to verbatim double words (32-bits))

We are super lucky with the ELF header as it starts with 0x7F. This happens to be the opcode for PUSH32, meaning the first 33 bytes of an ELF file can be ignored from the EVM perspective.

PUSH32 0x454c46010000000000000000004305020003001a0043051a00430504000000b9
BALANCE
STOP
...

Nice, but how do we avoid the STOP instruction and cram in some code there? The simplest approach is inserting an x86 jump instruction to skip over our EVM code.

Upon careful inspection of the ELF header description in the source code, it turns out there is a single instruction byte following the header in the PUSH32 data. The 0xB9 byte at the end corresponds to the mov ecx, msg instruction (MOV). Let's replace it with a JMP! Not so fast, the shortest encoding requires two bytes:

Opcode Description
0xEB offset Jump short, RIP = RIP + 8-bit displacement sign extended to 64-bits

What if we chose the offset such that it corresponds to a tame EVM instruction? Remember that so far we are pushing a single item to the EVM stack, and so we need an instruction which isn't terminating and consumes at most a single stack item. There are many such instructions (quick ref):

Opcode Instruction
0x15 iszero
0x19 not
0x30 address
... all the other context reading instructions
0x35 calldataload
0x3B extcodesize
... many others
0x50 pop
0x5B jumpdest
0x80 dup1

So many options:

  • jumpdest is partically a no-op if run into,
  • pop would clean up the stack,
  • calldataload just returns 0's on out of bounds (and our push value is substantial),
  • extcodesize would return non-zero value if an account exists there,
  • but iszero in our case always ensures we have a zero on the stack, which is useful.

Let us just go with iszero:

_start:         inc     ebx                     ; 1 = stdout file descriptor
                add     eax, strict dword 4     ; 4 = write system call number
                db      0xeb                    ; short jump 21 bytes forward
                db      0x15                    ; EVM: ISZERO
                ;; There are 21 bytes available here
                dd      0
                dd      0
                dd      0
                dd      0
                dd      0
                dd      0
                db      0
                mov     ecx, msg                ; Point ecx at string
                mov     dl, 13                  ; Set edx to string length
                int     0x80                    ; eax = write(ebx, ecx, edx)
                and     eax, 0x10020            ; al = 0 if no error occurred
                xchg    eax, ebx                ; 1 = exit system call number
                int     0x80                    ; exit(ebx)
msg:            db      'hello, world', 10

x86 will skip ahead and the EVM will have the value 0 (after iszero) on the stack. Now we have 21 bytes to play with. Will that be enough to fit our EVM code?

Not so fast of course, there's one caveat here. It turns out the and eax, 0x10020 line is important, because its payload represents two fields in the header. Did I forgot to mention that the ELF code I started out with is already hyper-optimized and reuses certain parts of the header as code? Use this inspector tool to play around ELF files.

With this in mind, our 21 bytes looks like this:

                ;; There are 21 bytes available here
                dd      0
                dd      0
                dw      0x20
                dw      0x01
                dd      0
                dd      0
                db      0

At this point the code remains fully operational on x86 and works in EVM with a clean termination:

PUSH32 0x454c46010000000000000000004305020003001a0043051a00430504000000eb
ISZERO
STOP

What do we even want to do in EVM? Probably something like this:

6C..  push13 "hello, world"
3D    returndatasize
52    mstore
600c  push1 12
6014  push1 20
F3    return

It clearly won't fit into the 8 or 9 bytes around the required header fields. There are two paths we can take:

  • use a different jump offset to have more free space,
  • or jump to the end of the file and ignore some of the 21 bytes.

I decided for the latter for a secondary reason: to reuse the hello, world text already present there. Now our code looks like this:

_start:         inc     ebx                     ; 1 = stdout file descriptor
                add     eax, strict dword 4     ; 4 = write system call number
                db      0xeb                    ; short jump 21 bytes forward
                db      0x15                    ; EVM: ISZERO
                ;; There are 21 bytes available here
                db      0x60, 69                ; EVM: PUSH1 69
                db      0x56                    ; EVM: JUMP
                db      0
                dd      0
                dw      0x20
                dw      0x01
                dd      0
                dd      0
                db      0
                mov     ecx, msg                ; Point ecx at string
                mov     dl, 13                  ; Set edx to string length
                int     0x80                    ; eax = write(ebx, ecx, edx)
                xor     al, 13                  ; "error code" 0 if exact amount was written
                xchg    eax, ebx                ; 1 = exit system call number
                int     0x80                    ; exit(ebx)
                ;; This is the absolute offset 69 the EVM jumps to
                db      0x5b                    ; EVM: JUMPDEST
msg:            db      'hello, world', 10

Notice that we simplified the cryptic and eax, 0x10020 statement as it isn't needed anymore for header-matching.

The next step is to insert our EVM code around the text there:

                ;; This is the absolute offset 69 the EVM jumps to
                db      0x5b                    ; EVM: JUMPDEST
                db      0x6c                    ; EVM: PUSH13
msg:            db      'hello, world', 10
                db      0x3d                    ; EVM: RETURNDATASIZE
                db      0x52                    ; EVM: MSTORE
                db      0x60, 12                ; EVM: PUSH1 12 (avoid the new line character)
                db      0x60, 19                ; EVM: PUSH1 19 (offset to string)
                db      0xf3                    ; EVM: RETURN

Also remember that we already have the value 0 on the stack from the header, and so we could replace the returndatasize instruction with swap1. It would cost slightly more gas, but no stray items would be left on the stack.

The final proof of concept is the following:

;; evm.elf is an experiment to make the same bytecode executable as ELF and EVM.
;; Source code and explanation to be found at https://github.com/axic/evm.elf
;; (C) Alex Beregszaszi
;;
;; Based on:
;; hello.asm: Copyright (C) 2001 Brian Raiter <breadbox@muppetlabs.com>
;; Licensed under the terms of the GNU General Public License, either
;; version 2 or (at your option) any later version.
;;
;; To build:
;;      nasm -f bin -o hello hello.asm && chmod +x hello
;; To use as EVM:
;;      xxd -ps -c 1000 hello > hello.evm

BITS 32

                org     0x05430000

                db      0x7F, "ELF"
                dd      1
                dd      0
                dd      $$
                dw      2
                dw      3
                dd      _start
                dw      _start - $$
_start:         inc     ebx                     ; 1 = stdout file descriptor
                add     eax, strict dword 4     ; 4 = write system call number
                db      0xeb                    ; short jump 21 bytes forward
                db      0x15                    ; EVM: ISZERO
                ;; There are 21 bytes available here
                db      0x60, 69                ; EVM: PUSH1 69
                db      0x56                    ; EVM: JUMP
                db      0
                dd      0
                dw      0x20
                dw      0x01
                dd      0
                dd      0
                db      0
                mov     ecx, msg                ; Point ecx at string
                mov     dl, 13                  ; Set edx to string length
                int     0x80                    ; eax = write(ebx, ecx, edx)
                xor     al, 13                  ; "error code" 0 if exact amount was written
                xchg    eax, ebx                ; 1 = exit system call number
                int     0x80                    ; exit(ebx)
                ;; This is the absolute offset 69 the EVM jumps to
                db      0x5b                    ; EVM: JUMPDEST
                db      0x6c                    ; EVM: PUSH13
msg:            db      'hello, world', 10
                db      0x3d                    ; EVM: RETURNDATASIZE
                db      0x52                    ; EVM: MSTORE
                db      0x60, 12                ; EVM: PUSH1 12 (avoid the new line character)
                db      0x60, 19                ; EVM: PUSH1 19 (offset to string)
                db      0xf3                    ; EVM: RETURN

When assembled this is 91 bytes total. The input we started with was 62 bytes. Not too bad for a simple approach.

There is a nice benefit of keeping those zeroes in the middle of the code, they actually correspond to other ELF headers, which in this case are "more compliant" then the code we started with.

What next?

There's so much room for improvement here. Too many zero bytes, not enough overlapping (reused) bytes, etc. One could add ABI-compatible output (the length needs to be prepended). Turn this into a quine. Use nasm labels for all the offsets/lengths. Rewrite from nasm to etk, yul, or huff. Make use of the 0x20 header byte as it corresponds to keccak256. The possibilities are endless.

Anon, what will you do next?

P.S. The labels are a simple change, but make it harder to understand the EVM part:

-               db      0xeb                    ; short jump 21 bytes forward
-               db      0x15                    ; EVM: ISZERO
+               jmp     elf_main                ; EVM: ISZERO

Appendix: EVM disassembly

   0:   push32 0x454c46010000000000000000004305020003001a0043051a00430504000000eb
  21:   iszero
  22:   push1 0x45
  24:   jump
  25:   stop
  26:   stop
  27:   stop
  28:   stop
  29:   stop
  2a:   keccak256
  2b:   stop
  2c:   add
  2d:   stop
  2e:   stop
  2f:   stop
  30:   stop
  31:   stop
  32:   stop
  33:   stop
  34:   stop
  35:   stop
  36:   stop
  37:   invalid_b9
  38:   selfbalance
  39:   stop
  3a:   number
  3b:   sdiv
  3c:   invalid_b2
  3d:   invalid_0d
  3e:   invalid_cd
  3f:   dup1
  40:   callvalue
  41:   invalid_0d
  42:   swap4
  43:   invalid_cd
  44:   dup1
  45:   jumpdest
  46:   push13 0x68656c6c6f2c20776f726c640a
  54:   returndatasize
  55:   mstore
  56:   push1 0x0c
  58:   push1 0x13
  5a:   return