├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── build.rs ├── src └── main.rs └── stub.exe /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | build: 15 | name: Build project 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | matrix: 20 | os: 21 | - windows-2022 22 | - windows-2019 23 | toolchain: 24 | - stable 25 | - nightly 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Cargo cache 32 | uses: Swatinem/rust-cache@v2 33 | 34 | - name: Setup ${{ matrix.toolchain }} toolchain 35 | uses: dtolnay/rust-toolchain@master 36 | with: 37 | toolchain: ${{ matrix.toolchain }} 38 | components: clippy, rustfmt 39 | 40 | - name: Patch MSVC linker 41 | run: | 42 | cargo install anonlink 43 | anonlink 44 | 45 | - name: Cargo build 46 | run: cargo build --release 47 | 48 | - name: Cargo clippy 49 | run: cargo clippy --release -- -D warnings 50 | 51 | - name: Cargo fmt 52 | run: cargo fmt -- --check 53 | 54 | - name: Test execution 55 | run: | 56 | $target = Get-Location 57 | $job = Start-Job -ScriptBlock { 58 | param ($target) 59 | & "${target}\target\release\min-sized-rust-windows.exe" 60 | } -ArgumentList $target 61 | 62 | $state = Wait-Job $job -Timeout 10 63 | $out = Receive-Job $job 64 | 65 | if ($out -ne "Hello World!") { 66 | Throw "Output did not equal ``Hello World!``, got ``$out``." 67 | } 68 | 69 | - name: Get size 70 | run: (Get-Item ".\target\release\min-sized-rust-windows.exe").Length 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .idea/ 3 | target/ -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 90 2 | tab_spaces = 2 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "iced-x86" 7 | version = "1.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b" 10 | dependencies = [ 11 | "lazy_static", 12 | ] 13 | 14 | [[package]] 15 | name = "lazy_static" 16 | version = "1.4.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 19 | 20 | [[package]] 21 | name = "min-sized-rust-windows" 22 | version = "0.1.0" 23 | dependencies = [ 24 | "iced-x86", 25 | ] 26 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "min-sized-rust-windows" 3 | version = "0.1.0" 4 | authors = ["Marvin Countryman "] 5 | edition = "2021" 6 | 7 | [profile.dev] 8 | panic = "abort" 9 | 10 | [profile.release] 11 | lto = true 12 | strip = "symbols" 13 | debug = false 14 | panic = "abort" 15 | opt-level = "z" 16 | codegen-units = 1 17 | 18 | [build-dependencies] 19 | iced-x86 = { version = "1.21", default-features = false, features = ["std", "decoder"] } 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimum Binary Size Windows 2 | [![CI](https://github.com/mcountryman/min-sized-rust-windows/actions/workflows/ci.yml/badge.svg)](https://github.com/mcountryman/min-sized-rust-windows/actions/workflows/ci.yml) 3 | 4 | The smallest hello world I could get on win10 x64 in rust. This isn't something meant to 5 | be used in production, more of a challenge. I'm in no ways an expert and 6 | [I have seen windows binaries get smaller on windows](https://github.com/pts/pts-tinype). [2] 7 | If you can go smaller let me know how you did it :grin: 8 | 9 | ### Results 10 | `464b` :sunglasses: 11 | 12 | ```powershell 13 | ❯ cargo +nightly install anonlink 14 | ❯ anonlink 15 | ❯ cargo +nightly run --release 16 | Hello World! 17 | 18 | ❯ cargo +nightly build --release && (Get-Item ".\target\release\min-sized-rust-windows.exe").Length 19 | Compiling min-sized-rust-windows v0.1.0 (**\min-sized-rust-windows) 20 | Finished release [optimized] target(s) in 1.33s 21 | 464 22 | ``` 23 | 24 | ### Strategies 25 | I'm excluding basic strategies here such as enabling lto and setting `opt-level = 'z'`. [0] 26 | 27 | * [`no_std`](https://github.com/johnthagen/min-sized-rust#removing-libstd-with-no_std) 28 | * [`no_main`](https://github.com/johnthagen/min-sized-rust#remove-corefmt-with-no_main-and-careful-usage-of-libstd) 29 | * Merge `.rdata` and `.pdata` sections into `.text` section linker flag. [1] 30 | * Using the LINK.exe [`/MERGE`](https://docs.microsoft.com/en-us/cpp/build/reference/merge-combine-sections?view=msvc-160) 31 | flag found at the bottom of `main.rs`. 32 | * Section definitions add more junk to the final output, and I _believe_ they have a 33 | min-size. For this example we really don't care about readonly data (`.rdata`) or 34 | exception handlers (`.pdata`) so we "merge" these empty sections into the `.text` 35 | sections. 36 | * No imports. 37 | * To avoid having an extra `.idata` section (more bytes and cannot be merged into 38 | `.text` section using `LINK.exe`) we do the following. 39 | * Resolve stdout handle from `PEB`'s process parameters (thanks ChrisSD). [3][4] 40 | * Invoke `NtWriteFile`/`ZwWriteFile` using syscall `0x80`. [5][6] 41 | 1. This is undocumented behaviour in windows, syscalls change over time. [5] 42 | 2. I can't guarantee this will work on your edition of windows.. it's tested on 43 | my local machine (W10) and on GH actions (windows-2022 and windows-2019) server 44 | editions. 45 | * Custom `LINK.exe` stub. 46 | * A custom built stub created to remove `Rich PE` header. More information can be found [here](https://bytepointer.com/articles/the_microsoft_rich_header.htm). 47 | * Credits to @Frago9876543210 for finding, and implementing this. 48 | * Drop debug info in pe header. 49 | * Add `/EMITPOGOPHASEINFO /DEBUG:NONE` flags. 50 | * Credits to @Frago9876543210 for finding, and implementing this. 51 | 52 | 53 | ### Future 54 | * Using strategies shown in [[2]](https://github.com/pts/pts-tinype) we _could_ post process 55 | the exe and merge headers to get closer to the 600-500b mark although we start straying 56 | away from the goal of this project. 57 | * Provided the call signature of `ZwWriteFile` I could use `build.rs` to make a script to 58 | dynamically resolve the syscall number from `ntdll` using something like [iced-x86](https://crates.io/crates/iced-x86). 59 | * Go pure assembly (drop type definitions for PEB). 60 | 61 | ### References 62 | 0. https://github.com/johnthagen/min-sized-rust 63 | 1. www.catch22.net/tuts/win32/reducing-executable-size#use-the-right-linker-settings 64 | 2. https://github.com/pts/pts-tinype 65 | 3. https://news.ycombinator.com/item?id=25266892 (Thank you anonunivgrad & ChrisSD!) 66 | 4. 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 67 | 5. https://j00ru.vexillium.org/syscalls/nt/64/ 68 | 6. https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntwritefile 69 | 70 | ### Credits 71 | * @Frago9876543210 - Brought binary size from `760b` -> `600b` :grin: 72 | * @Frago9876543210 - Brought binary size from `600b` -> `560b` :grin: 73 | * @ironhaven - Brought binary size from `560b` -> `536b` 😁 74 | * @StackOverflowExcept1on - Brought binary size from `536b` -> `464b` 😁 75 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | //! # The build script. 2 | //! 3 | //! Provides two functions. 4 | //! 5 | //! 1. Resolves syscall id of `NtWriteFile` of local system and creates a rust file at 6 | //! `$OUT_DIR/syscall.rs` containing a single constant `NT_WRITE_FILE_SYSCALL_ID`. 7 | //! 8 | //! 2. Writes link.exe flags to optimize size. 9 | 10 | use iced_x86::{Code, Decoder, DecoderOptions, Mnemonic, OpKind, Register}; 11 | use std::env; 12 | 13 | use core::ffi::{c_char, c_void}; 14 | use std::path::Path; 15 | use std::slice::from_raw_parts; 16 | 17 | extern "system" { 18 | pub fn GetProcAddress(hModule: *mut c_void, lpProcName: *const c_char) -> *mut c_void; 19 | pub fn LoadLibraryA(lpFileName: *const c_char) -> *mut c_void; 20 | } 21 | 22 | /// Converts string literal into a `LPCSTR` 23 | macro_rules! l { 24 | ($str:literal) => { 25 | concat!($str, "\0").as_ptr() as *const _ 26 | }; 27 | } 28 | 29 | fn main() { 30 | // File alignment flags to reduce size of `.text` section. 31 | println!("cargo:rustc-link-arg-bins=/ALIGN:8"); 32 | println!("cargo:rustc-link-arg-bins=/FILEALIGN:1"); 33 | // Merges empty `.rdata` and `.pdata` into .text section saving a few bytes in data 34 | // directories portion of PE header. 35 | println!("cargo:rustc-link-arg-bins=/MERGE:.rdata=.text"); 36 | println!("cargo:rustc-link-arg-bins=/MERGE:.pdata=.text"); 37 | // Prevents linking default C runtime libraries. 38 | println!("cargo:rustc-link-arg-bins=/NODEFAULTLIB"); 39 | // Removes `IMAGE_DEBUG_DIRECTORY` from PE. 40 | println!("cargo:rustc-link-arg-bins=/EMITPOGOPHASEINFO"); 41 | println!("cargo:rustc-link-arg-bins=/DEBUG:NONE"); 42 | // See: https://github.com/mcountryman/min-sized-rust-windows/pull/7 43 | println!("cargo:rustc-link-arg-bins=/STUB:stub.exe"); 44 | 45 | unsafe { 46 | // First we find the syscall id of `NtWriteFile`. 47 | let id = get_syscall_id(l!("ntdll.dll"), l!("NtWriteFile")) 48 | .expect("syscall for ntdll.NtWriteFile not found"); 49 | 50 | // Get `OUT_DIR` path. 51 | let path = env::var("OUT_DIR").expect("Missing environment variable 'OUT_DIR'"); 52 | 53 | // Create file at `$OUT_DIR/syscall.rs` 54 | let path = Path::new(&path).join("syscall.rs"); 55 | std::fs::write( 56 | &path, 57 | format!("pub const NT_WRITE_FILE_SYSCALL_ID: u32 = {};", id), 58 | ) 59 | .unwrap_or_else(|_| panic!("Failed to write to file '{:?}'", path)); 60 | } 61 | } 62 | 63 | /// Attempt to find syscall id from supplied procedure in supplied library by 64 | /// iterating over instructions until a syscall opcode is found. 65 | unsafe fn get_syscall_id(library: *const i8, name: *const i8) -> Option { 66 | // Load the procedure and pull out the first 50b 67 | let library = LoadLibraryA(library); 68 | let addr = GetProcAddress(library, name); 69 | let bytes = from_raw_parts(addr as *const u8, 50); 70 | 71 | let mut id = None; 72 | // Init decoder with hardcoded x64 arch 73 | let mut decoder = Decoder::new(64, bytes, DecoderOptions::NONE); 74 | 75 | // Iterate over instructions 76 | while decoder.can_decode() { 77 | let instr = decoder.decode(); 78 | 79 | // Find instruction that mov's syscall id into eax register 80 | // `mov eax, ?` 81 | if instr.op0_register() == Register::EAX { 82 | id = if let Ok(OpKind::Immediate32) = instr.try_op_kind(1) { 83 | Some(instr.immediate32()) 84 | } else { 85 | None 86 | }; 87 | } 88 | 89 | // Syscall or end of func found, return last known eax mov'd operand and 90 | // hope for the best. 91 | if instr.code() == Code::Syscall || instr.mnemonic() == Mnemonic::Ret { 92 | return id; 93 | } 94 | } 95 | 96 | None 97 | } 98 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![no_std] 2 | #![no_main] 3 | #![windows_subsystem = "console"] 4 | 5 | use core::arch::asm; 6 | use core::panic::PanicInfo; 7 | 8 | // Blow up if we try to compile without msvc, x64 arch, or windows. 9 | #[cfg(not(all(target_env = "msvc", target_arch = "x86_64", target_os = "windows")))] 10 | compile_error!("Platform not supported!"); 11 | 12 | // Includes syscall constant. 13 | include!(concat!(env!("OUT_DIR"), "/syscall.rs")); 14 | 15 | macro_rules! buf { 16 | () => { 17 | "Hello World!" 18 | }; 19 | } 20 | 21 | // Actually this function returns u32 (xor eax, eax; ret) 22 | #[no_mangle] 23 | extern "C" fn mainCRTStartup() { 24 | unsafe { 25 | asm!( 26 | // NtWriteFile (see https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntwritefile) 27 | // 28 | // num | type | name | register | desc 29 | // -----|------------------|---------------|----------|--------------------- 30 | // 1 | HANDLE | FileHandle | r10 | 31 | // 2 | HANDLE | Event | rdx | unused 32 | // 3 | PIO_APC_ROUTINE | ApcRoutine | r8 | unused 33 | // 4 | PVOID | ApcContext | r9 | unused 34 | // 5 | PIO_STATUS_BLOCK | IoStatusBlock | rsp+0x28 | unused (required) 35 | // 6 | PVOID | Buffer | rsp+0x30 | 36 | // 7 | ULONG | Length | rsp+0x38 | 37 | // 8 | PLARGE_INTEGER | ByteOffset | rsp+0x40 | should be 0 at time of syscall 38 | // 9 | PULONG | Key | rsp+0x48 | should be 0 at time of syscall 39 | // 40 | 41 | //allocate memory 42 | //stack size = 80 (see https://github.com/JustasMasiulis/inline_syscall/blob/master/include/inline_syscall.inl) 43 | //If I understand correctly, then the stack size is calculated like this: 44 | //1. 8 bytes for "pseudo ret address" 45 | //2. NtWriteFile has 9 args, 9 * 8 = 72 bytes (first 32 bytes is shadow space) 46 | //3. stack alignment by 16, in our case is nothing to align 47 | "sub rsp, 80", 48 | 49 | //arg 1, r10 = NtCurrentTeb()->ProcessParameters->hStdOutput 50 | //most useful structs is described in wine source code 51 | //see: https://github.com/wine-mirror/wine/blob/master/include/winternl.h 52 | //r10 = 0x60 (offset to PEB) 53 | "push 0x60", 54 | "pop r10", 55 | 56 | //r10 = PEB* 57 | "mov r10, gs:[r10]", 58 | //0x20 is RTL_USER_PROCESS_PARAMETERS offset 59 | "mov r10, [r10 + 0x20]", 60 | //0x28 is hStdOutput offset 61 | "mov r10, [r10 + 0x28]", 62 | 63 | //arg 2, rdx = 0 64 | "xor edx, edx", 65 | 66 | //arg 3, r8 = 0, not necessary 67 | //"xor r8, r8", 68 | 69 | //arg 4, r9 = 0, not necessary 70 | //"xor r9, r9", 71 | 72 | //arg 5, [rsp + 0x28] 73 | //this is not quite correct, but we will just overwrite the memory location 74 | //called "stack shadow space" 75 | //see: https://stackoverflow.com/questions/30190132/what-is-the-shadow-space-in-x64-assembly 76 | //memory from rsp to [rsp + sizeof(IO_STATUS_BLOCK)] will be overwritten after syscall 77 | //sizeof(IO_STATUS_BLOCK) = 16 bytes 78 | "mov [rsp + 0x28], rsp", 79 | 80 | //arg 6, [rsp + 0x30] 81 | //this is dirty hack to save bytes and push string to register rax 82 | //call instruction will push address of hello world string to the stack and jumps to label 2 83 | //so, we can store address of string using pop instruction 84 | //label "2", f - forward (see https://doc.rust-lang.org/nightly/rust-by-example/unsafe/asm.html#labels) 85 | "call 2f", 86 | concat!(".ascii \"", buf!(), "\""), 87 | //new line 88 | ".byte 0x0a", 89 | "2: pop rax", 90 | "mov [rsp + 0x30], rax", 91 | 92 | //arg 7, [rsp + 0x38] 93 | "mov dword ptr [rsp + 0x38], {1}", 94 | 95 | //arg 8, [rsp + 0x40], not necessary 96 | //"mov qword ptr [rsp + 0x40], 0", 97 | 98 | //arg 9, [rsp + 0x48], not necessary 99 | //"mov qword ptr [rsp + 0x48], 0", 100 | 101 | //eax = NT_WRITE_FILE_SYSCALL_ID 102 | "push {0}", 103 | "pop rax", 104 | 105 | //make syscall 106 | "syscall", 107 | 108 | //eax = 0 (exit code) 109 | "xor eax, eax", 110 | 111 | //deallocate memory 112 | "add rsp, 80", 113 | const NT_WRITE_FILE_SYSCALL_ID, 114 | const buf!().len(), 115 | options(nomem, nostack), 116 | ); 117 | } 118 | } 119 | 120 | #[panic_handler] 121 | fn panic(_: &PanicInfo) -> ! { 122 | loop {} 123 | } 124 | -------------------------------------------------------------------------------- /stub.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcountryman/min-sized-rust-windows/b2563ed6a86e75c806858709afbaf25914861c32/stub.exe --------------------------------------------------------------------------------