Skip to content

mcountryman/min-sized-rust-windows

Repository files navigation

Minimum Binary Size Windows

CI

The smallest hello world I could get on win10 x64 in rust. This isn't something meant to be used in production, more of a challenge. I'm in no ways an expert and I have seen windows binaries get smaller on windows. [2] If you can go smaller let me know how you did it 😁

Results

464b 😎

❯ cargo +nightly install anonlink
❯ anonlink
❯ cargo +nightly run --release
Hello World!

❯ cargo +nightly build --release && (Get-Item ".\target\release\min-sized-rust-windows.exe").Length
   Compiling min-sized-rust-windows v0.1.0 (**\min-sized-rust-windows)
    Finished release [optimized] target(s) in 1.33s
464

Strategies

I'm excluding basic strategies here such as enabling lto and setting opt-level = 'z'. [0]

  • no_std
  • no_main
  • Merge .rdata and .pdata sections into .text section linker flag. [1]
    • Using the LINK.exe /MERGE
      flag found at the bottom of main.rs.
    • Section definitions add more junk to the final output, and I believe they have a min-size. For this example we really don't care about readonly data (.rdata) or exception handlers (.pdata) so we "merge" these empty sections into the .text sections.
  • No imports.
    • To avoid having an extra .idata section (more bytes and cannot be merged into .text section using LINK.exe) we do the following.
    • Resolve stdout handle from PEB's process parameters (thanks ChrisSD). [3][4]
    • Invoke NtWriteFile/ZwWriteFile using syscall 0x80. [5][6]
      1. This is undocumented behaviour in windows, syscalls change over time. [5]
      2. I can't guarantee this will work on your edition of windows.. it's tested on my local machine (W10) and on GH actions (windows-2022 and windows-2019) server editions.
  • Custom LINK.exe stub.
    • A custom built stub created to remove Rich PE header. More information can be found here.
    • Credits to @Frago9876543210 for finding, and implementing this.
  • Drop debug info in pe header.
    • Add /EMITPOGOPHASEINFO /DEBUG:NONE flags.
    • Credits to @Frago9876543210 for finding, and implementing this.

Future

  • Using strategies shown in [2] we could post process the exe and merge headers to get closer to the 600-500b mark although we start straying away from the goal of this project.
  • Provided the call signature of ZwWriteFile I could use build.rs to make a script to dynamically resolve the syscall number from ntdll using something like iced-x86.
  • Go pure assembly (drop type definitions for PEB).

References

  1. https://github.com/johnthagen/min-sized-rust
  2. www.catch22.net/tuts/win32/reducing-executable-size#use-the-right-linker-settings
  3. https://github.com/pts/pts-tinype
  4. https://news.ycombinator.com/item?id=25266892 (Thank you anonunivgrad & ChrisSD!)
  5. https://processhacker.sourceforge.io/doc/struct___r_t_l___u_s_e_r___p_r_o_c_e_s_s___p_a_r_a_m_e_t_e_r_s.html
  6. https://j00ru.vexillium.org/syscalls/nt/64/
  7. https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntwritefile

Credits

  • @Frago9876543210 - Brought binary size from 760b -> 600b 😁
  • @Frago9876543210 - Brought binary size from 600b -> 560b 😁
  • @ironhaven - Brought binary size from 560b -> 536b 😁
  • @StackOverflowExcept1on - Brought binary size from 536b -> 464b 😁