From 92bbd28e60dd02cede77ed581e7b923b3fb340aa Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:21:38 -0400 Subject: [PATCH 01/32] Add test fixtures for Mach-O support LibObjectFile has no Mach-O test inputs. Adds committed fixtures covering the variants an implementation has to handle: 32- and 64-bit, LC_UNIXTHREAD and LC_MAIN entry points, dyld info opcode streams and chained fixups, a dylib, an object file with relocations, and a universal binary. Both 32-bit fixtures are synthesized with yaml2obj because LLVM and current cctools have dropped 32-bit Mach-O linking, so neither the LC_UNIXTHREAD nor the LC_MAIN-with-dyld-info shape can come from a linker any more. The sources call into libc so the linked fixtures carry lazy-binding stubs and an indirect symbol table, and the fixtures keep the padding after the load commands that in-place injection consumes. - src/LibObjectFile.Tests/MachO/generate_files.sh: regenerates the fixtures via OSXCross cctools and LLVM, and links to where OSXCross comes from - src/LibObjectFile.Tests/MachO/unixthread_i386_rpath: the same input after install_name_tool adds a runpath, used as an encoding reference - src/LibObjectFile.Tests/LibObjectFile.Tests.csproj: copies the fixtures to the test output --- .../LibObjectFile.Tests.csproj | 4 + .../MachO/chainedfixups_arm64 | Bin 0 -> 50096 bytes src/LibObjectFile.Tests/MachO/dyldinfo_i386 | Bin 0 -> 8256 bytes .../MachO/dyldinfo_i386.yaml | 162 ++++++++++++++++++ .../MachO/generate_files.sh | 47 +++++ src/LibObjectFile.Tests/MachO/helloworld.c | 14 ++ .../MachO/helloworld_arm64 | Bin 0 -> 50077 bytes src/LibObjectFile.Tests/MachO/helloworld_fat | Bin 0 -> 66461 bytes .../MachO/helloworld_x86_64 | Bin 0 -> 8528 bytes .../MachO/helloworld_x86_64.o | Bin 0 -> 1048 bytes src/LibObjectFile.Tests/MachO/libhelloworld.c | 9 + .../MachO/libhelloworld_x86_64.dylib | Bin 0 -> 8392 bytes src/LibObjectFile.Tests/MachO/unixthread_i386 | Bin 0 -> 8248 bytes .../MachO/unixthread_i386.yaml | 147 ++++++++++++++++ .../MachO/unixthread_i386_rpath | Bin 0 -> 8248 bytes 15 files changed, 383 insertions(+) create mode 100755 src/LibObjectFile.Tests/MachO/chainedfixups_arm64 create mode 100644 src/LibObjectFile.Tests/MachO/dyldinfo_i386 create mode 100644 src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml create mode 100755 src/LibObjectFile.Tests/MachO/generate_files.sh create mode 100644 src/LibObjectFile.Tests/MachO/helloworld.c create mode 100755 src/LibObjectFile.Tests/MachO/helloworld_arm64 create mode 100755 src/LibObjectFile.Tests/MachO/helloworld_fat create mode 100755 src/LibObjectFile.Tests/MachO/helloworld_x86_64 create mode 100644 src/LibObjectFile.Tests/MachO/helloworld_x86_64.o create mode 100644 src/LibObjectFile.Tests/MachO/libhelloworld.c create mode 100755 src/LibObjectFile.Tests/MachO/libhelloworld_x86_64.dylib create mode 100644 src/LibObjectFile.Tests/MachO/unixthread_i386 create mode 100644 src/LibObjectFile.Tests/MachO/unixthread_i386.yaml create mode 100644 src/LibObjectFile.Tests/MachO/unixthread_i386_rpath diff --git a/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj b/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj index 77332fe..1035286 100644 --- a/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj +++ b/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj @@ -84,6 +84,10 @@ + + + + diff --git a/src/LibObjectFile.Tests/MachO/chainedfixups_arm64 b/src/LibObjectFile.Tests/MachO/chainedfixups_arm64 new file mode 100755 index 0000000000000000000000000000000000000000..1186d2f292edbf10244a581ea623c33bfaf5aaf5 GIT binary patch literal 50096 zcmeIyZERCj7zgmDFPnpPY$Q&HZ>ukunJn9c1^IxclT9H^HrZy163)7|qYYh4+K#XT&o;c+u&qf(vzxv+ zd+0xz)OnR}RI0VX-I_hEuXkmSk(uZ5umo>OZ%W1ey>T<)F1KEiZRGefGXGCbgV>ajrL)(Pi#SMwIe-;vQqp z$Yo!*a(Vl52L3FJrY-DceE*G`w{B^u-<vR8@nTwCK`#EG@ z<8#)`cTl-0qs?uk$*E!*PZqnzsjs|+^Pz-IU79;cld}Vs)3clxkhU#ZNaN?eEFa@{ zeuONp@!#g=-nwR^p`?x8udvVzmpnr4r9*s(H>L_*0Hh);UuN+ z*Vo4zjm5h=R7Wr@$4DKPkCi@%vG>do{TON*)2x)?aA-#NGoV z-yEua?(E9Jma7w+wLdTRcfIbo=lP>2_P?_GsTJc3tzYc?+nyJWe{^`~g_#Eij!n*; YIrrD_j+xIs@BiS_%f)xhw4M6-UlYj6;Q#;t literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/dyldinfo_i386 b/src/LibObjectFile.Tests/MachO/dyldinfo_i386 new file mode 100644 index 0000000000000000000000000000000000000000..6f613c087bc3b3813b68a231a8a319c1ec16f1f4 GIT binary patch literal 8256 zcmeH|O-lk%6o#*6*%#3=sKpc!eSk)_aH%pwNTp&fqRmCogrcN*e(nzdp3HjMo^{*b?7%nUbX<6cK6Q)b}&ac#d) zFSu1aJ;>D`*5=IP+O0;|z{1e1{hXiT9&*X)E8|@;uhUZbB=Ada_I+%ywr5Np56@gJMMpXl|x-wWGUnPLV#cbeFiiGT=*fC!8TJn#qlaTLlC0TB=Z5fA|p_y+{| g_b(Fx5fA|p5CIVo0TB=Z5fA|p5CIVofu9Jx17VppSpWb4 literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml b/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml new file mode 100644 index 0000000..4dbd9fe --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml @@ -0,0 +1,162 @@ +# Models a 10.7-era i386 executable: 32-bit segments combined with the modern +# LC_MAIN entry point and LC_DYLD_INFO_ONLY opcode streams. No current linker +# emits that combination, since 32-bit Mach-O support was dropped while those +# commands were still in use, so it has to be synthesized to be tested at all. +--- !mach-o +FileHeader: + magic: 0xFEEDFACE + cputype: 0x00000007 + cpusubtype: 0x00000003 + filetype: 0x00000002 + ncmds: 16 + sizeofcmds: 720 + flags: 0x00218085 +LoadCommands: + - cmd: LC_SEGMENT + cmdsize: 56 + segname: __PAGEZERO + vmaddr: 0 + vmsize: 4096 + fileoff: 0 + filesize: 0 + maxprot: 0 + initprot: 0 + nsects: 0 + flags: 0 + - cmd: LC_SEGMENT + cmdsize: 124 + segname: __TEXT + vmaddr: 4096 + vmsize: 4096 + fileoff: 0 + filesize: 4096 + maxprot: 7 + initprot: 5 + nsects: 1 + flags: 0 + Sections: + - sectname: __text + segname: __TEXT + addr: 0x1800 + size: 6 + offset: 0x800 + align: 2 + reloff: 0 + nreloc: 0 + flags: 0x80000400 + reserved1: 0 + reserved2: 0 + content: 'B82A000000C3' + - cmd: LC_SEGMENT + cmdsize: 124 + segname: __DATA + vmaddr: 8192 + vmsize: 4096 + fileoff: 4096 + filesize: 4096 + maxprot: 7 + initprot: 3 + nsects: 1 + flags: 0 + Sections: + - sectname: __data + segname: __DATA + addr: 0x2000 + size: 4 + offset: 0x1000 + align: 2 + reloff: 0 + nreloc: 0 + flags: 0x00000000 + reserved1: 0 + reserved2: 0 + content: '2A000000' + - cmd: LC_SEGMENT + cmdsize: 56 + segname: __LINKEDIT + vmaddr: 12288 + vmsize: 4096 + fileoff: 8192 + filesize: 64 + maxprot: 7 + initprot: 1 + nsects: 0 + flags: 0 + - cmd: LC_DYLD_INFO_ONLY + cmdsize: 48 + rebase_off: 0 + rebase_size: 0 + bind_off: 0 + bind_size: 0 + weak_bind_off: 0 + weak_bind_size: 0 + lazy_bind_off: 0 + lazy_bind_size: 0 + export_off: 0 + export_size: 0 + - cmd: LC_SYMTAB + cmdsize: 24 + symoff: 8192 + nsyms: 2 + stroff: 8216 + strsize: 32 + - cmd: LC_DYSYMTAB + cmdsize: 80 + ilocalsym: 0 + nlocalsym: 0 + iextdefsym: 0 + nextdefsym: 2 + iundefsym: 2 + nundefsym: 0 + tocoff: 0 + ntoc: 0 + modtaboff: 0 + nmodtab: 0 + extrefsymoff: 0 + nextrefsyms: 0 + indirectsymoff: 0 + nindirectsyms: 0 + extreloff: 0 + nextrel: 0 + locreloff: 0 + nlocrel: 0 + - cmd: LC_LOAD_DYLINKER + cmdsize: 28 + name: 12 + Content: '/usr/lib/dyld' + ZeroPadBytes: 1 + - cmd: LC_UUID + cmdsize: 24 + uuid: 4D4F4445-524E-3332-4249-540000000002 + - cmd: LC_VERSION_MIN_MACOSX + cmdsize: 16 + version: 0x000A0700 + sdk: 0x000A0700 + - cmd: LC_SOURCE_VERSION + cmdsize: 16 + version: 0 + - cmd: LC_MAIN + cmdsize: 24 + entryoff: 2048 + stacksize: 0 + - cmd: LC_FUNCTION_STARTS + cmdsize: 16 + dataoff: 8248 + datasize: 8 + - cmd: LC_DATA_IN_CODE + cmdsize: 16 + dataoff: 8256 + datasize: 0 + - cmd: LC_DYLIB_CODE_SIGN_DRS + cmdsize: 16 + dataoff: 8256 + datasize: 0 + - cmd: LC_LOAD_DYLIB + cmdsize: 52 + dylib: + name: 24 + timestamp: 2 + current_version: 0x004F0901 + compatibility_version: 0x00010000 + Content: '/usr/lib/libSystem.B.dylib' + ZeroPadBytes: 2 diff --git a/src/LibObjectFile.Tests/MachO/generate_files.sh b/src/LibObjectFile.Tests/MachO/generate_files.sh new file mode 100755 index 0000000..0a792c2 --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/generate_files.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Regenerates the Mach-O test fixtures. Needs an OSXCross toolchain on PATH +# (o64-clang for x86_64, oa64-clang for arm64) so the fixtures come out of the +# real cctools ld64 rather than an approximation, plus LLVM for yaml2obj and +# ld64.lld. The toolchain is only needed to regenerate, not to run the tests, +# since the fixtures are committed. +# +# OSXCross: https://github.com/tpoechtrager/osxcross +# It builds the cctools/ld64 port and needs a macOS SDK packaged per its README; +# Apple does not permit redistributing the SDK, which is why the fixtures are +# committed rather than built during the test run. +# +# LLVM and current cctools both dropped 32-bit Mach-O linking, so the two i386 +# fixtures are synthesized with yaml2obj rather than linked. unixthread_i386 +# models a pre-10.8 executable, which uses LC_UNIXTHREAD for the entry point; +# dyldinfo_i386 models a 10.7-era one, which pairs 32-bit segments with LC_MAIN +# and LC_DYLD_INFO_ONLY. No linker still emits either combination. +set -e + +o64-clang helloworld.c -o helloworld_x86_64 +oa64-clang helloworld.c -o helloworld_arm64 + +# arm64 gets an ad-hoc signature from the linker, x86_64 stays unsigned. The +# signing tests need both a "sign from scratch" and a "replace existing" input. +o64-clang -dynamiclib -install_name /usr/local/lib/libhelloworld.dylib \ + libhelloworld.c -o libhelloworld_x86_64.dylib + +o64-clang -c helloworld.c -o helloworld_x86_64.o + +lipo -create helloworld_x86_64 helloworld_arm64 -output helloworld_fat + +# cctools ld64 predates chained fixups, so this one fixture comes from lld, +# which emits LC_DYLD_CHAINED_FIXUPS when the deployment target is macOS 12+. +# That is a separate read path from the LC_DYLD_INFO_ONLY opcode streams above. +SDK="$(dirname "$(command -v oa64-clang)")/../SDK/MacOSX14.sdk" +oa64-clang -mmacosx-version-min=12.0 -c helloworld.c -o chained.o +ld64.lld -arch arm64 -platform_version macos 12.0 12.0 -syslibroot "$SDK" \ + -lSystem -e _main -fixup_chains -o chainedfixups_arm64 chained.o +rm -f chained.o + +yaml2obj unixthread_i386.yaml -o unixthread_i386 +yaml2obj dyldinfo_i386.yaml -o dyldinfo_i386 + +# A reference for the editing tests: what Apple's own tool produces for the same +# edit. Comparing against it catches an encoding that is merely self-consistent. +cp unixthread_i386 unixthread_i386_rpath +install_name_tool -add_rpath '@executable_path/../Frameworks' unixthread_i386_rpath diff --git a/src/LibObjectFile.Tests/MachO/helloworld.c b/src/LibObjectFile.Tests/MachO/helloworld.c new file mode 100644 index 0000000..fe64797 --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/helloworld.c @@ -0,0 +1,14 @@ +// Fixture source for the Mach-O tests. It calls into libc on purpose: the +// resulting lazy-binding stubs, indirect symbol table and bind opcodes are +// structures the reader has to handle, and a freestanding binary has none. +#include + +int helloworld_twice(int x) { return x * 2; } + +int helloworld_data = 21; + +int main(void) +{ + printf("hello world %d\n", helloworld_twice(helloworld_data)); + return 0; +} diff --git a/src/LibObjectFile.Tests/MachO/helloworld_arm64 b/src/LibObjectFile.Tests/MachO/helloworld_arm64 new file mode 100755 index 0000000000000000000000000000000000000000..b30ce9414dcd7fbeed9ba836c33c89e022a8fde1 GIT binary patch literal 50077 zcmeI*Uu;uV90%}U{|u|ETc@BHB&#wMK~{p3Km_f8IdIB=?O_4Kv0b~Zp?|ja&TX)a z#!0}y!xm5p#>B)0Gb3aY9$dswqM{}yxQC%ZW6-Ce7>NlQP^`b-{WIDE@*Z`eD9d~Jmz_cb4y-sKA}`+yekq9sYtvfp~v~pW5mq2)=VOCVUFDV@?7`n zc~(|8R;v~DYZ{EF>o>nt-0G#Eb#SLtdG<1QrCJj?kCo$V=X3E`z4(pm&4{M(LGJfZ z<~709$YYJ-HeE0;j`iX<8f#wGnw5$MRH`QyOhi??mb`Ah$$6#o=XD3CwQ|WFlnMp3 zfE*d6aDJ||F+c13B(cotdgaRR^ZC_l>S|W5wticgf^~8%%rXmPK!!-x z;^KBXw^s3HjOVm~+a~UmHOct_j%AIWe|KzRpzA7mWmcp`K`nV<|JZ$~?Y4f8@E7+L9e_%sc#R`S^_e3%SqBRpy}2%3h)$ z8~6CaK9_stduNq7yne}3QSkeb`!41x=Vbo2Ws7+!wkvbt`84#Tv~a9)c_q&)668T# z9^oqacV-OF$IMu~q|D?h=hM9Ing0tTqfsCL0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf zKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fWYl3 zkX=DzXG&>wqSQZ1J>?A?4`v+dRJNbSE^oCBU*_0F>Gc!EGw>TUY%;}z_rOyQQS3%Nm`er0ZQ>1)t-#RwHA)J*c26!L&e%u zxI5h3sfATrI3U9-+rrUkqAQW)G_)`lh{WkxyIf({x+2YCstyIT0M$8YcON-usE?d9 zkk$vtpxu4brJ*LSB`zXY+ITO43@EjE_|u5*lbFwY(nkEK#QXvSxA`7}C1zY<#@xvJ z4JKc4P+JfeaXf{qv{JmJuCX>=#mwt3- zm}n*+T63JreY|Y@on&`%!~cjPT!lu;-9*RJUOTys@J640Yk)0h5rJfEQ58+N>th=$ z?`?Q@UGTp0am()mC7X`!89(0e`4(5zSH9&>etDzo zZ}$16qd)IIQ1$0$+a@;uHSpHK7oIp>+OhQTkXqigY1z8&`Ll2K{eSTV4~1%<3aqJ1 c?mJg9^kRGWltmw=cOE(M&)gl;&RsbAAAoc8S^xk5 literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/helloworld_fat b/src/LibObjectFile.Tests/MachO/helloworld_fat new file mode 100755 index 0000000000000000000000000000000000000000..bcc3fb2c3dd3a5c114bd60280f3e6118232509bc GIT binary patch literal 66461 zcmeI5e{5S<6~~Y5q9=NzW)5}AN-hzf&rrUu?`VMSY6-4hX&4TV9gTc zbo^O9G;)G~00@8p2!H?xfB*=900@8p2!H?xTww%Wd+X0{^MikYAN(oEsfklhJ<(%i z@fp#FIdyYVs;8r?^WM&Twn}cL@H|0H5kJ4;ib`$k?B7;dUALEM2d@~LyFzY~Vx{cl zggsx;?=Q*&5PzFY6|w8)tBaBMno5n@WAV|sEWf`;jlV^vhHJYG!p5Il?)Xzf$@EAv z$Cdp4rj5Uk7zbht_c^KR-@vGyOJ%m33Vwgj8-Ff7vAt5^n+ud0%Z#To36;tWX0=^; zKaLuIa?eG?_WN*3oTJ>=X?rZUIKkvAeO2-!JU&QB6Y_C5vx#o74 z`;&;-u{pD-;@&gQ?Tcip*VbLZ`My5BL&cOTdm=`pZGdATho%CD6Q z7Ll+CvtG*D70YsF2-mXi;Y{h1luxrQ_uwC_`&lpMB;{U_u}E5I93^Z1GvRG36OUY^ zCnVd(MsscHRJ<)QnNFBW*Rhsz-MCAv^g-{rQ!g$LP4={e0m@y1}(2 zk7W&ySK9UXzBe-Qxt^1hNq++y*YlsD0L@*SuirO0YA1(VH??v zkB_BO<#KuViS8#iwy|}XO7hG4Q^oG7S0eWxFRz+r>#ir4?O@f>S6(H$FvJHs`v-2v zkCN%EHJ;6-6V|E(|BBVW*FP%dEM6_YQ{{QLowcMX^P5)AkK*U z<2C`-OXhNL0syAnFRy|Rn=P4Ae8;+$ibcbH) z3)#I;YpzngY(b%s*Vqsp%?Sf8#8%SBuRj?)v@) zW6Qdolz)yBYdO`@#Ye9#>y9i@i6xM8i&tE$#~|zW>8vvb-Cb{nGYq9i<|3(rCKrK# z00@8p2!H?xfB*=900@8p2!O!<8v%*`=U7+$+$>M)&76k#A^CqE{~z^{F}54~FIuEl z{C@*a1ju0AxL~pMEQD^&hpPDch2#H6j6d@t1QOfp&yD|g%WK8|?==1-E?GouufOX4 zEgb*uwGv&NZl*I#;_OSGIr z*4N5;(Zv0;me_Y!tfGngXWh@4GA1c6uq|VZ9OJ(mti5sn?lok-O}@Wg4s2Emx4+yt z|I@htU%Ywr^RL+(9{<{nNB-1u^1A)&*t^9^`k;HovHx=JT;tW?zuO0}()az5`>>vq zj8*#o-|hpLE>`XX5MCn7ILYTe&*NUUAQ-6)0Ra#I0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5CDPqCV}#8G<&9rW=c)b8JcYAV|#Bgq)wHmX!g>>frFRW4paV~(qfu<`FP9s z*T}p4o1-&-EtkK3Hbi?%A^P^309{~u17C@BRy)fb3;uspAc(*m>%(z!3yM00ck)1V8`;KmY_l00ck)1V8`;KmY_l z00ck)1V8`;KmY_l00ck)1V8`;KmY_l00ck)1V8`;KmY_l00ck)1V8`;KmY_l00ck) z1V8`;KmY_l00ck)1m0BwPxE^Moc|-+>Bf?GwKupp2!H?xfB*=900@8p2!H?xfB*=9 z00@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p z2!H?xfB*=900@8p2!H?xfB*=*HwiSapk5hyb2KrTPN-3PEUx0IOd^@1-W(NZluOh| zE|swd*wT~y5U$VLw5wFMbI8k4J4@} z5wl~oIYdwFq7WU}MRioj>k3loiCs%YQ9q}~Fi|+~yq7>KGzB7jbnI)z=JH71u~&=D zFED5@?=je5?3Ko5CGR(wd&NPQ&#q^C5hu|i7OBgOjmJ1QHa!e)hBG{+N9>}if~l0w zVv=8u*-7VGa(wHz4Mj$@j8EM@Uc-4j?BG=ttYgLhh*F#cjmXtR$MRN?8XU9MG3r(b zcm@&64d2{OrG~A;_qBd-;PBM-|Gep|D}MKzFMVM9pufPxtOmeydjbipcA2`(N1b%%1bsqdy${OQG?B zm!3L#tnVieh1-ADw&^oJt=0aWF27~w_xoRH|Jy5%lpg$h;TtdB{pr(9J8n60Ky4a- yU}NvZO{?DN>;JJWekjrP+1QrNxqasv4}5WCV$sdt%}*aaaq))7mz+EQ(!T(7JmWb4 literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/helloworld_x86_64 b/src/LibObjectFile.Tests/MachO/helloworld_x86_64 new file mode 100755 index 0000000000000000000000000000000000000000..d6dfdc846dc3eb9c4583dd26e21b9e4b4f97cfe4 GIT binary patch literal 8528 zcmeHN-%C_M6rR<7Y-PGW6#eERnbm`q_+Sr~8*Z&=wWfgtG3>f)E?jqQ_lo%tND?R^ z_!9WsUl7zE5ES)Ty+riXi(ycYL4~w^-|UQg?=^fE=D@jUzL_&;&U|yP+`Y$le}4Sk zEkvYV2vG-Zf~Ix~@klsm32_*jgi4u;UrpRj+`PfwphSZ3zld=@!BNVQ#OO%SMa_=| z`-H|wMHr=rlwR(>SMv?W>%|5b@3M9w-cfTWnDav^%ieURywMeo_fW^%r9BKb6QVkv z8L!5Z6FGM(SAtVG-hz&ILEV!~fJDDlE za>^?q&;Q4Znk5j2^No;ywVv*f@@~<0n{0{~)%lrt#Nl}UeM{-4x5_UTibw2^hi5E+ zrOc+iw8@_565Q`09nbJX?9Zn*v)ZaXr-S|12NHe#<{5KB=KxGeX|ibiytN21ca9E@ z5acEbdkEHu;|TR=o`X%HlR0sG0hnj-3w9KCAC%*M;a;d)?PJZU-4p(~jQ|)D4bYD1 za;d}3XF9U8ZWfqw6v{mIf4(@Lxz^SBr0sWcc6H~^ljs9=7}$%28^u|J*#|j|{c>I^ z^xSZrZYal?Z=|Oie}*EpwXy#2Y}w0AwqI^X9{CKMAg7>QZ_cUmcSd9KW5qIH8L$jk z1}p=X0n318VEZ$0E4jG3_+b5UV*SIHJFmugS-i!n z_w{2OhxjeuQ~7(h3zj;s-)Y(AZ;=MfjH%fG!PkD(!OIHfL|_kW^w@@Fz%pPNunbrR zECZGS%YbFTGGH073|Iy%1KXW}=9btH_t4zSVwx(3%M9wnC|(bhqRXNbYwnd(C_sDT zVwsK+iF$m!oScxk`?;}cFNa!pn(poDLxuWIAw}n8I$sc1RHd)Zo5_#mL|hd-2kOPk z6ZjhOo|hE|Kvm~djjbtCZS(uXL1@z+)ZfrW+(wLssqbIVm~}gjUxLE2spKlIe$2Wf jq?i9oz_x>m>*hVMZtoVWdr;~6JF44|YG;O$O{Dz;=M3pQ literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/helloworld_x86_64.o b/src/LibObjectFile.Tests/MachO/helloworld_x86_64.o new file mode 100644 index 0000000000000000000000000000000000000000..d1ab5583880df9e5db491e6e48db98341c4ef946 GIT binary patch literal 1048 zcmah|J4*vW5Z?F!p;=CSkb-0tnu=f_vCQoc?F1MPxFJ79uP$Qbm6 z3xA2!3f#jYF*p@#H|HQkj(y;Gz8A+L+p$Khy)|)|k7p@hFh-bT1}#Jh%dvFC{NYe6 z)QH34y1nqP!KX)TtZH~f@ja)Q&h1KysC7P5@`m6|8(CY3dE)tO!HPSypDU$v38D=> zlFP6yJgb6_AmBTwzzA0hC6S$?WYNmnUPJqQ7s8Vg^n-`_wDc>bOKxfk*8P^jK5Q6p z_Z&s;5>0$&HQ+K3eJToV7UpG@t8Om+>u;Im*YZ*QEK+~2?NpN$j?I&I;~`S#-m?|9 zZ*0o<=K0D9oGUM0xHV7ijGd}doJ`r7Og>o37c+_AbOPTX67cZXX!_n3|ELd3zwdBU z`3?-9G0^L;fQ8R#9BO`hBK%5)0>){_`e2Xr@wzZ{Ue?Jzzt$~b4bE + +int helloworld_shared(int x) +{ + printf("shared %d\n", x); + return x + 1; +} diff --git a/src/LibObjectFile.Tests/MachO/libhelloworld_x86_64.dylib b/src/LibObjectFile.Tests/MachO/libhelloworld_x86_64.dylib new file mode 100755 index 0000000000000000000000000000000000000000..b28d9410cafdd5694a6dbcedc4c0b201303c6792 GIT binary patch literal 8392 zcmeHMO=uHA6n?Qk2HV>0LGiD$iWT+H_F$nN3aO-!Vv&|c=|#q-N!!3SX|o$@4?=4Z z8cHBX@gO~W^60Uk2X9_IdQlVm`C2e`DSLno%v=qO!D6U{Qm1W zQLK%KI@x>J_uGk{lEWpUv+N&YL_(w|=F+mN^%8b#)+yY8c6?I=FX2?os5XjokF>GqIMEa$w{T#0){`D%*qoMHez z)?nkpq46#FK`CEYQhlO)FBM;xYS&m(ws3Q$5aq&pzK|99!eUY4*7bO;_)>}hILe{9 z5wWhLa$S?->2ae)ujd~k_K9}fB}Bm!{>DnC=!w;!#Q12wxPAol;(l$=-&_|VmWswU zIm8!NHU6E0W66d$<~(Yr9n zIhymtdzN57!*13OmHpC?S6rC)hP-?R&Rfzet`|#Qb}+lam~UXaOtd+Sf=dJ~;vB)H z&2__}40Gg<+zV)*V}SWRgYc8*-(Iw?)h2_`;CH#Fu5>t-Ki$Gv!NY;2UjK4KK=5h^je;Rm^u!P}+|SGg~E zr`=G8>k4C@)~o_n0jq#jz$#!BunJfOtOEa9fqSXSr_}a0r}Fq z6|f3e1*`&Af&X8DzDv#wX3>}6sRZe6Fi&R6$7;@w) zAt}QDB4@j(fgBnt(Qk`s Bxu^gD literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/unixthread_i386 b/src/LibObjectFile.Tests/MachO/unixthread_i386 new file mode 100644 index 0000000000000000000000000000000000000000..cce73c41916b6e2c38369019ee7706e154ac2a7d GIT binary patch literal 8248 zcmeH_ze>YU6vh*+R8YjRphIR0iGql0v_VSMl$t?YF0JWMsEdhU!NJ9Ma&YS|zKAd2 zAbuw|w~4kE2dDBKINWpZ`Tt8^Ki)s9mSrtNm!NCV7Z^{DZP~C7p-Q#=li(`ogt!>l zG{*O{f~({`K$VJu%c$g+9FvDr`mR7FUPP(1f1hew-+$!S^7K_b>m;&bJsP{{-S8vd zyyA4ArhndBb}o9y-L#8`T$Gy5m))d>aGE7jOY=Hyp0|QV)9k(rhxB%|U-mYAVP^HP zTIQG9H77H(Z_$?bl8FDe{-v;)<_2E3q3clZF1huFgPs?UhB5A2P?@0>Mqy{~%s++= zvE}>73^K2*@Mp;unvRy<`%rPq9`>82F_{N5U5t{nf8!pxm~GJ0^Oke;pHnS%Kva^( z*Y&!4U~*8JLINZ}0wgepz_a+qa{}lh0TLhq5+DH*SU3VZ3%3y$A^{R00TLhq5+DH* QAOR8}0TLhq68J9yU)`rVlK=n! literal 0 HcmV?d00001 diff --git a/src/LibObjectFile.Tests/MachO/unixthread_i386.yaml b/src/LibObjectFile.Tests/MachO/unixthread_i386.yaml new file mode 100644 index 0000000..64d42da --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/unixthread_i386.yaml @@ -0,0 +1,147 @@ +# Models a pre-10.8 i386 executable: LC_UNIXTHREAD instead of LC_MAIN, and +# padding between the end of the load commands and the first section so that +# in-place load-command injection has somewhere to go. The first section sits +# at 0x800 while the load commands end at 728. +--- !mach-o +FileHeader: + magic: 0xFEEDFACE + cputype: 0x00000007 + cpusubtype: 0x00000003 + filetype: 0x00000002 + ncmds: 11 + sizeofcmds: 700 + flags: 0x00012085 +LoadCommands: + - cmd: LC_SEGMENT + cmdsize: 56 + segname: __PAGEZERO + vmaddr: 0 + vmsize: 4096 + fileoff: 0 + filesize: 0 + maxprot: 0 + initprot: 0 + nsects: 0 + flags: 4 + - cmd: LC_SEGMENT + cmdsize: 124 + segname: __TEXT + vmaddr: 4096 + vmsize: 4096 + fileoff: 0 + filesize: 4096 + maxprot: 7 + initprot: 5 + nsects: 1 + flags: 0 + Sections: + - sectname: __text + segname: __TEXT + addr: 0x1800 + size: 16 + offset: 0x800 + align: 2 + reloff: 0 + nreloc: 0 + flags: 0x80000400 + reserved1: 0 + reserved2: 0 + content: 'B800000000C3' + - cmd: LC_SEGMENT + cmdsize: 124 + segname: __DATA + vmaddr: 8192 + vmsize: 4096 + fileoff: 4096 + filesize: 4096 + maxprot: 7 + initprot: 3 + nsects: 1 + flags: 0 + Sections: + - sectname: __data + segname: __DATA + addr: 0x2000 + size: 4 + offset: 0x1000 + align: 2 + reloff: 0 + nreloc: 0 + flags: 0x00000000 + reserved1: 0 + reserved2: 0 + content: '2A000000' + - cmd: LC_SEGMENT + cmdsize: 56 + segname: __LINKEDIT + vmaddr: 12288 + vmsize: 4096 + fileoff: 8192 + filesize: 56 + maxprot: 7 + initprot: 1 + nsects: 0 + flags: 0 + - cmd: LC_SYMTAB + cmdsize: 24 + symoff: 8192 + nsyms: 2 + stroff: 8216 + strsize: 32 + - cmd: LC_DYSYMTAB + cmdsize: 80 + ilocalsym: 0 + nlocalsym: 0 + iextdefsym: 0 + nextdefsym: 2 + iundefsym: 2 + nundefsym: 0 + tocoff: 0 + ntoc: 0 + modtaboff: 0 + nmodtab: 0 + extrefsymoff: 0 + nextrefsyms: 0 + indirectsymoff: 0 + nindirectsyms: 0 + extreloff: 0 + nextrel: 0 + locreloff: 0 + nlocrel: 0 + - cmd: LC_LOAD_DYLINKER + cmdsize: 28 + name: 12 + Content: '/usr/lib/dyld' + ZeroPadBytes: 1 + - cmd: LC_UUID + cmdsize: 24 + uuid: 4B4F544F-5231-4D41-4300-000000000001 + - cmd: LC_UNIXTHREAD + cmdsize: 80 + PayloadBytes: [ 0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ] + - cmd: LC_LOAD_DYLIB + cmdsize: 52 + dylib: + name: 24 + timestamp: 2 + current_version: 0x0007D001 + compatibility_version: 0x00010000 + Content: '/usr/lib/libSystem.B.dylib' + ZeroPadBytes: 2 + - cmd: LC_LOAD_DYLIB + cmdsize: 52 + dylib: + name: 24 + timestamp: 2 + current_version: 0x00070000 + compatibility_version: 0x00070000 + Content: '/usr/lib/libstdc++.6.dylib' + ZeroPadBytes: 2 diff --git a/src/LibObjectFile.Tests/MachO/unixthread_i386_rpath b/src/LibObjectFile.Tests/MachO/unixthread_i386_rpath new file mode 100644 index 0000000000000000000000000000000000000000..586dbc9748e781a9c8f13810611c1f3d50fe0bce GIT binary patch literal 8248 zcmeH_!AiqG5QY=2R8YiYK@T}wNfbmp8f}zPwWa1DUY4{8SZq<6Ep5R=FTRs^@8V;4 z@dZ4H|75#uv{k%k28Ny8o!OcHOI|+SKl6rREW#JytME4jkCtheh!5aG)ZA0=+N)Qw z(YCIG?`sZQs(XM7;d@tp>Yi$J9aieQ1gCs5A!Ia=Dpva+^@VZyvYK_0nKMQ@p7t)e zzN=5MT5#Qe+?zB{dxwDxa4%It+ z_aH5~r?(4M5*jOrYw)zu{cru#dGq2m9B#tt8#}$Y>$F=9Cmgmz>^I;vL#66h>$~Uf zF=CiW--l+Pc}c>*EfF-EO}+QvbWJ+US4Cqo4`#X?#xm;IM>b|_HPpQ68ujPory7tI z%dlB2+WWc&I!D`hpZXm}gQ(e)L8Bds`#|0}w(XpBgHF`H>)yslU}FMIfC*%fz!PbH y&Y*CX%mkPK6JP>NAj1T=6Zir@3P#-k literal 0 HcmV?d00001 From 6a7bfdaf193a75a0f4e21fe2fcc4b58051fcdea2 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:22:39 -0400 Subject: [PATCH 02/32] Add Mach-O format constants and raw structures Nothing in the library described the Mach-O format, so there was no vocabulary to write a reader against. Adds the load command, CPU, file type, header flag, segment, section, platform and relocation type enumerations, the packed version helpers, and the blittable structures matching the on-disk layout. The structures are hand-written rather than generated from Apple's headers, whose licence this project cannot bundle, which follows what the PE support already does. A test pins every structure size, because the fields are copied by value and a wrong size would shift every later field rather than fail outright. - src/LibObjectFile/MachO/MachOLoadCommandType.cs: keeps the LC_REQ_DYLD high bit as part of the stored value - src/LibObjectFile/MachO/MachORelocation.cs: the two forms of entry, told apart by the top bit of the first word - src/LibObjectFile/MachO/Internal: the on-disk structures, including the always big-endian universal binary header --- .../MachO/MachOSimpleTests.cs | 30 ++++ src/LibObjectFile/Diagnostics/DiagnosticId.cs | 20 +++ src/LibObjectFile/MachO/Internal/MachOName.cs | 40 ++++++ .../MachO/Internal/RawBuildToolVersion.cs | 19 +++ .../MachO/Internal/RawBuildVersionCommand.cs | 24 ++++ .../MachO/Internal/RawDyldInfoCommand.cs | 34 +++++ .../MachO/Internal/RawDylibCommand.cs | 27 ++++ .../MachO/Internal/RawDysymtabCommand.cs | 42 ++++++ .../MachO/Internal/RawEntryPointCommand.cs | 21 +++ .../MachO/Internal/RawFatArch.cs | 22 +++ .../MachO/Internal/RawFatArch64.cs | 24 ++++ .../MachO/Internal/RawFatHeader.cs | 25 ++++ .../MachO/Internal/RawLinkEditDataCommand.cs | 21 +++ .../MachO/Internal/RawLoadCommand.cs | 20 +++ .../MachO/Internal/RawMachHeader32.cs | 24 ++++ .../MachO/Internal/RawMachHeader64.cs | 26 ++++ .../MachO/Internal/RawPathCommand.cs | 21 +++ .../MachO/Internal/RawRelocationInfo.cs | 24 ++++ .../MachO/Internal/RawSection32.cs | 29 ++++ .../MachO/Internal/RawSection64.cs | 29 ++++ .../MachO/Internal/RawSegmentCommand32.cs | 29 ++++ .../MachO/Internal/RawSegmentCommand64.cs | 29 ++++ .../MachO/Internal/RawSourceVersionCommand.cs | 20 +++ .../MachO/Internal/RawSymtabCommand.cs | 23 +++ .../MachO/Internal/RawTwoLevelHintsCommand.cs | 21 +++ .../MachO/Internal/RawUuidCommand.cs | 20 +++ .../MachO/Internal/RawVersionMinCommand.cs | 21 +++ .../MachO/MachOBuildToolVersion.cs | 40 ++++++ src/LibObjectFile/MachO/MachOCpuType.cs | 33 +++++ src/LibObjectFile/MachO/MachOFileType.cs | 36 +++++ src/LibObjectFile/MachO/MachOHeaderFlags.cs | 75 ++++++++++ .../MachO/MachOLoadCommandType.cs | 130 +++++++++++++++++ src/LibObjectFile/MachO/MachOMagic.cs | 41 ++++++ src/LibObjectFile/MachO/MachOPlatform.cs | 42 ++++++ src/LibObjectFile/MachO/MachORelocation.cs | 136 ++++++++++++++++++ .../MachO/MachORelocationType.cs | 84 +++++++++++ .../MachO/MachOSectionAttributes.cs | 38 +++++ src/LibObjectFile/MachO/MachOSectionType.cs | 64 +++++++++ src/LibObjectFile/MachO/MachOSegmentFlags.cs | 27 ++++ src/LibObjectFile/MachO/MachOVersion.cs | 48 +++++++ src/LibObjectFile/MachO/MachOVmProtection.cs | 24 ++++ 41 files changed, 1503 insertions(+) create mode 100644 src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs create mode 100644 src/LibObjectFile/MachO/Internal/MachOName.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawBuildToolVersion.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawBuildVersionCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawDyldInfoCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawDylibCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawDysymtabCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawEntryPointCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawFatArch.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawFatArch64.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawFatHeader.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawLinkEditDataCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawLoadCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawMachHeader32.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawMachHeader64.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawPathCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawRelocationInfo.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSection32.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSection64.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSegmentCommand32.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSegmentCommand64.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSourceVersionCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawSymtabCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawTwoLevelHintsCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawUuidCommand.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawVersionMinCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOBuildToolVersion.cs create mode 100644 src/LibObjectFile/MachO/MachOCpuType.cs create mode 100644 src/LibObjectFile/MachO/MachOFileType.cs create mode 100644 src/LibObjectFile/MachO/MachOHeaderFlags.cs create mode 100644 src/LibObjectFile/MachO/MachOLoadCommandType.cs create mode 100644 src/LibObjectFile/MachO/MachOMagic.cs create mode 100644 src/LibObjectFile/MachO/MachOPlatform.cs create mode 100644 src/LibObjectFile/MachO/MachORelocation.cs create mode 100644 src/LibObjectFile/MachO/MachORelocationType.cs create mode 100644 src/LibObjectFile/MachO/MachOSectionAttributes.cs create mode 100644 src/LibObjectFile/MachO/MachOSectionType.cs create mode 100644 src/LibObjectFile/MachO/MachOSegmentFlags.cs create mode 100644 src/LibObjectFile/MachO/MachOVersion.cs create mode 100644 src/LibObjectFile/MachO/MachOVmProtection.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs new file mode 100644 index 0000000..ba2bc33 --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.Tests.MachO; + +[TestClass] +public class MachOSimpleTests +{ + /// + /// The raw structures are copied by value straight out of the file, so a wrong size or an + /// unexpected padding byte would silently shift every field after it. + /// + [TestMethod] + public unsafe void RawStructuresMatchTheOnDiskLayout() + { + Assert.AreEqual(28, sizeof(RawMachHeader32)); + Assert.AreEqual(32, sizeof(RawMachHeader64)); + Assert.AreEqual(8, sizeof(RawLoadCommand)); + Assert.AreEqual(56, sizeof(RawSegmentCommand32)); + Assert.AreEqual(72, sizeof(RawSegmentCommand64)); + Assert.AreEqual(68, sizeof(RawSection32)); + Assert.AreEqual(80, sizeof(RawSection64)); + Assert.AreEqual(8, sizeof(RawFatHeader)); + Assert.AreEqual(20, sizeof(RawFatArch)); + Assert.AreEqual(32, sizeof(RawFatArch64)); + } +} diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 352a183..9789903 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -213,4 +213,24 @@ public enum DiagnosticId PE_ERR_InvalidResourceDirectoryEntry = 4001, PE_ERR_InvalidResourceDirectoryEntryRVAOffsetToData = 4002, PE_ERR_InvalidResourceString = 4003, + + // Mach-O + MACHO_ERR_InvalidMagic = 5000, + MACHO_ERR_UnsupportedByteOrder = 5001, + MACHO_ERR_InvalidLoadCommandSize = 5002, + MACHO_ERR_TruncatedLoadCommand = 5003, + MACHO_ERR_InvalidSegmentFileRange = 5004, + MACHO_ERR_InvalidSectionFileRange = 5005, + MACHO_ERR_InvalidFatHeader = 5006, + MACHO_ERR_InvalidFatArchRange = 5007, + MACHO_ERR_NoRoomForLoadCommands = 5008, + MACHO_ERR_ValueTooLargeFor32Bit = 5009, + MACHO_ERR_UnexpectedFatFile = 5010, + MACHO_ERR_DataOutsideImage = 5011, + MACHO_ERR_ContentNotContiguous = 5012, + MACHO_ERR_SectionAddressMismatch = 5013, + MACHO_ERR_InvalidCommandAlignment = 5014, + MACHO_ERR_SectionOutsideSegment = 5015, + MACHO_ERR_StaleCodeSignature = 5016, + MACHO_ERR_SectionContentMismatch = 5017, } \ No newline at end of file diff --git a/src/LibObjectFile/MachO/Internal/MachOName.cs b/src/LibObjectFile/MachO/Internal/MachOName.cs new file mode 100644 index 0000000..0e73ba2 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/MachOName.cs @@ -0,0 +1,40 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; + +namespace LibObjectFile.MachO.Internal; + +/// +/// Helpers for the fixed 16-byte name fields used by segments and sections. +/// +internal static class MachOName +{ + /// + /// The width of a segment or section name field. + /// + public const int Length = 16; + + /// + /// Decodes a fixed-width name. A name that fills the field has no terminator, so the + /// terminator is optional rather than required. + /// + public static string Read(ReadOnlySpan span) + { + int end = span.IndexOf((byte)0); + if (end < 0) end = span.Length; + return Encoding.UTF8.GetString(span.Slice(0, end)); + } + + /// + /// Encodes a name into a fixed-width field, zero-filling the remainder. Names longer than + /// the field are truncated, which matches what the linker does. + /// + public static void Write(Span span, string name) + { + span.Clear(); + Encoding.UTF8.TryGetBytes(name, span, out _); + } +} diff --git a/src/LibObjectFile/MachO/Internal/RawBuildToolVersion.cs b/src/LibObjectFile/MachO/Internal/RawBuildToolVersion.cs new file mode 100644 index 0000000..cdea4a9 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawBuildToolVersion.cs @@ -0,0 +1,19 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// One tool entry of a build version command (build_tool_version). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawBuildToolVersion +{ + public uint Tool; + public uint Version; +} diff --git a/src/LibObjectFile/MachO/Internal/RawBuildVersionCommand.cs b/src/LibObjectFile/MachO/Internal/RawBuildVersionCommand.cs new file mode 100644 index 0000000..3b0bf25 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawBuildVersionCommand.cs @@ -0,0 +1,24 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The build version load command (build_version_command), followed by +/// ToolCount tool entries. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawBuildVersionCommand +{ + public uint Cmd; + public uint CmdSize; + public uint Platform; + public uint MinOS; + public uint Sdk; + public uint ToolCount; +} diff --git a/src/LibObjectFile/MachO/Internal/RawDyldInfoCommand.cs b/src/LibObjectFile/MachO/Internal/RawDyldInfoCommand.cs new file mode 100644 index 0000000..9470ebf --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawDyldInfoCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The compressed dyld information load command (dyld_info_command). +/// +/// +/// Each pair locates an opcode stream in __LINKEDIT. The streams address their targets by +/// segment index and offset within that segment, never by file offset, so relocating them only +/// requires updating the offsets recorded here. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawDyldInfoCommand +{ + public uint Cmd; + public uint CmdSize; + public uint RebaseOffset; + public uint RebaseSize; + public uint BindOffset; + public uint BindSize; + public uint WeakBindOffset; + public uint WeakBindSize; + public uint LazyBindOffset; + public uint LazyBindSize; + public uint ExportOffset; + public uint ExportSize; +} diff --git a/src/LibObjectFile/MachO/Internal/RawDylibCommand.cs b/src/LibObjectFile/MachO/Internal/RawDylibCommand.cs new file mode 100644 index 0000000..f7156f7 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawDylibCommand.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A dylib load command (dylib_command), followed by the library path. +/// +/// +/// NameOffset is measured from the start of the command, not from the end of this +/// structure, so a command whose path is preceded by padding is still valid. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawDylibCommand +{ + public uint Cmd; + public uint CmdSize; + public uint NameOffset; + public uint Timestamp; + public uint CurrentVersion; + public uint CompatibilityVersion; +} diff --git a/src/LibObjectFile/MachO/Internal/RawDysymtabCommand.cs b/src/LibObjectFile/MachO/Internal/RawDysymtabCommand.cs new file mode 100644 index 0000000..7befc63 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawDysymtabCommand.cs @@ -0,0 +1,42 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The dynamic symbol table load command (dysymtab_command). +/// +/// +/// The first three pairs are index and count into the symbol table proper, which the linker +/// sorts into local, external and undefined runs. The rest are file offsets to tables of their +/// own, each paired with an entry count rather than a byte size. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawDysymtabCommand +{ + public uint Cmd; + public uint CmdSize; + public uint LocalSymbolIndex; + public uint LocalSymbolCount; + public uint ExternalSymbolIndex; + public uint ExternalSymbolCount; + public uint UndefinedSymbolIndex; + public uint UndefinedSymbolCount; + public uint TableOfContentsOffset; + public uint TableOfContentsCount; + public uint ModuleTableOffset; + public uint ModuleTableCount; + public uint ExternalReferenceOffset; + public uint ExternalReferenceCount; + public uint IndirectSymbolOffset; + public uint IndirectSymbolCount; + public uint ExternalRelocationOffset; + public uint ExternalRelocationCount; + public uint LocalRelocationOffset; + public uint LocalRelocationCount; +} diff --git a/src/LibObjectFile/MachO/Internal/RawEntryPointCommand.cs b/src/LibObjectFile/MachO/Internal/RawEntryPointCommand.cs new file mode 100644 index 0000000..7b131f9 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawEntryPointCommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The entry point load command (entry_point_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawEntryPointCommand +{ + public uint Cmd; + public uint CmdSize; + public ulong EntryOffset; + public ulong StackSize; +} diff --git a/src/LibObjectFile/MachO/Internal/RawFatArch.cs b/src/LibObjectFile/MachO/Internal/RawFatArch.cs new file mode 100644 index 0000000..c4a0ff1 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawFatArch.cs @@ -0,0 +1,22 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// One slice of a universal binary (fat_arch). Stored big-endian; see . +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawFatArch +{ + public uint CpuType; + public uint CpuSubType; + public uint Offset; + public uint Size; + public uint Align; +} diff --git a/src/LibObjectFile/MachO/Internal/RawFatArch64.cs b/src/LibObjectFile/MachO/Internal/RawFatArch64.cs new file mode 100644 index 0000000..7589894 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawFatArch64.cs @@ -0,0 +1,24 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// One slice of a universal binary using 64-bit offsets (fat_arch_64), used when a slice +/// starts beyond 4GB. Stored big-endian; see . +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawFatArch64 +{ + public uint CpuType; + public uint CpuSubType; + public ulong Offset; + public ulong Size; + public uint Align; + public uint Reserved; +} diff --git a/src/LibObjectFile/MachO/Internal/RawFatHeader.cs b/src/LibObjectFile/MachO/Internal/RawFatHeader.cs new file mode 100644 index 0000000..f05e250 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawFatHeader.cs @@ -0,0 +1,25 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The header of a universal binary (fat_header), followed by NumberOfArchitectures +/// architecture entries. +/// +/// +/// Unlike every other structure in the format, the fat header and its architecture entries are +/// always stored big-endian regardless of the architectures they contain, so they cannot be read +/// by a straight copy on a little-endian host. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawFatHeader +{ + public uint Magic; + public uint NumberOfArchitectures; +} diff --git a/src/LibObjectFile/MachO/Internal/RawLinkEditDataCommand.cs b/src/LibObjectFile/MachO/Internal/RawLinkEditDataCommand.cs new file mode 100644 index 0000000..b783c0d --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawLinkEditDataCommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A load command pointing at a blob in __LINKEDIT (linkedit_data_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawLinkEditDataCommand +{ + public uint Cmd; + public uint CmdSize; + public uint DataOffset; + public uint DataSize; +} diff --git a/src/LibObjectFile/MachO/Internal/RawLoadCommand.cs b/src/LibObjectFile/MachO/Internal/RawLoadCommand.cs new file mode 100644 index 0000000..bc48396 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawLoadCommand.cs @@ -0,0 +1,20 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The header every load command starts with (load_command). CmdSize covers the +/// header itself, so walking the command list means advancing by that value. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawLoadCommand +{ + public uint Cmd; + public uint CmdSize; +} diff --git a/src/LibObjectFile/MachO/Internal/RawMachHeader32.cs b/src/LibObjectFile/MachO/Internal/RawMachHeader32.cs new file mode 100644 index 0000000..ac0edad --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawMachHeader32.cs @@ -0,0 +1,24 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The 32-bit Mach-O file header (mach_header). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawMachHeader32 +{ + public uint Magic; + public uint CpuType; + public uint CpuSubType; + public uint FileType; + public uint NumberOfCommands; + public uint SizeOfCommands; + public uint Flags; +} diff --git a/src/LibObjectFile/MachO/Internal/RawMachHeader64.cs b/src/LibObjectFile/MachO/Internal/RawMachHeader64.cs new file mode 100644 index 0000000..952679e --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawMachHeader64.cs @@ -0,0 +1,26 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The 64-bit Mach-O file header (mach_header_64). Identical to the 32-bit header +/// except for the trailing reserved field, which pads it to 32 bytes. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawMachHeader64 +{ + public uint Magic; + public uint CpuType; + public uint CpuSubType; + public uint FileType; + public uint NumberOfCommands; + public uint SizeOfCommands; + public uint Flags; + public uint Reserved; +} diff --git a/src/LibObjectFile/MachO/Internal/RawPathCommand.cs b/src/LibObjectFile/MachO/Internal/RawPathCommand.cs new file mode 100644 index 0000000..2bc62ab --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawPathCommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The shared shape of the load commands that carry a single path, covering +/// rpath_command, dylinker_command and sub_*_command. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawPathCommand +{ + public uint Cmd; + public uint CmdSize; + public uint PathOffset; +} diff --git a/src/LibObjectFile/MachO/Internal/RawRelocationInfo.cs b/src/LibObjectFile/MachO/Internal/RawRelocationInfo.cs new file mode 100644 index 0000000..f946135 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawRelocationInfo.cs @@ -0,0 +1,24 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A relocation entry (relocation_info or scattered_relocation_info). +/// +/// +/// The two forms share a size and are told apart by the top bit of the first word. C bitfields +/// have no equivalent here, so both words are kept whole and unpacked by hand, which also avoids +/// depending on how a compiler happens to lay the fields out. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawRelocationInfo +{ + public uint Word0; + public uint Word1; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSection32.cs b/src/LibObjectFile/MachO/Internal/RawSection32.cs new file mode 100644 index 0000000..70fb779 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSection32.cs @@ -0,0 +1,29 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 32-bit section header (section). It carries its own segment name, so a section is +/// self-describing even though it is stored inside a segment command. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal unsafe struct RawSection32 +{ + public fixed byte SectionName[16]; + public fixed byte SegmentName[16]; + public uint Address; + public uint Size; + public uint Offset; + public uint Align; + public uint RelocationOffset; + public uint NumberOfRelocations; + public uint Flags; + public uint Reserved1; + public uint Reserved2; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSection64.cs b/src/LibObjectFile/MachO/Internal/RawSection64.cs new file mode 100644 index 0000000..0c4adbd --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSection64.cs @@ -0,0 +1,29 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 64-bit section header (section_64). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal unsafe struct RawSection64 +{ + public fixed byte SectionName[16]; + public fixed byte SegmentName[16]; + public ulong Address; + public ulong Size; + public uint Offset; + public uint Align; + public uint RelocationOffset; + public uint NumberOfRelocations; + public uint Flags; + public uint Reserved1; + public uint Reserved2; + public uint Reserved3; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSegmentCommand32.cs b/src/LibObjectFile/MachO/Internal/RawSegmentCommand32.cs new file mode 100644 index 0000000..c5a1bc8 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSegmentCommand32.cs @@ -0,0 +1,29 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 32-bit segment load command (segment_command), followed in the file by +/// NumberOfSections entries. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal unsafe struct RawSegmentCommand32 +{ + public uint Cmd; + public uint CmdSize; + public fixed byte SegmentName[16]; + public uint VmAddress; + public uint VmSize; + public uint FileOffset; + public uint FileSize; + public uint MaxProtection; + public uint InitProtection; + public uint NumberOfSections; + public uint Flags; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSegmentCommand64.cs b/src/LibObjectFile/MachO/Internal/RawSegmentCommand64.cs new file mode 100644 index 0000000..945c85d --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSegmentCommand64.cs @@ -0,0 +1,29 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 64-bit segment load command (segment_command_64), followed in the file by +/// NumberOfSections entries. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal unsafe struct RawSegmentCommand64 +{ + public uint Cmd; + public uint CmdSize; + public fixed byte SegmentName[16]; + public ulong VmAddress; + public ulong VmSize; + public ulong FileOffset; + public ulong FileSize; + public uint MaxProtection; + public uint InitProtection; + public uint NumberOfSections; + public uint Flags; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSourceVersionCommand.cs b/src/LibObjectFile/MachO/Internal/RawSourceVersionCommand.cs new file mode 100644 index 0000000..62fc527 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSourceVersionCommand.cs @@ -0,0 +1,20 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The source version load command (source_version_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawSourceVersionCommand +{ + public uint Cmd; + public uint CmdSize; + public ulong Version; +} diff --git a/src/LibObjectFile/MachO/Internal/RawSymtabCommand.cs b/src/LibObjectFile/MachO/Internal/RawSymtabCommand.cs new file mode 100644 index 0000000..271ac6f --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawSymtabCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The symbol table load command (symtab_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawSymtabCommand +{ + public uint Cmd; + public uint CmdSize; + public uint SymbolOffset; + public uint SymbolCount; + public uint StringOffset; + public uint StringSize; +} diff --git a/src/LibObjectFile/MachO/Internal/RawTwoLevelHintsCommand.cs b/src/LibObjectFile/MachO/Internal/RawTwoLevelHintsCommand.cs new file mode 100644 index 0000000..1fe7038 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawTwoLevelHintsCommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The two-level namespace hint table load command (twolevel_hints_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawTwoLevelHintsCommand +{ + public uint Cmd; + public uint CmdSize; + public uint Offset; + public uint HintCount; +} diff --git a/src/LibObjectFile/MachO/Internal/RawUuidCommand.cs b/src/LibObjectFile/MachO/Internal/RawUuidCommand.cs new file mode 100644 index 0000000..6cf334e --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawUuidCommand.cs @@ -0,0 +1,20 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// The image identifier load command (uuid_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal unsafe struct RawUuidCommand +{ + public uint Cmd; + public uint CmdSize; + public fixed byte Uuid[16]; +} diff --git a/src/LibObjectFile/MachO/Internal/RawVersionMinCommand.cs b/src/LibObjectFile/MachO/Internal/RawVersionMinCommand.cs new file mode 100644 index 0000000..7563c37 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawVersionMinCommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A minimum OS version load command (version_min_command). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawVersionMinCommand +{ + public uint Cmd; + public uint CmdSize; + public uint Version; + public uint Sdk; +} diff --git a/src/LibObjectFile/MachO/MachOBuildToolVersion.cs b/src/LibObjectFile/MachO/MachOBuildToolVersion.cs new file mode 100644 index 0000000..acca812 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOBuildToolVersion.cs @@ -0,0 +1,40 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// A tool that took part in producing an image, as recorded by LC_BUILD_VERSION. +/// +public enum MachOBuildTool : uint +{ + /// The tool is not one of the known values. + Unknown = 0, + /// The Clang compiler (TOOL_CLANG). + Clang = 1, + /// The Swift compiler (TOOL_SWIFT). + Swift = 2, + /// The static linker (TOOL_LD). + Ld = 3, + /// An alternative linker (TOOL_LLD). + Lld = 4, +} + +/// +/// One entry of the tool list in . +/// +/// The tool. +/// The tool's version, packed as 16.8.8 bits. +public readonly record struct MachOBuildToolVersion(MachOBuildTool Tool, uint PackedVersion) +{ + /// + /// Gets the tool's version. + /// + public Version Version => MachOVersion.Decode(PackedVersion); + + /// + public override string ToString() => $"{Tool} {Version}"; +} diff --git a/src/LibObjectFile/MachO/MachOCpuType.cs b/src/LibObjectFile/MachO/MachOCpuType.cs new file mode 100644 index 0000000..5b65b08 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOCpuType.cs @@ -0,0 +1,33 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// CPU architecture of a Mach-O image, as stored in the cputype header field. +/// +/// +/// The 64-bit variants are the 32-bit value with set, which is how a +/// reader tells x86_64 from i386 without consulting the magic. +/// +public enum MachOCpuType : uint +{ + /// Marks a type as using the 64-bit ABI (CPU_ARCH_ABI64). + Abi64 = 0x01000000, + + /// Motorola 68000 (CPU_TYPE_MC680x0). + MC680x0 = 6, + /// 32-bit Intel, also known as CPU_TYPE_I386 (CPU_TYPE_X86). + X86 = 7, + /// 64-bit Intel (CPU_TYPE_X86_64). + X86_64 = X86 | Abi64, + /// 32-bit ARM (CPU_TYPE_ARM). + Arm = 12, + /// 64-bit ARM (CPU_TYPE_ARM64). + Arm64 = Arm | Abi64, + /// 32-bit PowerPC (CPU_TYPE_POWERPC). + PowerPC = 18, + /// 64-bit PowerPC (CPU_TYPE_POWERPC64). + PowerPC64 = PowerPC | Abi64, +} diff --git a/src/LibObjectFile/MachO/MachOFileType.cs b/src/LibObjectFile/MachO/MachOFileType.cs new file mode 100644 index 0000000..0b58c99 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFileType.cs @@ -0,0 +1,36 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Kind of Mach-O image, as stored in the filetype header field. +/// +public enum MachOFileType : uint +{ + /// Relocatable object file (MH_OBJECT). + Object = 0x1, + /// Demand paged executable (MH_EXECUTE). + Execute = 0x2, + /// Fixed VM shared library, obsolete (MH_FVMLIB). + FixedVMLibrary = 0x3, + /// Core dump (MH_CORE). + Core = 0x4, + /// Preloaded executable (MH_PRELOAD). + Preload = 0x5, + /// Dynamically bound shared library (MH_DYLIB). + Dylib = 0x6, + /// Dynamic link editor (MH_DYLINKER). + Dylinker = 0x7, + /// Dynamically bound bundle (MH_BUNDLE). + Bundle = 0x8, + /// Shared library stub for static linking only (MH_DYLIB_STUB). + DylibStub = 0x9, + /// Companion file carrying only debug sections (MH_DSYM). + Dsym = 0xa, + /// x86_64 kexts (MH_KEXT_BUNDLE). + KextBundle = 0xb, + /// Set of Mach-O images linked together, used by kernel collections (MH_FILESET). + Fileset = 0xc, +} diff --git a/src/LibObjectFile/MachO/MachOHeaderFlags.cs b/src/LibObjectFile/MachO/MachOHeaderFlags.cs new file mode 100644 index 0000000..1284f6e --- /dev/null +++ b/src/LibObjectFile/MachO/MachOHeaderFlags.cs @@ -0,0 +1,75 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Flags of a Mach-O image, as stored in the flags header field. +/// +[Flags] +public enum MachOHeaderFlags : uint +{ + /// No flags set. + None = 0, + /// The image has no undefined references (MH_NOUNDEFS). + NoUndefs = 0x1, + /// Output of an incremental link, not re-linkable (MH_INCRLINK). + IncrementalLink = 0x2, + /// The image is input for the dynamic linker and cannot be re-linked (MH_DYLDLINK). + DyldLink = 0x4, + /// Undefined references are bound by the dynamic linker when loaded (MH_BINDATLOAD). + BindAtLoad = 0x8, + /// The image has its dynamic undefined references prebound (MH_PREBOUND). + Prebound = 0x10, + /// The image has its read-only and read-write segments split (MH_SPLIT_SEGS). + SplitSegs = 0x20, + /// The shared library init routine is to be run lazily, obsolete (MH_LAZY_INIT). + LazyInit = 0x40, + /// The image uses two-level namespace bindings (MH_TWOLEVEL). + TwoLevel = 0x80, + /// The executable forces all images to use flat namespace bindings (MH_FORCE_FLAT). + ForceFlat = 0x100, + /// No multiple definitions of symbols exist, so no lookup is needed (MH_NOMULTIDEFS). + NoMultiDefs = 0x200, + /// Do not notify the prebinding agent about this executable (MH_NOFIXPREBINDING). + NoFixPrebinding = 0x400, + /// The binary is not prebound but can have its prebinding redone (MH_PREBINDABLE). + Prebindable = 0x800, + /// All two-level namespace modules of dependent libraries are bound (MH_ALLMODSBOUND). + AllModsBound = 0x1000, + /// Sections of object files were divided into subsections by symbol (MH_SUBSECTIONS_VIA_SYMBOLS). + SubsectionsViaSymbols = 0x2000, + /// The binary has been canonicalized (MH_CANONICAL). + Canonical = 0x4000, + /// The final linked image contains external weak symbols (MH_WEAK_DEFINES). + WeakDefines = 0x8000, + /// The final linked image uses weak symbols (MH_BINDS_TO_WEAK). + BindsToWeak = 0x10000, + /// Stack segments are made executable (MH_ALLOW_STACK_EXECUTION). + AllowStackExecution = 0x20000, + /// The binary is safe for use in processes with uid zero (MH_ROOT_SAFE). + RootSafe = 0x40000, + /// The binary is safe for use in processes when issetugid is true (MH_SETUID_SAFE). + SetuidSafe = 0x80000, + /// The image has no re-exported dylibs, so sub-library resolution can be skipped (MH_NO_REEXPORTED_DYLIBS). + NoReexportedDylibs = 0x100000, + /// The image is position independent and loads at a random address (MH_PIE). + PositionIndependent = 0x200000, + /// The linker should strip this dylib if it is not used (MH_DEAD_STRIPPABLE_DYLIB). + DeadStrippableDylib = 0x400000, + /// The image has thread-local variable descriptors (MH_HAS_TLV_DESCRIPTORS). + HasTlvDescriptors = 0x800000, + /// The image has no executable heap pages (MH_NO_HEAP_EXECUTION). + NoHeapExecution = 0x1000000, + /// The code was linked for use in an application extension (MH_APP_EXTENSION_SAFE). + AppExtensionSafe = 0x2000000, + /// The external symbols in the symbol table do not agree with the dyld info (MH_NLIST_OUTOFSYNC_WITH_DYLDINFO). + NListOutOfSyncWithDyldInfo = 0x4000000, + /// The image supports running in a simulator (MH_SIM_SUPPORT). + SimSupport = 0x8000000, + /// The dylib is part of the shared cache rather than a standalone file (MH_DYLIB_IN_CACHE). + DylibInCache = 0x80000000, +} diff --git a/src/LibObjectFile/MachO/MachOLoadCommandType.cs b/src/LibObjectFile/MachO/MachOLoadCommandType.cs new file mode 100644 index 0000000..254e194 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOLoadCommandType.cs @@ -0,0 +1,130 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Type of a load command, as stored in the cmd field of a Mach-O load command. +/// +/// +/// The high bit is part of the stored value, not a +/// separate flag. It marks commands that dyld must understand to load the image at all, so a +/// loader that does not recognise one has to refuse the file rather than skip the command. +/// +public enum MachOLoadCommandType : uint +{ + /// Set on commands that dyld is required to understand rather than skip. + RequiredByDynamicLinker = 0x80000000, + + /// 32-bit segment of the file mapped into memory (LC_SEGMENT). + Segment = 0x1, + /// Symbol table and string table location (LC_SYMTAB). + SymbolTable = 0x2, + /// Gdb symbol table info, obsolete (LC_SYMSEG). + SymbolSegment = 0x3, + /// Thread state, without a stack (LC_THREAD). + Thread = 0x4, + /// Thread state and stack, used as the entry point before LC_MAIN (LC_UNIXTHREAD). + UnixThread = 0x5, + /// Load a fixed VM shared library, obsolete (LC_LOADFVMLIB). + LoadFixedVMLibrary = 0x6, + /// Fixed VM shared library identification, obsolete (LC_IDFVMLIB). + IdFixedVMLibrary = 0x7, + /// Object identification, obsolete (LC_IDENT). + Identification = 0x8, + /// Fixed VM file inclusion, obsolete (LC_FVMFILE). + FixedVMFile = 0x9, + /// Prepage command, obsolete (LC_PREPAGE). + Prepage = 0xa, + /// Dynamic link editor symbol table info (LC_DYSYMTAB). + DynamicSymbolTable = 0xb, + /// Load a dynamically linked shared library (LC_LOAD_DYLIB). + LoadDylib = 0xc, + /// Identification of a dynamically linked shared library (LC_ID_DYLIB). + IdDylib = 0xd, + /// Load a dynamic linker (LC_LOAD_DYLINKER). + LoadDylinker = 0xe, + /// Dynamic linker identification (LC_ID_DYLINKER). + IdDylinker = 0xf, + /// Modules prebound for a dynamically linked shared library (LC_PREBOUND_DYLIB). + PreboundDylib = 0x10, + /// Image routines (LC_ROUTINES). + Routines = 0x11, + /// Sub framework (LC_SUB_FRAMEWORK). + SubFramework = 0x12, + /// Sub umbrella (LC_SUB_UMBRELLA). + SubUmbrella = 0x13, + /// Sub client (LC_SUB_CLIENT). + SubClient = 0x14, + /// Sub library (LC_SUB_LIBRARY). + SubLibrary = 0x15, + /// Two-level namespace lookup hints (LC_TWOLEVEL_HINTS). + TwoLevelHints = 0x16, + /// Prebind checksum (LC_PREBIND_CKSUM). + PrebindChecksum = 0x17, + /// Load a weak dylib, tolerated as missing at runtime (LC_LOAD_WEAK_DYLIB). + LoadWeakDylib = 0x18 | RequiredByDynamicLinker, + /// 64-bit segment of the file mapped into memory (LC_SEGMENT_64). + Segment64 = 0x19, + /// 64-bit image routines (LC_ROUTINES_64). + Routines64 = 0x1a, + /// The image UUID (LC_UUID). + Uuid = 0x1b, + /// Runpath additions used to resolve @rpath (LC_RPATH). + RPath = 0x1c | RequiredByDynamicLinker, + /// Local of code signature (LC_CODE_SIGNATURE). + CodeSignature = 0x1d, + /// Local of info to split segments (LC_SEGMENT_SPLIT_INFO). + SegmentSplitInfo = 0x1e, + /// Load and re-export a dylib (LC_REEXPORT_DYLIB). + ReexportDylib = 0x1f | RequiredByDynamicLinker, + /// Delay load of a dylib until first use (LC_LAZY_LOAD_DYLIB). + LazyLoadDylib = 0x20, + /// Encrypted segment information (LC_ENCRYPTION_INFO). + EncryptionInfo = 0x21, + /// Compressed dyld information (LC_DYLD_INFO). + DyldInfo = 0x22, + /// Compressed dyld information only, with no classic relocations (LC_DYLD_INFO_ONLY). + DyldInfoOnly = 0x22 | RequiredByDynamicLinker, + /// Load an upward dylib (LC_LOAD_UPWARD_DYLIB). + LoadUpwardDylib = 0x23 | RequiredByDynamicLinker, + /// Build for macOS minimum OS version (LC_VERSION_MIN_MACOSX). + VersionMinMacOSX = 0x24, + /// Build for iPhoneOS minimum OS version (LC_VERSION_MIN_IPHONEOS). + VersionMinIPhoneOS = 0x25, + /// Compressed table of function start addresses (LC_FUNCTION_STARTS). + FunctionStarts = 0x26, + /// String for dyld to treat like an environment variable (LC_DYLD_ENVIRONMENT). + DyldEnvironment = 0x27, + /// Replacement for LC_UNIXTHREAD as the entry point (LC_MAIN). + Main = 0x28 | RequiredByDynamicLinker, + /// Table of non-instructions in __text (LC_DATA_IN_CODE). + DataInCode = 0x29, + /// Source version used to build the binary (LC_SOURCE_VERSION). + SourceVersion = 0x2a, + /// Code signing designated requirements copied from linked dylibs (LC_DYLIB_CODE_SIGN_DRS). + DylibCodeSignDrs = 0x2b, + /// 64-bit encrypted segment information (LC_ENCRYPTION_INFO_64). + EncryptionInfo64 = 0x2c, + /// Linker options embedded in object files (LC_LINKER_OPTION). + LinkerOption = 0x2d, + /// Optimization hints in object files (LC_LINKER_OPTIMIZATION_HINT). + LinkerOptimizationHint = 0x2e, + /// Build for tvOS minimum OS version (LC_VERSION_MIN_TVOS). + VersionMinTvOS = 0x2f, + /// Build for watchOS minimum OS version (LC_VERSION_MIN_WATCHOS). + VersionMinWatchOS = 0x30, + /// Arbitrary data included within a Mach-O file (LC_NOTE). + Note = 0x31, + /// Build for platform minimum OS version, replacing the per-platform commands (LC_BUILD_VERSION). + BuildVersion = 0x32, + /// Exported symbols trie, split out of the dyld info (LC_DYLD_EXPORTS_TRIE). + DyldExportsTrie = 0x33 | RequiredByDynamicLinker, + /// Chained fixups, replacing the rebase and bind opcode streams (LC_DYLD_CHAINED_FIXUPS). + DyldChainedFixups = 0x34 | RequiredByDynamicLinker, + /// Entry in a fileset, used by the kernel collections (LC_FILESET_ENTRY). + FilesetEntry = 0x35 | RequiredByDynamicLinker, + /// Atom information for live linking (LC_ATOM_INFO). + AtomInfo = 0x36, +} diff --git a/src/LibObjectFile/MachO/MachOMagic.cs b/src/LibObjectFile/MachO/MachOMagic.cs new file mode 100644 index 0000000..97dfda9 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOMagic.cs @@ -0,0 +1,41 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// The magic values a Mach-O or universal binary can start with. +/// +/// +/// The Cigam spellings are the byte-swapped forms, written by a host of the opposite +/// endianness. The fat magics work the other way round from the thin ones: a universal binary +/// header is always big-endian, so is what a big-endian reader sees and +/// is what a little-endian host reads out of a well-formed file. +/// +public static class MachOMagic +{ + /// 32-bit Mach-O, host-endian (MH_MAGIC). + public const uint Magic32 = 0xfeedface; + + /// 32-bit Mach-O, byte-swapped (MH_CIGAM). + public const uint Cigam32 = 0xcefaedfe; + + /// 64-bit Mach-O, host-endian (MH_MAGIC_64). + public const uint Magic64 = 0xfeedfacf; + + /// 64-bit Mach-O, byte-swapped (MH_CIGAM_64). + public const uint Cigam64 = 0xcffaedfe; + + /// Universal binary with 32-bit slice offsets (FAT_MAGIC). + public const uint FatMagic = 0xcafebabe; + + /// Universal binary with 32-bit slice offsets, byte-swapped (FAT_CIGAM). + public const uint FatCigam = 0xbebafeca; + + /// Universal binary with 64-bit slice offsets (FAT_MAGIC_64). + public const uint FatMagic64 = 0xcafebabf; + + /// Universal binary with 64-bit slice offsets, byte-swapped (FAT_CIGAM_64). + public const uint FatCigam64 = 0xbfbafeca; +} diff --git a/src/LibObjectFile/MachO/MachOPlatform.cs b/src/LibObjectFile/MachO/MachOPlatform.cs new file mode 100644 index 0000000..4a71df1 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOPlatform.cs @@ -0,0 +1,42 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// The platform an image was built for, as recorded by LC_BUILD_VERSION. +/// +/// +/// This replaced the per-platform LC_VERSION_MIN_* commands, which could not express +/// targets such as Catalyst or a simulator without inventing a command for each. +/// +public enum MachOPlatform : uint +{ + /// No platform recorded. + Unknown = 0, + /// macOS (PLATFORM_MACOS). + MacOS = 1, + /// iOS (PLATFORM_IOS). + IOS = 2, + /// tvOS (PLATFORM_TVOS). + TvOS = 3, + /// watchOS (PLATFORM_WATCHOS). + WatchOS = 4, + /// The bridgeOS of a T2 coprocessor (PLATFORM_BRIDGEOS). + BridgeOS = 5, + /// An iOS app running on macOS (PLATFORM_MACCATALYST). + MacCatalyst = 6, + /// The iOS simulator (PLATFORM_IOSSIMULATOR). + IOSSimulator = 7, + /// The tvOS simulator (PLATFORM_TVOSSIMULATOR). + TvOSSimulator = 8, + /// The watchOS simulator (PLATFORM_WATCHOSSIMULATOR). + WatchOSSimulator = 9, + /// A driver extension (PLATFORM_DRIVERKIT). + DriverKit = 10, + /// visionOS (PLATFORM_VISIONOS). + VisionOS = 11, + /// The visionOS simulator (PLATFORM_VISIONOSSIMULATOR). + VisionOSSimulator = 12, +} diff --git a/src/LibObjectFile/MachO/MachORelocation.cs b/src/LibObjectFile/MachO/MachORelocation.cs new file mode 100644 index 0000000..d19936b --- /dev/null +++ b/src/LibObjectFile/MachO/MachORelocation.cs @@ -0,0 +1,136 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// A relocation entry, telling the linker how to fix up one place in a section. +/// +/// +/// Two forms share the same eight bytes. The ordinary form names a symbol or a section by number. +/// The scattered form carries the target address outright, for when the fixed-up value does not +/// land inside the thing it refers to and a section number would be ambiguous; it has no room +/// left for an external flag, so it is always local. +/// +public sealed class MachORelocation +{ + /// Bit of the first word marking an entry as scattered (R_SCATTERED). + public const uint ScatteredMask = 0x80000000; + + /// The size of one entry, which both forms share. + public const uint EntrySize = 8; + + /// + /// Gets or sets the offset of the fixed-up field from the start of its section. + /// + public int Address { get; set; } + + /// + /// Gets or sets the symbol index when is true, and the one-based + /// section number otherwise. Always zero for a scattered entry. + /// + public uint SymbolOrSectionNumber { get; set; } + + /// + /// Gets or sets the address of the target, for a scattered entry only. + /// + public int Value { get; set; } + + /// + /// Gets or sets whether the fixed-up field holds a displacement from the program counter + /// rather than an address. + /// + public bool IsPcRelative { get; set; } + + /// + /// Gets or sets the width of the fixed-up field as a power of two, so 2 means four bytes. + /// + public byte LengthLog2 { get; set; } + + /// + /// Gets or sets whether is a symbol index. Never true + /// for a scattered entry, which has no room for the flag. + /// + public bool IsExternal { get; set; } + + /// + /// Gets or sets whether this entry carries its target address rather than a symbol or + /// section number. + /// + public bool IsScattered { get; set; } + + /// + /// Gets or sets the type, whose meaning depends on the image's architecture. Interpret it as + /// , or + /// accordingly. + /// + public byte RawType { get; set; } + + /// + /// Gets the width of the fixed-up field in bytes. + /// + public int LengthInBytes => 1 << LengthLog2; + + /// + /// Decodes an entry from the two words it occupies. + /// + /// The first word, which is the address unless the top bit marks it scattered. + /// The second word. + /// The decoded entry. + public static MachORelocation Decode(uint word0, uint word1) + { + if ((word0 & ScatteredMask) != 0) + { + return new MachORelocation + { + IsScattered = true, + Address = (int)(word0 & 0x00ffffff), + RawType = (byte)((word0 >> 24) & 0xf), + LengthLog2 = (byte)((word0 >> 28) & 0x3), + IsPcRelative = ((word0 >> 30) & 1) != 0, + Value = (int)word1, + }; + } + + return new MachORelocation + { + Address = (int)word0, + SymbolOrSectionNumber = word1 & 0x00ffffff, + IsPcRelative = ((word1 >> 24) & 1) != 0, + LengthLog2 = (byte)((word1 >> 25) & 0x3), + IsExternal = ((word1 >> 27) & 1) != 0, + RawType = (byte)((word1 >> 28) & 0xf), + }; + } + + /// + /// Encodes this entry back into the two words it occupies. + /// + /// The first and second words. + public (uint Word0, uint Word1) Encode() + { + if (IsScattered) + { + var packed = ScatteredMask + | ((uint)Address & 0x00ffffff) + | ((uint)(RawType & 0xf) << 24) + | ((uint)(LengthLog2 & 0x3) << 28) + | (IsPcRelative ? 1u << 30 : 0u); + return (packed, (uint)Value); + } + + var info = (SymbolOrSectionNumber & 0x00ffffff) + | (IsPcRelative ? 1u << 24 : 0u) + | ((uint)(LengthLog2 & 0x3) << 25) + | (IsExternal ? 1u << 27 : 0u) + | ((uint)(RawType & 0xf) << 28); + return ((uint)Address, info); + } + + /// + public override string ToString() + => $"{nameof(MachORelocation)} {{ Address = 0x{Address:X}, Type = {RawType}, {LengthInBytes} bytes{(IsPcRelative ? ", pcrel" : string.Empty)}{(IsExternal ? ", external" : string.Empty)}{(IsScattered ? ", scattered" : string.Empty)} }}"; +} diff --git a/src/LibObjectFile/MachO/MachORelocationType.cs b/src/LibObjectFile/MachO/MachORelocationType.cs new file mode 100644 index 0000000..1687b6e --- /dev/null +++ b/src/LibObjectFile/MachO/MachORelocationType.cs @@ -0,0 +1,84 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Relocation types for 32-bit Intel and the other architectures using the generic set. +/// +/// +/// The numbering restarts for every architecture, so a value only means something once the +/// image's is known. +/// +public enum MachOGenericRelocationType : byte +{ + /// The relocated field takes the symbol's address (GENERIC_RELOC_VANILLA). + Vanilla = 0, + /// The second half of a pair, following the entry it belongs to (GENERIC_RELOC_PAIR). + Pair = 1, + /// The difference between two section-relative addresses (GENERIC_RELOC_SECTDIFF). + SectionDifference = 2, + /// A prebound lazy pointer (GENERIC_RELOC_PB_LA_PTR). + PreboundLazyPointer = 3, + /// A section difference where the subtrahend is local (GENERIC_RELOC_LOCAL_SECTDIFF). + LocalSectionDifference = 4, + /// A thread local variable reference (GENERIC_RELOC_TLV). + ThreadLocalVariable = 5, +} + +/// +/// Relocation types for 64-bit Intel. +/// +public enum MachOX86_64RelocationType : byte +{ + /// An absolute address (X86_64_RELOC_UNSIGNED). + Unsigned = 0, + /// A signed 32-bit displacement (X86_64_RELOC_SIGNED). + Signed = 1, + /// The target of a call or jump (X86_64_RELOC_BRANCH). + Branch = 2, + /// A load through the global offset table (X86_64_RELOC_GOT_LOAD). + GotLoad = 3, + /// Any other global offset table reference (X86_64_RELOC_GOT). + Got = 4, + /// The subtrahend of a difference, paired with the entry after it (X86_64_RELOC_SUBTRACTOR). + Subtractor = 5, + /// A signed displacement with an implicit addend of one (X86_64_RELOC_SIGNED_1). + Signed1 = 6, + /// A signed displacement with an implicit addend of two (X86_64_RELOC_SIGNED_2). + Signed2 = 7, + /// A signed displacement with an implicit addend of four (X86_64_RELOC_SIGNED_4). + Signed4 = 8, + /// A thread local variable reference (X86_64_RELOC_TLV). + ThreadLocalVariable = 9, +} + +/// +/// Relocation types for 64-bit ARM. +/// +public enum MachOArm64RelocationType : byte +{ + /// An absolute address (ARM64_RELOC_UNSIGNED). + Unsigned = 0, + /// The subtrahend of a difference, paired with the entry after it (ARM64_RELOC_SUBTRACTOR). + Subtractor = 1, + /// The target of a 26-bit branch (ARM64_RELOC_BRANCH26). + Branch26 = 2, + /// The page of a target, for adrp (ARM64_RELOC_PAGE21). + Page21 = 3, + /// The offset within a page (ARM64_RELOC_PAGEOFF12). + PageOffset12 = 4, + /// The page of a global offset table entry (ARM64_RELOC_GOT_LOAD_PAGE21). + GotLoadPage21 = 5, + /// The offset of a global offset table entry within its page (ARM64_RELOC_GOT_LOAD_PAGEOFF12). + GotLoadPageOffset12 = 6, + /// A pointer to a global offset table entry (ARM64_RELOC_POINTER_TO_GOT). + PointerToGot = 7, + /// The page of a thread local variable pointer (ARM64_RELOC_TLVP_LOAD_PAGE21). + ThreadLocalPage21 = 8, + /// The offset of a thread local variable pointer within its page (ARM64_RELOC_TLVP_LOAD_PAGEOFF12). + ThreadLocalPageOffset12 = 9, + /// An addend applied to the entry after it (ARM64_RELOC_ADDEND). + Addend = 10, +} diff --git a/src/LibObjectFile/MachO/MachOSectionAttributes.cs b/src/LibObjectFile/MachO/MachOSectionAttributes.cs new file mode 100644 index 0000000..0cc85c0 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSectionAttributes.cs @@ -0,0 +1,38 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Attributes of a section, held in the upper three bytes of the section flags field +/// (SECTION_ATTRIBUTES). The low byte holds the instead. +/// +[Flags] +public enum MachOSectionAttributes : uint +{ + /// No attributes set. + None = 0, + /// Section contains only true machine instructions (S_ATTR_PURE_INSTRUCTIONS). + PureInstructions = 0x80000000, + /// Section contains coalesced symbols that are not to be in the table of contents (S_ATTR_NO_TOC). + NoToc = 0x40000000, + /// Static symbols in this section can be stripped (S_ATTR_STRIP_STATIC_SYMS). + StripStaticSyms = 0x20000000, + /// No dead stripping (S_ATTR_NO_DEAD_STRIP). + NoDeadStrip = 0x10000000, + /// Blocks are live if they reference live blocks (S_ATTR_LIVE_SUPPORT). + LiveSupport = 0x08000000, + /// Used in code that can be modified, such as dyld stubs (S_ATTR_SELF_MODIFYING_CODE). + SelfModifyingCode = 0x04000000, + /// A debug section, which the linker treats specially (S_ATTR_DEBUG). + Debug = 0x02000000, + /// Section contains some machine instructions (S_ATTR_SOME_INSTRUCTIONS). + SomeInstructions = 0x00000400, + /// Section has external relocation entries (S_ATTR_EXT_RELOC). + ExternalReloc = 0x00000200, + /// Section has local relocation entries (S_ATTR_LOC_RELOC). + LocalReloc = 0x00000100, +} diff --git a/src/LibObjectFile/MachO/MachOSectionType.cs b/src/LibObjectFile/MachO/MachOSectionType.cs new file mode 100644 index 0000000..7ad4512 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSectionType.cs @@ -0,0 +1,64 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Type of a section, held in the low byte of the section flags field +/// (SECTION_TYPE). +/// +/// +/// The type decides whether the section occupies space in the file. , +/// and have a virtual size but no +/// file content, so their recorded offset does not point at bytes belonging to them. +/// +public enum MachOSectionType : byte +{ + /// Regular section (S_REGULAR). + Regular = 0x0, + /// Zero-filled on demand, occupying no file space (S_ZEROFILL). + ZeroFill = 0x1, + /// Only literal C strings (S_CSTRING_LITERALS). + CStringLiterals = 0x2, + /// Only 4-byte literals (S_4BYTE_LITERALS). + FourByteLiterals = 0x3, + /// Only 8-byte literals (S_8BYTE_LITERALS). + EightByteLiterals = 0x4, + /// Only pointers to literals (S_LITERAL_POINTERS). + LiteralPointers = 0x5, + /// Only non-lazy symbol pointers (S_NON_LAZY_SYMBOL_POINTERS). + NonLazySymbolPointers = 0x6, + /// Only lazy symbol pointers (S_LAZY_SYMBOL_POINTERS). + LazySymbolPointers = 0x7, + /// Only symbol stubs; the stub size is in reserved2 (S_SYMBOL_STUBS). + SymbolStubs = 0x8, + /// Only function pointers for initialization (S_MOD_INIT_FUNC_POINTERS). + ModInitFuncPointers = 0x9, + /// Only function pointers for termination (S_MOD_TERM_FUNC_POINTERS). + ModTermFuncPointers = 0xa, + /// Only symbols that are to be coalesced (S_COALESCED). + Coalesced = 0xb, + /// Zero-filled on demand, and may be larger than 4GB (S_GB_ZEROFILL). + GBZeroFill = 0xc, + /// Only pairs of function pointers for interposing (S_INTERPOSING). + Interposing = 0xd, + /// Only 16-byte literals (S_16BYTE_LITERALS). + SixteenByteLiterals = 0xe, + /// Contains DTrace object format data (S_DTRACE_DOF). + DtraceDof = 0xf, + /// Only lazy symbol pointers to lazy loaded dylibs (S_LAZY_DYLIB_SYMBOL_POINTERS). + LazyDylibSymbolPointers = 0x10, + /// Thread local data (S_THREAD_LOCAL_REGULAR). + ThreadLocalRegular = 0x11, + /// Thread local zero-filled data (S_THREAD_LOCAL_ZEROFILL). + ThreadLocalZeroFill = 0x12, + /// Thread local variable descriptors (S_THREAD_LOCAL_VARIABLES). + ThreadLocalVariables = 0x13, + /// Pointers to thread local variable descriptors (S_THREAD_LOCAL_VARIABLE_POINTERS). + ThreadLocalVariablePointers = 0x14, + /// Functions to call to initialize a thread local variable (S_THREAD_LOCAL_INIT_FUNCTION_POINTERS). + ThreadLocalInitFunctionPointers = 0x15, + /// 32-bit offsets to initializers (S_INIT_FUNC_OFFSETS). + InitFuncOffsets = 0x16, +} diff --git a/src/LibObjectFile/MachO/MachOSegmentFlags.cs b/src/LibObjectFile/MachO/MachOSegmentFlags.cs new file mode 100644 index 0000000..18cf787 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSegmentFlags.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Flags of a segment, as stored in the flags field of a segment load command. +/// +[Flags] +public enum MachOSegmentFlags : uint +{ + /// No flags set. + None = 0, + /// The file contents of this segment are for the high part of the VM space (SG_HIGHVM). + HighVM = 0x1, + /// The segment is the virtual memory module table, obsolete (SG_FVMLIB). + FixedVMLibrary = 0x2, + /// The segment contents are exactly the file contents and may be mapped directly (SG_NORELOC). + NoReloc = 0x4, + /// The first page is protected, and the rest becomes read-only after relocation (SG_PROTECTED_VERSION_1). + ProtectedVersion1 = 0x8, + /// The segment is read-only after the fixups have been applied (SG_READ_ONLY). + ReadOnly = 0x10, +} diff --git a/src/LibObjectFile/MachO/MachOVersion.cs b/src/LibObjectFile/MachO/MachOVersion.cs new file mode 100644 index 0000000..18a5453 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOVersion.cs @@ -0,0 +1,48 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Helpers for the packed version numbers Mach-O stores. +/// +public static class MachOVersion +{ + /// + /// Unpacks a 32-bit version, which stores its three parts in 16, 8 and 8 bits. This is the + /// encoding used by dylib versions and by the minimum OS version commands. + /// + /// The packed value. + /// The version it encodes. + public static Version Decode(uint packed) + => new((int)(packed >> 16), (int)((packed >> 8) & 0xff), (int)(packed & 0xff)); + + /// + /// Packs a version into the 16.8.8 encoding. + /// + /// The version to pack. A negative build or revision is treated as zero. + /// The packed value. + /// is null. + /// A component does not fit in the encoding. + public static uint Encode(Version version) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentOutOfRangeException.ThrowIfGreaterThan(version.Major, 0xffff); + ArgumentOutOfRangeException.ThrowIfGreaterThan(version.Minor, 0xff); + ArgumentOutOfRangeException.ThrowIfGreaterThan(Math.Max(version.Build, 0), 0xff); + + return ((uint)version.Major << 16) | ((uint)version.Minor << 8) | (uint)Math.Max(version.Build, 0); + } + + /// + /// Unpacks a 64-bit source version, which stores five parts in 24, 10, 10, 10 and 10 bits. + /// The last part is dropped, since holds only four. + /// + /// The packed value. + /// The first four components of the version. + public static Version DecodeSource(ulong packed) + => new((int)(packed >> 40), (int)((packed >> 30) & 0x3ff), (int)((packed >> 20) & 0x3ff), (int)((packed >> 10) & 0x3ff)); +} diff --git a/src/LibObjectFile/MachO/MachOVmProtection.cs b/src/LibObjectFile/MachO/MachOVmProtection.cs new file mode 100644 index 0000000..568b954 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOVmProtection.cs @@ -0,0 +1,24 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Virtual memory protection applied to a segment, as stored in the maxprot and +/// initprot fields of a segment load command. +/// +[Flags] +public enum MachOVmProtection : uint +{ + /// No access (VM_PROT_NONE). + None = 0, + /// Read access (VM_PROT_READ). + Read = 0x1, + /// Write access (VM_PROT_WRITE). + Write = 0x2, + /// Execute access (VM_PROT_EXECUTE). + Execute = 0x4, +} From 359d9a61ee7794ccaa79520ba55b2ec81db73631 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:23:57 -0400 Subject: [PATCH 03/32] Add Mach-O object model and reader The format constants had nothing to build on, so a Mach-O image could not be turned into anything inspectable. Adds the file, segment, section and load command model, and a reader that walks the command table and decodes every command an image of these architectures carries. Anything unrecognised is kept as raw bytes, so images from a newer linker still load. Everything in the file becomes an ordered content list: the header, the load command table, the padding after it, the bytes of each section, each table in __LINKEDIT, and the gaps between them. Every byte belongs to an element, which is what makes writing the list back reproduce the image and a layout a single walk over it. Content carrying an address is pinned, because a section's address is its segment's address plus its distance from the segment's file offset, so moving it in the file would move it in memory. Padding is kept as the bytes that were read rather than regenerated, since a linker pads executable sections with nop and zero-filling would leave a different instruction somewhere reachable. - src/LibObjectFile/MachO/MachOFile.Read.cs: turns the file into content, leaving nothing implicit - src/LibObjectFile/MachO/Content: the element types, and which of them a layout may move - src/LibObjectFile/MachO/MachOPathLoadCommand.cs: keeps the linker's own padding on the commands carrying a string --- .../MachO/MachOSimpleTests.cs | 102 ++++- .../MachO/MachOTestBase.cs | 17 + .../MachO/Content/MachOContent.cs | 51 +++ .../MachO/Content/MachOHeaderContent.cs | 58 +++ .../MachO/Content/MachOLoadCommandPadding.cs | 57 +++ .../MachO/Content/MachOLoadCommandTable.cs | 40 ++ .../MachO/Content/MachOSectionData.cs | 46 +++ .../MachO/Content/MachOStreamContent.cs | 66 ++++ .../MachO/MachOBuildVersionCommand.cs | 117 ++++++ .../MachO/MachODyldInfoCommand.cs | 129 ++++++ src/LibObjectFile/MachO/MachODylibCommand.cs | 86 ++++ .../MachO/MachODynamicSymbolTableCommand.cs | 178 +++++++++ src/LibObjectFile/MachO/MachOFile.Read.cs | 374 ++++++++++++++++++ src/LibObjectFile/MachO/MachOFile.cs | 293 ++++++++++++++ .../MachO/MachOLinkEditDataCommand.cs | 80 ++++ src/LibObjectFile/MachO/MachOLoadCommand.cs | 50 +++ src/LibObjectFile/MachO/MachOMainCommand.cs | 69 ++++ src/LibObjectFile/MachO/MachOObject.cs | 12 + src/LibObjectFile/MachO/MachOPathCommand.cs | 54 +++ .../MachO/MachOPathLoadCommand.cs | 115 ++++++ src/LibObjectFile/MachO/MachOReader.cs | 50 +++ src/LibObjectFile/MachO/MachOReaderOptions.cs | 17 + src/LibObjectFile/MachO/MachOSection.cs | 112 ++++++ src/LibObjectFile/MachO/MachOSegment.cs | 311 +++++++++++++++ .../MachO/MachOSourceVersionCommand.cs | 63 +++ .../MachO/MachOSymbolTableCommand.cs | 100 +++++ src/LibObjectFile/MachO/MachOThreadCommand.cs | 133 +++++++ .../MachO/MachOTwoLevelHintsCommand.cs | 79 ++++ .../MachO/MachOUnknownLoadCommand.cs | 43 ++ src/LibObjectFile/MachO/MachOUuidCommand.cs | 70 ++++ .../MachO/MachOVersionMinCommand.cs | 80 ++++ .../MachO/MachOVisitorContext.cs | 17 + src/LibObjectFile/MachO/MachOWriter.cs | 38 ++ 33 files changed, 3106 insertions(+), 1 deletion(-) create mode 100644 src/LibObjectFile.Tests/MachO/MachOTestBase.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOContent.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOHeaderContent.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOLoadCommandPadding.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOLoadCommandTable.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOSectionData.cs create mode 100644 src/LibObjectFile/MachO/Content/MachOStreamContent.cs create mode 100644 src/LibObjectFile/MachO/MachOBuildVersionCommand.cs create mode 100644 src/LibObjectFile/MachO/MachODyldInfoCommand.cs create mode 100644 src/LibObjectFile/MachO/MachODylibCommand.cs create mode 100644 src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.Read.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.cs create mode 100644 src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOLoadCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOMainCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOObject.cs create mode 100644 src/LibObjectFile/MachO/MachOPathCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOPathLoadCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOReader.cs create mode 100644 src/LibObjectFile/MachO/MachOReaderOptions.cs create mode 100644 src/LibObjectFile/MachO/MachOSection.cs create mode 100644 src/LibObjectFile/MachO/MachOSegment.cs create mode 100644 src/LibObjectFile/MachO/MachOSourceVersionCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOSymbolTableCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOThreadCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOUnknownLoadCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOUuidCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOVersionMinCommand.cs create mode 100644 src/LibObjectFile/MachO/MachOVisitorContext.cs create mode 100644 src/LibObjectFile/MachO/MachOWriter.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index ba2bc33..e860d58 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -2,13 +2,35 @@ // This file is licensed under the BSD-Clause 2 license. // See the license.txt file in the project root for more information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO; using LibObjectFile.MachO.Internal; +using VerifyTests; namespace LibObjectFile.Tests.MachO; [TestClass] -public class MachOSimpleTests +public class MachOSimpleTests : MachOTestBase { + private static List CollectOffsets(MachOFile file) + { + var offsets = new List(); + foreach (var command in file.LoadCommands) + { + command.UpdateFileOffsets(offset => + { + offsets.Add(offset); + return offset; + }); + } + return offsets; + } + /// /// The raw structures are copied by value straight out of the file, so a wrong size or an /// unexpected padding byte would silently shift every field after it. @@ -27,4 +49,82 @@ public unsafe void RawStructuresMatchTheOnDiskLayout() Assert.AreEqual(20, sizeof(RawFatArch)); Assert.AreEqual(32, sizeof(RawFatArch64)); } + + [TestMethod] + [DataRow("helloworld_x86_64", true, MachOCpuType.X86_64, MachOFileType.Execute)] + [DataRow("helloworld_arm64", true, MachOCpuType.Arm64, MachOFileType.Execute)] + [DataRow("libhelloworld_x86_64.dylib", true, MachOCpuType.X86_64, MachOFileType.Dylib)] + [DataRow("helloworld_x86_64.o", true, MachOCpuType.X86_64, MachOFileType.Object)] + [DataRow("unixthread_i386", false, MachOCpuType.X86, MachOFileType.Execute)] + [DataRow("dyldinfo_i386", false, MachOCpuType.X86, MachOFileType.Execute)] + public void ReadsHeader(string name, bool is64Bit, MachOCpuType cpuType, MachOFileType fileType) + { + var file = LoadMachO(name); + + Assert.AreEqual(is64Bit, file.Is64Bit); + Assert.AreEqual(cpuType, file.CpuType); + Assert.AreEqual(fileType, file.FileType); + Assert.AreEqual(file.HeaderSize + file.SizeOfCommands, file.LoadCommandsEndOffset); + } + + /// + /// The padding after the load commands is what an in-place edit consumes, and a command + /// keeps the linker's own padding rather than being re-encoded to its smallest legal size. + /// Both are what let an untouched image round-trip byte for byte. + /// + [TestMethod] + public void ReportsSpaceLeftForNewLoadCommands() + { + var file = LoadMachO("unixthread_i386"); + + Assert.AreEqual(728u, file.LoadCommandsEndOffset); + Assert.AreEqual(2048ul, file.ContentStartOffset); + Assert.AreEqual(1320, file.AvailableLoadCommandSpace); + Assert.AreEqual(1320ul, file.LoadCommandPadding!.Size); + Assert.AreEqual(728ul, file.LoadCommandPadding.Position, "the padding follows the command table"); + + var dylinker = file.LoadCommands.OfType().Single(c => c.Type == MachOLoadCommandType.LoadDylinker); + Assert.AreEqual(28ul, dylinker.Size); + Assert.AreEqual(28u, dylinker.MinimumSize); + Assert.IsTrue(file.LoadCommands.OfType().All(d => d.Size >= d.MinimumSize)); + } + + /// + /// The snapshot shows the thread state's flavour and length, but not that the eleventh word + /// is the entry point, nor that the symbol table runs straight into the string table. + /// + [TestMethod] + public void ReadsThe32BitEntryPointAndTableLayout() + { + var file = LoadMachO("unixthread_i386"); + + var state = file.LoadCommands.OfType().Single().States.Single(); + Assert.AreEqual(1u, state.Flavor, "x86_THREAD_STATE32"); + Assert.AreEqual((uint)file.FindSegment("__TEXT")!.Sections.Single().Address, state.Registers[10]); + + var symtab = file.LoadCommands.OfType().Single(); + Assert.AreEqual(symtab.StringOffset, symtab.SymbolOffset + symtab.SymbolCount * MachOSymbolTableCommand.GetSymbolSize(file.Is64Bit)); + } + + /// + /// Packing is not visible in a printed image, since only the decoded version is shown. + /// + [TestMethod] + public void VersionPackingRoundTrips() + { + Assert.AreEqual(new Version(10, 13, 0), MachOVersion.Decode(0x000A0D00)); + Assert.AreEqual(0x000A0D00u, MachOVersion.Encode(new Version(10, 13, 0))); + Assert.AreEqual(0x0001000Au, MachOVersion.Encode(new Version(1, 0, 10))); + Assert.ThrowsExactly(() => MachOVersion.Encode(new Version(1, 256, 0))); + } + + [TestMethod] + public void RejectsANonMachOStream() + { + using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); + + Assert.IsFalse(MachOFile.IsMachO(input)); + Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); + Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); + } } diff --git a/src/LibObjectFile.Tests/MachO/MachOTestBase.cs b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs new file mode 100644 index 0000000..9777ad4 --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs @@ -0,0 +1,17 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; +using LibObjectFile.MachO; +using VerifyMSTest; + +namespace LibObjectFile.Tests.MachO; + +public abstract class MachOTestBase : VerifyBase +{ + protected static string GetFile(string name) => Path.Combine(AppContext.BaseDirectory, "MachO", name); + + protected static MachOFile LoadMachO(string name) => MachOFile.Read(new MemoryStream(File.ReadAllBytes(GetFile(name)))); +} diff --git a/src/LibObjectFile/MachO/Content/MachOContent.cs b/src/LibObjectFile/MachO/Content/MachOContent.cs new file mode 100644 index 0000000..c3c266d --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOContent.cs @@ -0,0 +1,51 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// A run of bytes in a Mach-O image, holding its place in the file. +/// +/// +/// Everything in an image is one of these, in one ordered list: the header, the load command +/// table, the padding after it, each section's bytes, each table in __LINKEDIT and any +/// alignment padding between them. Nothing is left implicit, so writing the list back out +/// reproduces the file and a layout is a single walk over it. +/// +/// is where Mach-O parts company with ELF. A section's address is +/// its segment's address plus its distance from the segment's file offset, so moving a section +/// in the file moves it in memory and invalidates everything referring to it. Content carrying +/// an address is therefore pinned and a layout leaves it alone; only what nothing addresses, +/// which in practice is __LINKEDIT and the file tail, is free to move. +/// +/// +public abstract class MachOContent : MachOObject +{ + /// + /// Initializes a new instance. + /// + protected MachOContent() + { + FileAlignment = 1; + } + + /// + /// Gets or sets the alignment this content requires in the file. + /// + public uint FileAlignment { get; set; } + + /// + /// Gets whether a layout has to leave this content where it is, because something records + /// its address and moving it in the file would move it in memory. + /// + public virtual bool IsPositionPinned => false; + + /// + /// Writes the bytes of this content. The writer is positioned at . + /// + /// The writer to write to. + public abstract void WriteContent(MachOWriter writer); +} diff --git a/src/LibObjectFile/MachO/Content/MachOHeaderContent.cs b/src/LibObjectFile/MachO/Content/MachOHeaderContent.cs new file mode 100644 index 0000000..9b6e80d --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOHeaderContent.cs @@ -0,0 +1,58 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The Mach-O header, which is always the first content of an image. +/// +/// +/// The header is generated from the file it belongs to rather than held as bytes, so the counts +/// it records cannot drift from the load commands actually present. +/// +public sealed class MachOHeaderContent : MachOContent +{ + /// + public override bool IsPositionPinned => true; + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = context.File.HeaderSize; + + /// + public override unsafe void WriteContent(MachOWriter writer) + { + var file = writer.File; + + if (file.Is64Bit) + { + writer.Write(new RawMachHeader64 + { + Magic = MachOMagic.Magic64, + CpuType = (uint)file.CpuType, + CpuSubType = file.CpuSubType, + FileType = (uint)file.FileType, + NumberOfCommands = (uint)file.LoadCommands.Count, + SizeOfCommands = file.SizeOfCommands, + Flags = (uint)file.Flags, + Reserved = file.Reserved, + }); + } + else + { + writer.Write(new RawMachHeader32 + { + Magic = MachOMagic.Magic32, + CpuType = (uint)file.CpuType, + CpuSubType = file.CpuSubType, + FileType = (uint)file.FileType, + NumberOfCommands = (uint)file.LoadCommands.Count, + SizeOfCommands = file.SizeOfCommands, + Flags = (uint)file.Flags, + }); + } + } +} diff --git a/src/LibObjectFile/MachO/Content/MachOLoadCommandPadding.cs b/src/LibObjectFile/MachO/Content/MachOLoadCommandPadding.cs new file mode 100644 index 0000000..86f4a1c --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOLoadCommandPadding.cs @@ -0,0 +1,57 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; + +namespace LibObjectFile.MachO; + +/// +/// The padding a linker leaves between the load command table and whatever follows it. +/// +/// +/// Adding a load command consumes this from the front, since the table grows into it, so what +/// survives is its tail. That is what bounds an install_name_tool-style edit: once the +/// padding is used up there is nowhere for another command to go without moving content, and +/// moving content in a Mach-O moves addresses with it. +/// +public sealed class MachOLoadCommandPadding : MachOStreamContent +{ + /// + /// Initializes a new instance holding the padding bytes. + /// + /// The padding bytes as the linker left them. + /// is null. + public MachOLoadCommandPadding(Stream content) : base(content) + { + } + + /// + public override bool IsPositionPinned => true; + + /// + /// Keeps the size the layout assigned rather than taking it from the bytes held, because the + /// load command table may have grown into them since they were read. + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + { + } + + /// + public override void WriteContent(MachOWriter writer) + { + // The commands grew into the front of this, so the part that survives is the tail. + var available = (long)Content.Length; + var keep = (long)Size; + + if (keep > available) + { + writer.WriteZero((int)(keep - available)); + keep = available; + } + + Content.Position = available - keep; + writer.Write(Content, (ulong)keep); + } +} diff --git a/src/LibObjectFile/MachO/Content/MachOLoadCommandTable.cs b/src/LibObjectFile/MachO/Content/MachOLoadCommandTable.cs new file mode 100644 index 0000000..4bc1712 --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOLoadCommandTable.cs @@ -0,0 +1,40 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// The load command table, which follows the header and describes everything else. +/// +/// +/// The table is pinned because dyld expects it directly after the header, and because the +/// padding the linker leaves after it is what bounds how far it can grow. +/// +public sealed class MachOLoadCommandTable : MachOContent +{ + /// + public override bool IsPositionPinned => true; + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + { + foreach (var command in context.File.LoadCommands) + { + command.UpdateLayout(context); + } + + Size = context.File.SizeOfCommands; + } + + /// + public override void WriteContent(MachOWriter writer) + { + foreach (var command in writer.File.LoadCommands) + { + command.Write(writer); + } + } +} diff --git a/src/LibObjectFile/MachO/Content/MachOSectionData.cs b/src/LibObjectFile/MachO/Content/MachOSectionData.cs new file mode 100644 index 0000000..3fe0087 --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOSectionData.cs @@ -0,0 +1,46 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; + +namespace LibObjectFile.MachO; + +/// +/// The bytes of a . +/// +/// +/// This is pinned. A section's address is its segment's address plus its distance from the +/// segment's file offset, so moving it in the file moves it in memory, and every instruction and +/// relocation referring to it would then be wrong. Only relinking can move a section. +/// +public sealed class MachOSectionData : MachOStreamContent +{ + /// + /// Initializes a new instance holding the bytes of a section. + /// + /// The section these bytes belong to. + /// The bytes of the section. + /// or is null. + public MachOSectionData(MachOSection section, Stream content) : base(content) + { + ArgumentNullException.ThrowIfNull(section); + Section = section; + } + + /// + /// Gets the section these bytes belong to. + /// + public MachOSection Section { get; } + + /// + public override bool IsPositionPinned => true; + + /// + protected override bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"{Section.SegmentName},{Section.Name} Position = 0x{Position:X}, Size = 0x{Size:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/Content/MachOStreamContent.cs b/src/LibObjectFile/MachO/Content/MachOStreamContent.cs new file mode 100644 index 0000000..1d7b01c --- /dev/null +++ b/src/LibObjectFile/MachO/Content/MachOStreamContent.cs @@ -0,0 +1,66 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; + +namespace LibObjectFile.MachO; + +/// +/// Content held as a stream of bytes this library does not interpret. +/// +/// +/// This covers alignment padding as well as anything else the file contains. Padding is kept as +/// the bytes that were there rather than regenerated: a linker pads executable sections with +/// nop rather than zeros, so filling a gap with zeros would put a different instruction +/// in a place that can be reached. +/// +public class MachOStreamContent : MachOContent +{ + private Stream _content; + + /// + /// Initializes a new instance holding the given bytes. + /// + /// The bytes of this content. + /// is null. + public MachOStreamContent(Stream content) + { + ArgumentNullException.ThrowIfNull(content); + _content = content; + Size = (ulong)content.Length; + } + + /// + /// Gets or sets the bytes of this content. + /// + /// The value is null. + public Stream Content + { + get => _content; + set + { + ArgumentNullException.ThrowIfNull(value); + _content = value; + Size = (ulong)value.Length; + } + } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = (ulong)_content.Length; + + /// + public override void WriteContent(MachOWriter writer) + { + _content.Position = 0; + writer.Write(_content, (ulong)_content.Length); + } + + /// + protected override bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"Position = 0x{Position:X}, Size = 0x{Size:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs new file mode 100644 index 0000000..24f2d95 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs @@ -0,0 +1,117 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The build version load command (LC_BUILD_VERSION). +/// +/// +/// This replaced the LC_VERSION_MIN_* family, carrying the platform as a field rather +/// than encoding it in the command type, and adding the list of tools that produced the image. +/// +public sealed class MachOBuildVersionCommand : MachOLoadCommand +{ + /// + /// The size of the command excluding its tool entries. + /// + public const uint HeaderSize = 24; + + /// + /// The size of one tool entry. + /// + public const uint ToolSize = 8; + + /// + /// Gets or sets the platform the image targets. + /// + public MachOPlatform Platform { get; set; } + + /// + /// Gets or sets the packed minimum OS version. + /// + public uint MinOSVersion { get; set; } + + /// + /// Gets or sets the packed SDK version the image was built against. + /// + public uint SdkVersion { get; set; } + + /// + /// Gets the tools that produced this image. + /// + public List Tools { get; } = []; + + /// + /// Gets the minimum OS version. + /// + public Version MinOS => MachOVersion.Decode(MinOSVersion); + + /// + /// Gets the SDK version. + /// + public Version Sdk => MachOVersion.Decode(SdkVersion); + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + => Size = HeaderSize + (uint)Tools.Count * ToolSize; + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawBuildVersionCommand), out RawBuildVersionCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_BUILD_VERSION at 0x{Position:X}"); + return; + } + + Platform = (MachOPlatform)raw.Platform; + MinOSVersion = raw.MinOS; + SdkVersion = raw.Sdk; + + Tools.Clear(); + for (uint i = 0; i < raw.ToolCount; i++) + { + if (!reader.TryReadData(sizeof(RawBuildToolVersion), out RawBuildToolVersion tool)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated tool entry in LC_BUILD_VERSION at 0x{Position:X}"); + return; + } + + Tools.Add(new MachOBuildToolVersion((MachOBuildTool)tool.Tool, tool.Version)); + } + } + + /// + public override void Write(MachOWriter writer) + { + writer.Write(new RawBuildVersionCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + Platform = (uint)Platform, + MinOS = MinOSVersion, + Sdk = SdkVersion, + ToolCount = (uint)Tools.Count, + }); + + foreach (var tool in Tools) + { + writer.Write(new RawBuildToolVersion { Tool = (uint)tool.Tool, Version = tool.PackedVersion }); + } + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Platform = {Platform}, MinOS = {MinOS}, Sdk = {Sdk}, Tools = {Tools.Count}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachODyldInfoCommand.cs b/src/LibObjectFile/MachO/MachODyldInfoCommand.cs new file mode 100644 index 0000000..b3a8aed --- /dev/null +++ b/src/LibObjectFile/MachO/MachODyldInfoCommand.cs @@ -0,0 +1,129 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The compressed dyld information load command (LC_DYLD_INFO or LC_DYLD_INFO_ONLY). +/// +/// +/// Each offset and size pair locates an opcode stream in __LINKEDIT telling dyld how to +/// rebase and bind the image. The streams are not decoded here; they address their targets by +/// segment index and offset within that segment rather than by file offset, so moving them only +/// means updating the offsets recorded in this command. +/// +/// The ONLY spelling means the image carries no classic relocations as a fallback, so a +/// loader that does not understand these streams cannot load it at all. +/// +/// +public sealed class MachODyldInfoCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 48; + + /// Gets or sets the file offset of the rebase opcodes. + public uint RebaseOffset { get; set; } + + /// Gets or sets the size in bytes of the rebase opcodes. + public uint RebaseSize { get; set; } + + /// Gets or sets the file offset of the binding opcodes. + public uint BindOffset { get; set; } + + /// Gets or sets the size in bytes of the binding opcodes. + public uint BindSize { get; set; } + + /// Gets or sets the file offset of the weak binding opcodes. + public uint WeakBindOffset { get; set; } + + /// Gets or sets the size in bytes of the weak binding opcodes. + public uint WeakBindSize { get; set; } + + /// Gets or sets the file offset of the lazy binding opcodes. + public uint LazyBindOffset { get; set; } + + /// Gets or sets the size in bytes of the lazy binding opcodes. + public uint LazyBindSize { get; set; } + + /// Gets or sets the file offset of the export trie. + public uint ExportOffset { get; set; } + + /// Gets or sets the size in bytes of the export trie. + public uint ExportSize { get; set; } + + /// + /// Gets a value indicating whether the image relies on these streams alone, with no classic + /// relocations to fall back on. + /// + public bool IsOnly => Type == MachOLoadCommandType.DyldInfoOnly; + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + if (RebaseOffset != 0) RebaseOffset = mapper(RebaseOffset); + if (BindOffset != 0) BindOffset = mapper(BindOffset); + if (WeakBindOffset != 0) WeakBindOffset = mapper(WeakBindOffset); + if (LazyBindOffset != 0) LazyBindOffset = mapper(LazyBindOffset); + if (ExportOffset != 0) ExportOffset = mapper(ExportOffset); + } + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawDyldInfoCommand), out RawDyldInfoCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated {Type} at 0x{Position:X}"); + return; + } + + RebaseOffset = raw.RebaseOffset; + RebaseSize = raw.RebaseSize; + BindOffset = raw.BindOffset; + BindSize = raw.BindSize; + WeakBindOffset = raw.WeakBindOffset; + WeakBindSize = raw.WeakBindSize; + LazyBindOffset = raw.LazyBindOffset; + LazyBindSize = raw.LazyBindSize; + ExportOffset = raw.ExportOffset; + ExportSize = raw.ExportSize; + } + + /// + public override void Write(MachOWriter writer) + { + writer.Write(new RawDyldInfoCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + RebaseOffset = RebaseOffset, + RebaseSize = RebaseSize, + BindOffset = BindOffset, + BindSize = BindSize, + WeakBindOffset = WeakBindOffset, + WeakBindSize = WeakBindSize, + LazyBindOffset = LazyBindOffset, + LazyBindSize = LazyBindSize, + ExportOffset = ExportOffset, + ExportSize = ExportSize, + }); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Type = {Type}, Rebase = 0x{RebaseSize:X}, Bind = 0x{BindSize:X}, LazyBind = 0x{LazyBindSize:X}, Export = 0x{ExportSize:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachODylibCommand.cs b/src/LibObjectFile/MachO/MachODylibCommand.cs new file mode 100644 index 0000000..f1c40cc --- /dev/null +++ b/src/LibObjectFile/MachO/MachODylibCommand.cs @@ -0,0 +1,86 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// A load command naming a dynamic library, covering LC_LOAD_DYLIB, LC_ID_DYLIB, +/// LC_LOAD_WEAK_DYLIB, LC_REEXPORT_DYLIB, LC_LOAD_UPWARD_DYLIB and +/// LC_LAZY_LOAD_DYLIB. +/// +/// +/// The order of these commands is what dyld reports as the library ordinal, so inserting one +/// anywhere other than after the existing ones renumbers the bindings of every command following +/// it. appends rather than inserts for that reason. +/// +public sealed class MachODylibCommand : MachOPathLoadCommand +{ + /// + /// Gets or sets the path of the library, which is its install name rather than a file path + /// and may start with @rpath, @executable_path or @loader_path. + /// + public string Name + { + get => Value; + set => Value = value; + } + + /// + /// Gets or sets the build timestamp of the library. + /// + public uint Timestamp { get; set; } + + /// + /// Gets or sets the current version of the library, packed as 16.8.8 bits. + /// + public uint CurrentVersion { get; set; } + + /// + /// Gets or sets the oldest version of the library this image is compatible with, packed as 16.8.8 bits. + /// + public uint CompatibilityVersion { get; set; } + + /// + /// Gets a value indicating whether a missing library is tolerated at load time. + /// + public bool IsWeak => Type == MachOLoadCommandType.LoadWeakDylib; + + /// + protected override unsafe uint FixedSize => (uint)sizeof(RawDylibCommand); + + /// + public override unsafe void Read(MachOReader reader) + { + var commandPosition = reader.Position; + if (!reader.TryReadData(sizeof(RawDylibCommand), out RawDylibCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated dylib command at 0x{commandPosition:X}"); + return; + } + + Timestamp = raw.Timestamp; + CurrentVersion = raw.CurrentVersion; + CompatibilityVersion = raw.CompatibilityVersion; + ReadValue(reader, commandPosition, raw.NameOffset); + } + + /// + public override unsafe void Write(MachOWriter writer) + { + writer.Write(new RawDylibCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + NameOffset = (uint)sizeof(RawDylibCommand), + Timestamp = Timestamp, + CurrentVersion = CurrentVersion, + CompatibilityVersion = CompatibilityVersion, + }); + WriteValue(writer); + } +} diff --git a/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs b/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs new file mode 100644 index 0000000..9210ea6 --- /dev/null +++ b/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs @@ -0,0 +1,178 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The dynamic symbol table load command (LC_DYSYMTAB). +/// +/// +/// The index and count pairs describe runs within the table located by +/// , which the linker sorts into local, then external, then +/// undefined symbols. The remaining pairs locate tables of their own in __LINKEDIT. The +/// indirect symbol table is the one that matters at runtime: the stub and symbol pointer sections +/// index into it through their reserved1 field. +/// +public sealed class MachODynamicSymbolTableCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 80; + + /// The size of one table of contents entry (dylib_table_of_contents). + public const uint TableOfContentsEntrySize = 8; + + /// The size of one external reference entry (dylib_reference). + public const uint ExternalReferenceEntrySize = 4; + + /// The size of one indirect symbol entry, which is an index into the symbol table. + public const uint IndirectSymbolEntrySize = 4; + + /// + /// Gets the size of one module table entry for the given image width + /// (dylib_module or dylib_module_64). + /// + /// Whether the containing image is 64-bit. + /// 56 for a 64-bit image, 52 for a 32-bit one, the difference being one 64-bit field. + public static uint GetModuleTableEntrySize(bool is64Bit) => is64Bit ? 56u : 52u; + + /// Gets or sets the index of the first local symbol. + public uint LocalSymbolIndex { get; set; } + + /// Gets or sets the number of local symbols. + public uint LocalSymbolCount { get; set; } + + /// Gets or sets the index of the first externally defined symbol. + public uint ExternalSymbolIndex { get; set; } + + /// Gets or sets the number of externally defined symbols. + public uint ExternalSymbolCount { get; set; } + + /// Gets or sets the index of the first undefined symbol. + public uint UndefinedSymbolIndex { get; set; } + + /// Gets or sets the number of undefined symbols. + public uint UndefinedSymbolCount { get; set; } + + /// Gets or sets the file offset of the table of contents, used only by static libraries. + public uint TableOfContentsOffset { get; set; } + + /// Gets or sets the number of table of contents entries. + public uint TableOfContentsCount { get; set; } + + /// Gets or sets the file offset of the module table. + public uint ModuleTableOffset { get; set; } + + /// Gets or sets the number of module table entries. + public uint ModuleTableCount { get; set; } + + /// Gets or sets the file offset of the external reference table. + public uint ExternalReferenceOffset { get; set; } + + /// Gets or sets the number of external reference entries. + public uint ExternalReferenceCount { get; set; } + + /// Gets or sets the file offset of the indirect symbol table. + public uint IndirectSymbolOffset { get; set; } + + /// Gets or sets the number of indirect symbol entries, each a 4-byte symbol index. + public uint IndirectSymbolCount { get; set; } + + /// Gets or sets the file offset of the external relocation entries. + public uint ExternalRelocationOffset { get; set; } + + /// Gets or sets the number of external relocation entries. + public uint ExternalRelocationCount { get; set; } + + /// Gets or sets the file offset of the local relocation entries. + public uint LocalRelocationOffset { get; set; } + + /// Gets or sets the number of local relocation entries. + public uint LocalRelocationCount { get; set; } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + if (TableOfContentsOffset != 0) TableOfContentsOffset = mapper(TableOfContentsOffset); + if (ModuleTableOffset != 0) ModuleTableOffset = mapper(ModuleTableOffset); + if (ExternalReferenceOffset != 0) ExternalReferenceOffset = mapper(ExternalReferenceOffset); + if (IndirectSymbolOffset != 0) IndirectSymbolOffset = mapper(IndirectSymbolOffset); + if (ExternalRelocationOffset != 0) ExternalRelocationOffset = mapper(ExternalRelocationOffset); + if (LocalRelocationOffset != 0) LocalRelocationOffset = mapper(LocalRelocationOffset); + } + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawDysymtabCommand), out RawDysymtabCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_DYSYMTAB at 0x{Position:X}"); + return; + } + + LocalSymbolIndex = raw.LocalSymbolIndex; + LocalSymbolCount = raw.LocalSymbolCount; + ExternalSymbolIndex = raw.ExternalSymbolIndex; + ExternalSymbolCount = raw.ExternalSymbolCount; + UndefinedSymbolIndex = raw.UndefinedSymbolIndex; + UndefinedSymbolCount = raw.UndefinedSymbolCount; + TableOfContentsOffset = raw.TableOfContentsOffset; + TableOfContentsCount = raw.TableOfContentsCount; + ModuleTableOffset = raw.ModuleTableOffset; + ModuleTableCount = raw.ModuleTableCount; + ExternalReferenceOffset = raw.ExternalReferenceOffset; + ExternalReferenceCount = raw.ExternalReferenceCount; + IndirectSymbolOffset = raw.IndirectSymbolOffset; + IndirectSymbolCount = raw.IndirectSymbolCount; + ExternalRelocationOffset = raw.ExternalRelocationOffset; + ExternalRelocationCount = raw.ExternalRelocationCount; + LocalRelocationOffset = raw.LocalRelocationOffset; + LocalRelocationCount = raw.LocalRelocationCount; + } + + /// + public override void Write(MachOWriter writer) + { + writer.Write(new RawDysymtabCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + LocalSymbolIndex = LocalSymbolIndex, + LocalSymbolCount = LocalSymbolCount, + ExternalSymbolIndex = ExternalSymbolIndex, + ExternalSymbolCount = ExternalSymbolCount, + UndefinedSymbolIndex = UndefinedSymbolIndex, + UndefinedSymbolCount = UndefinedSymbolCount, + TableOfContentsOffset = TableOfContentsOffset, + TableOfContentsCount = TableOfContentsCount, + ModuleTableOffset = ModuleTableOffset, + ModuleTableCount = ModuleTableCount, + ExternalReferenceOffset = ExternalReferenceOffset, + ExternalReferenceCount = ExternalReferenceCount, + IndirectSymbolOffset = IndirectSymbolOffset, + IndirectSymbolCount = IndirectSymbolCount, + ExternalRelocationOffset = ExternalRelocationOffset, + ExternalRelocationCount = ExternalRelocationCount, + LocalRelocationOffset = LocalRelocationOffset, + LocalRelocationCount = LocalRelocationCount, + }); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Local = {LocalSymbolCount}, External = {ExternalSymbolCount}, Undefined = {UndefinedSymbolCount}, Indirect = {IndirectSymbolCount}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs new file mode 100644 index 0000000..c9c4c3b --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -0,0 +1,374 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Reads a Mach-O image from a stream. + /// + /// The stream positioned at the start of the image. + /// Options controlling how content is read, or null for the defaults. + /// The image read. + /// is null. + /// The stream does not contain a readable Mach-O image. + public static MachOFile Read(Stream stream, MachOReaderOptions? options = null) + { + if (!TryRead(stream, out var file, out var diagnostics, options)) + { + throw new ObjectFileException($"Unexpected error while reading the Mach-O file", diagnostics); + } + return file; + } + + /// + /// Tries to read a Mach-O image from a stream. + /// + /// The stream positioned at the start of the image. + /// The image read, if reading succeeded. + /// The diagnostics collected, if reading failed. + /// Options controlling how content is read, or null for the defaults. + /// true if the image was read; otherwise false. + /// is null. + public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFile? file, [NotNullWhen(false)] out DiagnosticBag? diagnostics, MachOReaderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(stream); + + file = new MachOFile(); + var reader = new MachOReader(file, stream, options ?? new MachOReaderOptions()); + diagnostics = reader.Diagnostics; + + file.Read(reader); + + if (reader.Diagnostics.HasErrors) + { + file = null; + return false; + } + + diagnostics = null; + return true; + } + + /// + /// Checks whether a stream starts with a thin Mach-O magic, without consuming it. + /// + /// The stream to inspect. + /// true if the stream starts with a Mach-O magic. + /// is null. + public static bool IsMachO(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + var position = stream.Position; + try + { + Span magic = stackalloc byte[4]; + if (stream.Read(magic) != 4) return false; + var value = BitConverter.ToUInt32(magic); + return value is MachOMagic.Magic32 or MachOMagic.Magic64 or MachOMagic.Cigam32 or MachOMagic.Cigam64; + } + finally + { + stream.Position = position; + } + } + + /// + public override unsafe void Read(MachOReader reader) + { + Position = reader.Position; + + var magic = reader.ReadU32(); + reader.Position -= 4; + + switch (magic) + { + case MachOMagic.Magic32: + Is64Bit = false; + break; + case MachOMagic.Magic64: + Is64Bit = true; + break; + case MachOMagic.Cigam32: + case MachOMagic.Cigam64: + // Reading these means byte-swapping every field. The only architectures that + // shipped big-endian Mach-O are PowerPC and 68k, so nothing is done here rather + // than carrying a swapping reader that nothing exercises. + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_UnsupportedByteOrder, $"Big-endian Mach-O images are not supported (magic 0x{magic:X8})"); + return; + case MachOMagic.FatCigam: + case MachOMagic.FatCigam64: + // The fat magics are stored big-endian, so a little-endian read of a well-formed + // universal binary sees the swapped spelling. + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_UnexpectedFatFile, "This is a universal binary rather than a single Mach-O image. Read it with MachOFatFile and pick a slice."); + return; + default: + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidMagic, $"Invalid Mach-O magic 0x{magic:X8}"); + return; + } + + uint numberOfCommands; + if (Is64Bit) + { + if (!reader.TryReadData(sizeof(RawMachHeader64), out RawMachHeader64 header)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidMagic, "Truncated 64-bit Mach-O header"); + return; + } + CpuType = (MachOCpuType)header.CpuType; + CpuSubType = header.CpuSubType; + FileType = (MachOFileType)header.FileType; + Flags = (MachOHeaderFlags)header.Flags; + Reserved = header.Reserved; + numberOfCommands = header.NumberOfCommands; + } + else + { + if (!reader.TryReadData(sizeof(RawMachHeader32), out RawMachHeader32 header)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidMagic, "Truncated 32-bit Mach-O header"); + return; + } + CpuType = (MachOCpuType)header.CpuType; + CpuSubType = header.CpuSubType; + FileType = (MachOFileType)header.FileType; + Flags = (MachOHeaderFlags)header.Flags; + numberOfCommands = header.NumberOfCommands; + } + + ReadLoadCommands(reader, numberOfCommands); + if (reader.Diagnostics.HasErrors) return; + + ReadContent(reader); + } + + private void ReadLoadCommands(MachOReader reader, uint numberOfCommands) + { + var commandAlignment = MachOLoadCommand.GetSizeAlignment(Is64Bit); + + for (uint i = 0; i < numberOfCommands; i++) + { + var commandPosition = reader.Position; + if (commandPosition + 8 > reader.Length) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} starts past the end of the file"); + return; + } + + var type = (MachOLoadCommandType)reader.ReadU32(); + var commandSize = reader.ReadU32(); + reader.Position = commandPosition; + + if (commandSize < 8 || (commandSize % commandAlignment) != 0) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, $"Load command {i} has an invalid cmdsize of {commandSize}, which must be at least 8 and a multiple of {commandAlignment}"); + return; + } + + if (commandPosition + commandSize > reader.Length) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} of size {commandSize} extends past the end of the file"); + return; + } + + MachOLoadCommand command = type switch + { + MachOLoadCommandType.Segment => new MachOSegment { Is64Bit = false }, + MachOLoadCommandType.Segment64 => new MachOSegment { Is64Bit = true }, + MachOLoadCommandType.LoadDylib + or MachOLoadCommandType.IdDylib + or MachOLoadCommandType.LoadWeakDylib + or MachOLoadCommandType.ReexportDylib + or MachOLoadCommandType.LoadUpwardDylib + or MachOLoadCommandType.LazyLoadDylib => new MachODylibCommand { Is64Bit = Is64Bit }, + MachOLoadCommandType.RPath + or MachOLoadCommandType.LoadDylinker + or MachOLoadCommandType.IdDylinker + or MachOLoadCommandType.DyldEnvironment => new MachOPathCommand { Is64Bit = Is64Bit }, + MachOLoadCommandType.SymbolTable => new MachOSymbolTableCommand(), + MachOLoadCommandType.TwoLevelHints => new MachOTwoLevelHintsCommand(), + MachOLoadCommandType.Thread or MachOLoadCommandType.UnixThread => new MachOThreadCommand(), + MachOLoadCommandType.Main => new MachOMainCommand(), + MachOLoadCommandType.Uuid => new MachOUuidCommand(), + MachOLoadCommandType.BuildVersion => new MachOBuildVersionCommand(), + MachOLoadCommandType.SourceVersion => new MachOSourceVersionCommand(), + MachOLoadCommandType.VersionMinMacOSX + or MachOLoadCommandType.VersionMinIPhoneOS + or MachOLoadCommandType.VersionMinTvOS + or MachOLoadCommandType.VersionMinWatchOS => new MachOVersionMinCommand(), + MachOLoadCommandType.DyldInfo or MachOLoadCommandType.DyldInfoOnly => new MachODyldInfoCommand(), + MachOLoadCommandType.DynamicSymbolTable => new MachODynamicSymbolTableCommand(), + MachOLoadCommandType.CodeSignature + or MachOLoadCommandType.FunctionStarts + or MachOLoadCommandType.DataInCode + or MachOLoadCommandType.DyldExportsTrie + or MachOLoadCommandType.DyldChainedFixups + or MachOLoadCommandType.SegmentSplitInfo + or MachOLoadCommandType.DylibCodeSignDrs + or MachOLoadCommandType.LinkerOptimizationHint => new MachOLinkEditDataCommand(), + _ => new MachOUnknownLoadCommand(), + }; + + command.Type = type; + command.Position = commandPosition; + command.Size = commandSize; + LoadCommands.Add(command); + + command.Read(reader); + if (reader.Diagnostics.HasErrors) return; + + reader.Position = commandPosition + commandSize; + } + } + + /// + /// Turns the rest of the file into content, so that every byte belongs to something. The + /// header and the load command table come first, then whatever the commands point at, and + /// the gaps between them are kept as they are rather than regenerated. + /// + private void ReadContent(MachOReader reader) + { + var commandsEnd = (ulong)LoadCommandsEndOffset; + var fileLength = reader.Length; + + Content.Add(new MachOHeaderContent { Position = 0, Size = HeaderSize }); + Content.Add(new MachOLoadCommandTable { Position = HeaderSize, Size = SizeOfCommands }); + + var regions = CollectKnownRegions(reader, commandsEnd, fileLength); + if (reader.Diagnostics.HasErrors) return; + + regions.Sort((left, right) => left.Offset.CompareTo(right.Offset)); + + var cursor = commandsEnd; + var firstGap = true; + + foreach (var region in regions) + { + // Overlapping regions would mean the same bytes belong to two things; keep the first. + if (region.Offset < cursor) continue; + + if (region.Offset > cursor) + { + var gap = AddStreamContent(reader, cursor, region.Offset - cursor, firstGap); + if (firstGap) + { + LoadCommandPadding = gap; + firstGap = false; + } + } + + reader.Position = region.Offset; + var content = region.Section is null + ? new MachOStreamContent(reader.ReadAsStream(region.Size)) + : new MachOSectionData(region.Section, reader.ReadAsStream(region.Size)); + content.Position = region.Offset; + Content.Add(content); + cursor = region.Offset + region.Size; + firstGap = false; + } + + if (cursor < fileLength) + { + var trailing = AddStreamContent(reader, cursor, fileLength - cursor, firstGap); + if (firstGap) LoadCommandPadding = trailing; + } + } + + /// + /// A run of bytes something in the image points at. + /// + private readonly record struct KnownRegion(ulong Offset, ulong Size, MachOSection? Section); + + /// + /// Finds every run of bytes the header or a load command points at. What is left over between + /// them is padding, and is kept verbatim. + /// + private List CollectKnownRegions(MachOReader reader, ulong commandsEnd, ulong fileLength) + { + var regions = new List(); + + void Add(ulong offset, ulong size, MachOSection? section = null) + { + // An offset of zero means the data is absent rather than at the start of the file. + if (offset < commandsEnd || size == 0) return; + + if (offset + size > fileLength) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidSectionFileRange, + $"Content at 0x{offset:X} for 0x{size:X} bytes extends past the end of the file"); + return; + } + + regions.Add(new KnownRegion(offset, size, section)); + } + + foreach (var segment in Segments) + { + foreach (var section in segment.Sections) + { + if (!section.IsZeroFill) + { + Add(section.FileOffset, section.Size, section); + } + + Add(section.RelocationOffset, section.NumberOfRelocations * (ulong)MachORelocation.EntrySize); + } + } + + foreach (var command in LoadCommands) + { + switch (command) + { + case MachOSymbolTableCommand symtab: + Add(symtab.SymbolOffset, symtab.SymbolCount * (ulong)MachOSymbolTableCommand.GetSymbolSize(Is64Bit)); + Add(symtab.StringOffset, symtab.StringSize); + break; + case MachODynamicSymbolTableCommand dysymtab: + Add(dysymtab.TableOfContentsOffset, dysymtab.TableOfContentsCount * MachODynamicSymbolTableCommand.TableOfContentsEntrySize); + Add(dysymtab.ModuleTableOffset, dysymtab.ModuleTableCount * MachODynamicSymbolTableCommand.GetModuleTableEntrySize(Is64Bit)); + Add(dysymtab.ExternalReferenceOffset, dysymtab.ExternalReferenceCount * MachODynamicSymbolTableCommand.ExternalReferenceEntrySize); + Add(dysymtab.IndirectSymbolOffset, dysymtab.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize); + Add(dysymtab.ExternalRelocationOffset, dysymtab.ExternalRelocationCount * MachORelocation.EntrySize); + Add(dysymtab.LocalRelocationOffset, dysymtab.LocalRelocationCount * MachORelocation.EntrySize); + break; + case MachODyldInfoCommand dyldInfo: + Add(dyldInfo.RebaseOffset, dyldInfo.RebaseSize); + Add(dyldInfo.BindOffset, dyldInfo.BindSize); + Add(dyldInfo.WeakBindOffset, dyldInfo.WeakBindSize); + Add(dyldInfo.LazyBindOffset, dyldInfo.LazyBindSize); + Add(dyldInfo.ExportOffset, dyldInfo.ExportSize); + break; + case MachOLinkEditDataCommand data: + Add(data.DataOffset, data.DataSize); + break; + case MachOTwoLevelHintsCommand hints: + Add(hints.Offset, hints.HintCount * MachOTwoLevelHintsCommand.HintSize); + break; + } + } + + return regions; + } + + private MachOStreamContent AddStreamContent(MachOReader reader, ulong offset, ulong size, bool isLoadCommandPadding) + { + reader.Position = offset; + var stream = reader.ReadAsStream(size); + MachOStreamContent content = isLoadCommandPadding + ? new MachOLoadCommandPadding(stream) + : new MachOStreamContent(stream); + content.Position = offset; + Content.Add(content); + return content; + } +} diff --git a/src/LibObjectFile/MachO/MachOFile.cs b/src/LibObjectFile/MachO/MachOFile.cs new file mode 100644 index 0000000..81857fa --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.cs @@ -0,0 +1,293 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using LibObjectFile.Collections; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +/// +/// A Mach-O image that can be read, modified and written back. +/// +/// +/// The writer places content at the offsets recorded in the load commands rather than computing +/// a fresh layout. Every byte outside the header and the load command table keeps its position, +/// so addresses, relocations and code stay valid across a read-modify-write cycle. That is what +/// makes in-place load command injection and code signing possible on an existing image, and it +/// is also why growing the load command table is bounded by +/// rather than free. +/// +public sealed partial class MachOFile : MachOObject +{ + private readonly ObjectList _loadCommands; + private readonly ObjectList _content; + + /// + /// Initializes a new empty instance. + /// + public MachOFile() + { + _loadCommands = new ObjectList(this); + _content = new ObjectList(this); + } + + /// + /// Gets or sets whether this image uses the 64-bit layout. + /// + public bool Is64Bit { get; set; } + + /// + /// Gets or sets the CPU architecture of this image. + /// + public MachOCpuType CpuType { get; set; } + + /// + /// Gets or sets the CPU subtype. Its meaning depends on , so it is kept + /// as the raw value rather than being interpreted. + /// + public uint CpuSubType { get; set; } + + /// + /// Gets or sets the kind of image. + /// + public MachOFileType FileType { get; set; } + + /// + /// Gets or sets the header flags. + /// + public MachOHeaderFlags Flags { get; set; } + + /// + /// Gets or sets the reserved header field, present only in 64-bit images. + /// + public uint Reserved { get; set; } + + /// + /// Gets the load commands of this image, in file order. The order is meaningful: dyld + /// identifies a library by the position of its command among the others. + /// + public ObjectList LoadCommands => _loadCommands; + + /// + /// Gets everything in this image, in file order: the header, the load command table, each + /// section's bytes, each table in __LINKEDIT, and the padding between them. + /// + /// + /// Every byte of the file belongs to one of these, so writing the list back out reproduces + /// the image and a layout is a single walk over it. + /// + public ObjectList Content => _content; + + /// + /// Gets the segments of this image, in load command order. + /// + public IEnumerable Segments => LoadCommands.OfType(); + + /// + /// Gets the libraries this image links against, in the order dyld assigns their ordinals. + /// + public IEnumerable LinkedLibraries + => LoadCommands.OfType().Where(c => c.Type != MachOLoadCommandType.IdDylib); + + /// + /// Gets the runpaths @rpath expands to, in search order. + /// + public IEnumerable RunPaths + => LoadCommands.OfType().Where(c => c.Type == MachOLoadCommandType.RPath); + + /// + /// Gets the command giving this image's own install name, present only in a dylib. + /// + public MachODylibCommand? IdDylib + => LoadCommands.OfType().FirstOrDefault(c => c.Type == MachOLoadCommandType.IdDylib); + + /// + /// Gets the size of the Mach-O header, which is 32 bytes for a 64-bit image and 28 otherwise. + /// + public uint HeaderSize => Is64Bit ? 32u : 28u; + + /// + /// Gets the total size of the load commands, the value stored in sizeofcmds. + /// + public uint SizeOfCommands + { + get + { + uint total = 0; + foreach (var command in LoadCommands) + { + total += (uint)command.Size; + } + return total; + } + } + + /// + /// Gets the file offset one past the end of the load command table. + /// + public uint LoadCommandsEndOffset => HeaderSize + SizeOfCommands; + + /// + /// Gets the command locating this image's code signature, or null if it is unsigned. + /// + public MachOLinkEditDataCommand? CodeSignature + => LoadCommands.OfType().FirstOrDefault(c => c.Type == MachOLoadCommandType.CodeSignature); + + /// + /// Gets whether this image has been changed since it was signed, which would leave the + /// signature covering bytes that are no longer there. + /// + /// + /// Set by the editing operations and cleared by . Writing an image in + /// this state fails, because the result would look signed and be refused at execution. Sign + /// again after editing. + /// + /// This follows the editing operations, not arbitrary writes to the model. Reaching into a + /// load command or a piece of content directly still invalidates a signature without being + /// noticed here. + /// + /// + public bool IsCodeSignatureStale { get; internal set; } + + /// + /// Gets the padding the linker left after the load command table, or null if there is none. + /// + /// + /// Linkers leave this gap so commands can be added later without moving anything. Adding a + /// command consumes it from the front, which is how install_name_tool edits an image + /// without relinking it. + /// + public MachOContent? LoadCommandPadding { get; internal set; } + + /// + /// Gets the file offset at which content after the load commands and their padding begins. + /// + public ulong ContentStartOffset + => LoadCommandPadding is { } padding ? padding.Position + padding.Size : LoadCommandsEndOffset; + + /// + /// Gets the number of bytes the load command table can still grow by without moving any + /// content. A negative value means the current commands no longer fit. + /// + public long AvailableLoadCommandSpace => (long)ContentStartOffset - LoadCommandsEndOffset; + + /// + /// Rewrites every file offset recorded by the load commands, so content in + /// __LINKEDIT can be moved without each caller knowing which commands carry offsets. + /// + /// Maps an old file offset to its new one. + /// + /// This does not move any bytes, and it does not touch section offsets, which are fixed by + /// their segment: a section's address is its segment's address plus its distance from the + /// segment's file offset, so moving a section moves its address with it. + /// + /// is null. + public void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + + foreach (var command in LoadCommands) + { + command.UpdateFileOffsets(mapper); + } + } + + /// + /// Computes the size this image occupies when written, without writing it. + /// + /// The number of bytes produces. + /// + /// Content sits at the offsets recorded for it, so the size is where the furthest of it + /// ends. An image with no content at all is just its header, commands and their padding. + /// + public ulong ComputeFileSize() + { + ulong end = 0; + foreach (var content in Content) + { + end = Math.Max(end, content.Position + content.Size); + } + return end; + } + + /// + /// Finds a segment by name. + /// + /// The segment name, such as __TEXT. + /// The segment, or null if this image has no segment with that name. + public MachOSegment? FindSegment(string name) + { + ArgumentNullException.ThrowIfNull(name); + foreach (var segment in Segments) + { + if (segment.Name == name) return segment; + } + return null; + } + + /// + /// Places the content of this image. + /// + /// + /// Only the header, the load command table and the padding between the table and what follows + /// it are placed. Everything else keeps the position recorded for it, because a section's + /// address is its segment's address plus its distance from the segment's file offset, so + /// moving content in the file would move it in memory. The padding is what absorbs a command + /// table that has grown, which is the whole of the room an edit has to work in. + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + { + foreach (var content in Content) + { + content.UpdateLayout(context); + } + + ulong cursor = 0; + for (var i = 0; i < Content.Count; i++) + { + var content = Content[i]; + + switch (content) + { + case MachOHeaderContent: + case MachOLoadCommandTable: + content.Position = cursor; + break; + + case MachOLoadCommandPadding padding: + padding.Position = cursor; + + // The padding runs from the end of the commands to whatever comes next, so a + // table that has grown eats into it and one that has shrunk gives back. + var next = i + 1 < Content.Count ? Content[i + 1].Position : cursor; + if (next < cursor) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_NoRoomForLoadCommands, + $"The load commands now end at {cursor} but the content after them starts at {next}, so they no longer fit."); + return; + } + padding.Size = next - cursor; + break; + } + + cursor = content.Position + content.Size; + } + } + + /// + protected override void PrintName(StringBuilder builder) => builder.Append(nameof(MachOFile)); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"{CpuType} {FileType}, {(Is64Bit ? "64-bit" : "32-bit")}, Commands = {LoadCommands.Count}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs b/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs new file mode 100644 index 0000000..41a88f7 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs @@ -0,0 +1,80 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// A load command locating a blob inside __LINKEDIT, covering LC_CODE_SIGNATURE, +/// LC_FUNCTION_STARTS, LC_DATA_IN_CODE, LC_DYLD_EXPORTS_TRIE, +/// LC_DYLD_CHAINED_FIXUPS, LC_SEGMENT_SPLIT_INFO, LC_DYLIB_CODE_SIGN_DRS +/// and LC_LINKER_OPTIMIZATION_HINT. +/// +/// +/// The command only records where the blob is; the bytes themselves live in the +/// __LINKEDIT segment's content and are not interpreted here. +/// +public sealed class MachOLinkEditDataCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 16; + + /// + /// Gets or sets the file offset of the blob. + /// + public uint DataOffset { get; set; } + + /// + /// Gets or sets the size in bytes of the blob. + /// + public uint DataSize { get; set; } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + if (DataOffset != 0) DataOffset = mapper(DataOffset); + } + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawLinkEditDataCommand), out RawLinkEditDataCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated link edit data command at 0x{Position:X}"); + return; + } + + DataOffset = raw.DataOffset; + DataSize = raw.DataSize; + } + + /// + public override void Write(MachOWriter writer) + { + writer.Write(new RawLinkEditDataCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + DataOffset = DataOffset, + DataSize = DataSize, + }); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Type = {Type}, DataOffset = 0x{DataOffset:X}, DataSize = 0x{DataSize:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOLoadCommand.cs b/src/LibObjectFile/MachO/MachOLoadCommand.cs new file mode 100644 index 0000000..9c2a6f1 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOLoadCommand.cs @@ -0,0 +1,50 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// Base class for a Mach-O load command. +/// +/// +/// is the command's cmdsize, which covers the +/// cmd and cmdsize fields themselves. It is always a multiple of 4 on 32-bit +/// images and of 8 on 64-bit ones, because dyld walks the list by adding it to a pointer. +/// +public abstract class MachOLoadCommand : MachOObject +{ + /// + /// Gets or sets the type of this load command. + /// + public MachOLoadCommandType Type { get; set; } + + /// + /// Rewrites every file offset this command records, so a layout can move the data they point + /// at without each caller having to know which commands carry offsets. + /// + /// Maps an old file offset to its new one. + /// + /// An offset of zero means the data is absent rather than located at the start of the file, + /// so it is left alone and is not called for it. + /// + public virtual void UpdateFileOffsets(Func mapper) + { + } + + /// + /// Gets the alignment cmdsize has to satisfy for the given image width. + /// + /// Whether the containing image is 64-bit. + /// 4 for a 32-bit image, 8 for a 64-bit one. + public static uint GetSizeAlignment(bool is64Bit) => is64Bit ? 8u : 4u; + + /// + protected override bool PrintMembers(System.Text.StringBuilder builder) + { + builder.Append($"Type = {Type}, Size = {Size}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOMainCommand.cs b/src/LibObjectFile/MachO/MachOMainCommand.cs new file mode 100644 index 0000000..383800d --- /dev/null +++ b/src/LibObjectFile/MachO/MachOMainCommand.cs @@ -0,0 +1,69 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The entry point load command (LC_MAIN). +/// +/// +/// This replaced LC_UNIXTHREAD, which specified the entry point by handing dyld a whole +/// register state to restore. is a file offset rather than an address, +/// so it moves with the content it points at. +/// +public sealed class MachOMainCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 24; + + /// + /// Gets or sets the file offset of the entry point. + /// + public ulong EntryOffset { get; set; } + + /// + /// Gets or sets the initial stack size, or zero to let the system choose. + /// + public ulong StackSize { get; set; } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawEntryPointCommand), out RawEntryPointCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_MAIN at 0x{Position:X}"); + return; + } + + EntryOffset = raw.EntryOffset; + StackSize = raw.StackSize; + } + + /// + public override void Write(MachOWriter writer) + => writer.Write(new RawEntryPointCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + EntryOffset = EntryOffset, + StackSize = StackSize, + }); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"EntryOffset = 0x{EntryOffset:X}, StackSize = 0x{StackSize:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOObject.cs b/src/LibObjectFile/MachO/MachOObject.cs new file mode 100644 index 0000000..b37605f --- /dev/null +++ b/src/LibObjectFile/MachO/MachOObject.cs @@ -0,0 +1,12 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Base class for all elements of a . +/// +public abstract class MachOObject : ObjectFileElement +{ +} diff --git a/src/LibObjectFile/MachO/MachOPathCommand.cs b/src/LibObjectFile/MachO/MachOPathCommand.cs new file mode 100644 index 0000000..3a24eff --- /dev/null +++ b/src/LibObjectFile/MachO/MachOPathCommand.cs @@ -0,0 +1,54 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// A load command carrying a single path, covering LC_RPATH, LC_LOAD_DYLINKER, +/// LC_ID_DYLINKER and LC_DYLD_ENVIRONMENT. +/// +public sealed class MachOPathCommand : MachOPathLoadCommand +{ + /// + /// Gets or sets the path carried by this command. For LC_RPATH this is a directory + /// that @rpath expands to, and for the dylinker commands it is the path of the loader. + /// + public string Path + { + get => Value; + set => Value = value; + } + + /// + protected override unsafe uint FixedSize => (uint)sizeof(RawPathCommand); + + /// + public override unsafe void Read(MachOReader reader) + { + var commandPosition = reader.Position; + if (!reader.TryReadData(sizeof(RawPathCommand), out RawPathCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated path command at 0x{commandPosition:X}"); + return; + } + + ReadValue(reader, commandPosition, raw.PathOffset); + } + + /// + public override unsafe void Write(MachOWriter writer) + { + writer.Write(new RawPathCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + PathOffset = (uint)sizeof(RawPathCommand), + }); + WriteValue(writer); + } +} diff --git a/src/LibObjectFile/MachO/MachOPathLoadCommand.cs b/src/LibObjectFile/MachO/MachOPathLoadCommand.cs new file mode 100644 index 0000000..39741ee --- /dev/null +++ b/src/LibObjectFile/MachO/MachOPathLoadCommand.cs @@ -0,0 +1,115 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.Utils; + +namespace LibObjectFile.MachO; + +/// +/// Base class for the load commands that store a string inline after a fixed header. +/// +/// +/// The string is stored NUL-terminated and then padded with more NULs until cmdsize is +/// aligned. The original cmdsize is kept as long as the string still fits, so re-writing +/// an untouched command reproduces the linker's padding byte for byte rather than the smallest +/// encoding this library would have chosen. +/// +public abstract class MachOPathLoadCommand : MachOLoadCommand +{ + private string _value = string.Empty; + + /// + /// Gets or sets the string carried by this command. + /// + /// The value is null. + public string Value + { + get => _value; + set + { + ArgumentNullException.ThrowIfNull(value); + _value = value; + } + } + + /// + /// Gets or sets whether the containing image is 64-bit, which sets the cmdsize alignment. + /// + public bool Is64Bit { get; set; } + + /// + /// Gets the size of the fixed part preceding the string, including cmd and cmdsize. + /// + protected abstract uint FixedSize { get; } + + /// + /// Gets the smallest valid cmdsize for the current . + /// + public uint MinimumSize + { + get + { + // The string is stored NUL-terminated, hence the extra byte. + var unaligned = FixedSize + (uint)Encoding.UTF8.GetByteCount(Value) + 1; + return AlignHelper.AlignUp(unaligned, GetSizeAlignment(Is64Bit)); + } + } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + { + var minimum = MinimumSize; + if (Size < minimum) + { + Size = minimum; + } + } + + /// + /// Reads the string that follows the fixed part. + /// + /// The reader, positioned anywhere. + /// The file offset of the start of this command. + /// The offset of the string from the start of this command. + protected void ReadValue(MachOReader reader, ulong commandPosition, uint stringOffset) + { + if (stringOffset < FixedSize || stringOffset >= Size) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, + $"The string offset {stringOffset} in {Type} at 0x{commandPosition:X} is outside the command, which is {Size} bytes"); + return; + } + + var length = (int)(Size - stringOffset); + var buffer = new byte[length]; + reader.Position = commandPosition + stringOffset; + reader.ReadExactly(buffer); + + var end = Array.IndexOf(buffer, (byte)0); + if (end < 0) end = length; + Value = Encoding.UTF8.GetString(buffer, 0, end); + } + + /// + /// Writes the string and the NUL padding that brings the command up to . + /// + /// The writer, positioned at the end of the fixed part. + protected void WriteValue(MachOWriter writer) + { + var bytes = Encoding.UTF8.GetBytes(Value); + writer.Write(bytes); + writer.WriteZero((int)Size - (int)FixedSize - bytes.Length); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Type = {Type}, Value = {Value}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOReader.cs b/src/LibObjectFile/MachO/MachOReader.cs new file mode 100644 index 0000000..2b649e7 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOReader.cs @@ -0,0 +1,50 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.IO; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +/// +/// Reads a from a . +/// +public sealed class MachOReader : ObjectFileReaderWriter +{ + internal MachOReader(MachOFile file, Stream stream, MachOReaderOptions options) : base(file, stream) + { + Options = options; + VisitorContext = new MachOVisitorContext(file, Diagnostics); + } + + internal MachOReader(MachOFile file, Stream stream, MachOReaderOptions options, DiagnosticBag diagnostics) : base(file, stream, diagnostics) + { + Options = options; + VisitorContext = new MachOVisitorContext(file, Diagnostics); + } + + /// + /// Gets the file being read. + /// + public new MachOFile File => (MachOFile)base.File; + + /// + /// Gets the context carrying the diagnostics collected while reading. + /// + public MachOVisitorContext VisitorContext { get; } + + /// + /// Gets the options used for reading. + /// + public MachOReaderOptions Options { get; } + + /// + public override bool KeepOriginalStreamForSubStreams => Options.UseSubStream; + + /// + /// Converts a reader to the visitor context it carries. + /// + /// The reader. + public static implicit operator MachOVisitorContext(MachOReader reader) => reader.VisitorContext; +} diff --git a/src/LibObjectFile/MachO/MachOReaderOptions.cs b/src/LibObjectFile/MachO/MachOReaderOptions.cs new file mode 100644 index 0000000..ae96dcd --- /dev/null +++ b/src/LibObjectFile/MachO/MachOReaderOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// Options for reading a . +/// +public sealed class MachOReaderOptions +{ + /// + /// Gets or sets whether segment content is read as a view over the input stream instead of + /// being copied into memory. The input stream then has to outlive the returned file. + /// + public bool UseSubStream { get; set; } +} diff --git a/src/LibObjectFile/MachO/MachOSection.cs b/src/LibObjectFile/MachO/MachOSection.cs new file mode 100644 index 0000000..ad20f21 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSection.cs @@ -0,0 +1,112 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; + +namespace LibObjectFile.MachO; + +/// +/// A section inside a . +/// +/// +/// A section records an absolute file offset rather than one relative to its segment, and it +/// repeats the name of the segment containing it. Sections whose is a +/// zero-fill kind occupy address space but no file space, and their +/// does not point at bytes belonging to them. +/// +public sealed class MachOSection : MachOObject +{ + /// + /// Gets or sets the name of the section, such as __text. Truncated to 16 bytes on write. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the segment containing this section, such as __TEXT. + /// + public string SegmentName { get; set; } = string.Empty; + + /// + /// Gets or sets the virtual address of this section. + /// + public ulong Address { get; set; } + + /// + /// Gets or sets the file offset of this section. Meaningless for zero-fill sections. + /// + public uint FileOffset { get; set; } + + /// + /// Gets or sets the alignment of this section as a power of two. + /// + public uint Align { get; set; } + + /// + /// Gets or sets the file offset of the relocation entries for this section. + /// + public uint RelocationOffset { get; set; } + + /// + /// Gets or sets the number of relocation entries for this section. + /// + public uint NumberOfRelocations { get; set; } + + /// + /// Gets or sets the type of this section, held in the low byte of the raw flags. + /// + public MachOSectionType SectionType { get; set; } + + /// + /// Gets or sets the attributes of this section, held in the upper three bytes of the raw flags. + /// + public MachOSectionAttributes Attributes { get; set; } + + /// + /// Gets or sets the first reserved field. It holds the indirect symbol table index for + /// stub and symbol pointer sections, and the ordinal for others. + /// + public uint Reserved1 { get; set; } + + /// + /// Gets or sets the second reserved field. It holds the stub size for + /// sections. + /// + public uint Reserved2 { get; set; } + + /// + /// Gets or sets the third reserved field, present only in 64-bit sections. + /// + public uint Reserved3 { get; set; } + + /// + /// Gets a value indicating whether this section occupies no space in the file. + /// + public bool IsZeroFill => SectionType is MachOSectionType.ZeroFill or MachOSectionType.GBZeroFill or MachOSectionType.ThreadLocalZeroFill; + + /// + /// Gets the raw flags value combining and . + /// + public uint RawFlags => (uint)SectionType | (uint)Attributes; + + /// + /// Sets and from a raw flags value. + /// + /// The raw section flags. + public void SetRawFlags(uint rawFlags) + { + SectionType = (MachOSectionType)(rawFlags & 0xff); + Attributes = (MachOSectionAttributes)(rawFlags & 0xffffff00); + } + + /// + protected override void PrintName(StringBuilder builder) => builder.Append(nameof(MachOSection)); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"{SegmentName},{Name} Address = 0x{Address:X}, Size = 0x{Size:X}, FileOffset = 0x{FileOffset:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOSegment.cs b/src/LibObjectFile/MachO/MachOSegment.cs new file mode 100644 index 0000000..201e3b6 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSegment.cs @@ -0,0 +1,311 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using LibObjectFile.Collections; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// A segment load command (LC_SEGMENT or LC_SEGMENT_64) and the file content it maps. +/// +/// +/// A segment describes a mapping rather than owning bytes: the bytes live in +/// , and a segment names the file range that is mapped and the +/// address it is mapped at. The __TEXT segment starts at file offset zero, so it covers +/// the header and the load commands that describe it as well as its own sections. +/// +public sealed class MachOSegment : MachOLoadCommand +{ + private readonly ObjectList _sections; + + /// + /// Initializes a new instance. + /// + public MachOSegment() + { + _sections = new ObjectList(this); + } + + /// + /// Gets or sets the name of the segment, such as __TEXT. Truncated to 16 bytes on write. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the virtual address this segment is mapped at. + /// + public ulong VmAddress { get; set; } + + /// + /// Gets or sets the number of bytes of address space this segment occupies. It may exceed + /// , in which case the remainder is zero-filled at load time. + /// + public ulong VmSize { get; set; } + + /// + /// Gets or sets the offset in the file of the bytes this segment maps. + /// + public ulong FileOffset { get; set; } + + /// + /// Gets or sets the number of bytes in the file this segment maps. + /// + public ulong FileSize { get; set; } + + /// + /// Gets or sets the highest protection this segment may be given. + /// + public MachOVmProtection MaxProtection { get; set; } + + /// + /// Gets or sets the protection this segment is mapped with. + /// + public MachOVmProtection InitProtection { get; set; } + + /// + /// Gets or sets the flags of this segment. + /// + public MachOSegmentFlags SegmentFlags { get; set; } + + /// + /// Gets the sections contained in this segment. + /// + public ObjectList Sections => _sections; + + /// + /// Gets or sets whether this segment uses the 64-bit layout. + /// + public bool Is64Bit { get; set; } + + /// + /// Gets the offset one past the last byte this segment maps in the file. + /// + public ulong FileEndOffset => FileOffset + FileSize; + + /// + /// Gets the size in bytes a segment load command occupies for the given section count. + /// + /// Whether the segment uses the 64-bit layout. + /// The number of sections in the segment. + /// The value to store in cmdsize. + public static unsafe uint ComputeCommandSize(bool is64Bit, int sectionCount) + => is64Bit + ? (uint)(sizeof(RawSegmentCommand64) + sectionCount * sizeof(RawSection64)) + : (uint)(sizeof(RawSegmentCommand32) + sectionCount * sizeof(RawSection32)); + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + => Size = ComputeCommandSize(Is64Bit, Sections.Count); + + /// + public override unsafe void Read(MachOReader reader) + { + if (Is64Bit) + { + if (!reader.TryReadData(sizeof(RawSegmentCommand64), out RawSegmentCommand64 raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated 64-bit segment command at 0x{Position:X}"); + return; + } + + Name = MachOName.Read(new ReadOnlySpan(raw.SegmentName, MachOName.Length)); + VmAddress = raw.VmAddress; + VmSize = raw.VmSize; + FileOffset = raw.FileOffset; + FileSize = raw.FileSize; + MaxProtection = (MachOVmProtection)raw.MaxProtection; + InitProtection = (MachOVmProtection)raw.InitProtection; + SegmentFlags = (MachOSegmentFlags)raw.Flags; + ReadSections(reader, raw.NumberOfSections); + } + else + { + if (!reader.TryReadData(sizeof(RawSegmentCommand32), out RawSegmentCommand32 raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated 32-bit segment command at 0x{Position:X}"); + return; + } + + Name = MachOName.Read(new ReadOnlySpan(raw.SegmentName, MachOName.Length)); + VmAddress = raw.VmAddress; + VmSize = raw.VmSize; + FileOffset = raw.FileOffset; + FileSize = raw.FileSize; + MaxProtection = (MachOVmProtection)raw.MaxProtection; + InitProtection = (MachOVmProtection)raw.InitProtection; + SegmentFlags = (MachOSegmentFlags)raw.Flags; + ReadSections(reader, raw.NumberOfSections); + } + } + + private unsafe void ReadSections(MachOReader reader, uint count) + { + Sections.Clear(); + for (uint i = 0; i < count; i++) + { + var section = new MachOSection(); + if (Is64Bit) + { + if (!reader.TryReadData(sizeof(RawSection64), out RawSection64 raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated 64-bit section header in segment {Name}"); + return; + } + + section.Name = MachOName.Read(new ReadOnlySpan(raw.SectionName, MachOName.Length)); + section.SegmentName = MachOName.Read(new ReadOnlySpan(raw.SegmentName, MachOName.Length)); + section.Address = raw.Address; + section.Size = raw.Size; + section.FileOffset = raw.Offset; + section.Align = raw.Align; + section.RelocationOffset = raw.RelocationOffset; + section.NumberOfRelocations = raw.NumberOfRelocations; + section.SetRawFlags(raw.Flags); + section.Reserved1 = raw.Reserved1; + section.Reserved2 = raw.Reserved2; + section.Reserved3 = raw.Reserved3; + } + else + { + if (!reader.TryReadData(sizeof(RawSection32), out RawSection32 raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated 32-bit section header in segment {Name}"); + return; + } + + section.Name = MachOName.Read(new ReadOnlySpan(raw.SectionName, MachOName.Length)); + section.SegmentName = MachOName.Read(new ReadOnlySpan(raw.SegmentName, MachOName.Length)); + section.Address = raw.Address; + section.Size = raw.Size; + section.FileOffset = raw.Offset; + section.Align = raw.Align; + section.RelocationOffset = raw.RelocationOffset; + section.NumberOfRelocations = raw.NumberOfRelocations; + section.SetRawFlags(raw.Flags); + section.Reserved1 = raw.Reserved1; + section.Reserved2 = raw.Reserved2; + } + + Sections.Add(section); + } + } + + /// + public override unsafe void Write(MachOWriter writer) + { + if (Is64Bit) + { + var raw = new RawSegmentCommand64 + { + Cmd = (uint)Type, + CmdSize = ComputeCommandSize(true, Sections.Count), + VmAddress = VmAddress, + VmSize = VmSize, + FileOffset = FileOffset, + FileSize = FileSize, + MaxProtection = (uint)MaxProtection, + InitProtection = (uint)InitProtection, + NumberOfSections = (uint)Sections.Count, + Flags = (uint)SegmentFlags, + }; + MachOName.Write(new Span(raw.SegmentName, MachOName.Length), Name); + writer.Write(raw); + } + else + { + // A 32-bit segment stores these as 32-bit fields, and casting a value that does not + // fit would write a different segment rather than fail. + if (VmAddress > uint.MaxValue || VmSize > uint.MaxValue || FileOffset > uint.MaxValue || FileSize > uint.MaxValue) + { + writer.Diagnostics.Error( + DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + $"Segment {Name} has a value that does not fit a 32-bit image: VmAddress = 0x{VmAddress:X}, VmSize = 0x{VmSize:X}, FileOffset = 0x{FileOffset:X}, FileSize = 0x{FileSize:X}"); + return; + } + + var raw = new RawSegmentCommand32 + { + Cmd = (uint)Type, + CmdSize = ComputeCommandSize(false, Sections.Count), + VmAddress = (uint)VmAddress, + VmSize = (uint)VmSize, + FileOffset = (uint)FileOffset, + FileSize = (uint)FileSize, + MaxProtection = (uint)MaxProtection, + InitProtection = (uint)InitProtection, + NumberOfSections = (uint)Sections.Count, + Flags = (uint)SegmentFlags, + }; + MachOName.Write(new Span(raw.SegmentName, MachOName.Length), Name); + writer.Write(raw); + } + + foreach (var section in Sections) + { + WriteSection(writer, section); + } + } + + private unsafe void WriteSection(MachOWriter writer, MachOSection section) + { + if (Is64Bit) + { + var raw = new RawSection64 + { + Address = section.Address, + Size = section.Size, + Offset = section.FileOffset, + Align = section.Align, + RelocationOffset = section.RelocationOffset, + NumberOfRelocations = section.NumberOfRelocations, + Flags = section.RawFlags, + Reserved1 = section.Reserved1, + Reserved2 = section.Reserved2, + Reserved3 = section.Reserved3, + }; + MachOName.Write(new Span(raw.SectionName, MachOName.Length), section.Name); + MachOName.Write(new Span(raw.SegmentName, MachOName.Length), section.SegmentName); + writer.Write(raw); + } + else + { + if (section.Address > uint.MaxValue || section.Size > uint.MaxValue) + { + writer.Diagnostics.Error( + DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + $"Section {section.SegmentName},{section.Name} has a value that does not fit a 32-bit image: Address = 0x{section.Address:X}, Size = 0x{section.Size:X}"); + return; + } + + var raw = new RawSection32 + { + Address = (uint)section.Address, + Size = (uint)section.Size, + Offset = section.FileOffset, + Align = section.Align, + RelocationOffset = section.RelocationOffset, + NumberOfRelocations = section.NumberOfRelocations, + Flags = section.RawFlags, + Reserved1 = section.Reserved1, + Reserved2 = section.Reserved2, + }; + MachOName.Write(new Span(raw.SectionName, MachOName.Length), section.Name); + MachOName.Write(new Span(raw.SegmentName, MachOName.Length), section.SegmentName); + writer.Write(raw); + } + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"{Name} VmAddress = 0x{VmAddress:X}, VmSize = 0x{VmSize:X}, FileOffset = 0x{FileOffset:X}, FileSize = 0x{FileSize:X}, Sections = {Sections.Count}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs b/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs new file mode 100644 index 0000000..840f368 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs @@ -0,0 +1,63 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The source version load command (LC_SOURCE_VERSION), recording the version of the +/// source the image was built from. +/// +public sealed class MachOSourceVersionCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 16; + + /// + /// Gets or sets the packed source version, which holds five parts in 24, 10, 10, 10 and 10 bits. + /// + public ulong SourceVersion { get; set; } + + /// + /// Gets the first four components of the source version. + /// + public Version Version => MachOVersion.DecodeSource(SourceVersion); + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawSourceVersionCommand), out RawSourceVersionCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_SOURCE_VERSION at 0x{Position:X}"); + return; + } + + SourceVersion = raw.Version; + } + + /// + public override void Write(MachOWriter writer) + => writer.Write(new RawSourceVersionCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + Version = SourceVersion, + }); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Version = {Version}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs b/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs new file mode 100644 index 0000000..4f47cc5 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs @@ -0,0 +1,100 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The symbol table load command (LC_SYMTAB), locating the symbol and string tables in +/// __LINKEDIT. +/// +/// +/// counts entries rather than bytes, so the size of the table depends +/// on whether the image is 32- or 64-bit. is a byte count. +/// +public sealed class MachOSymbolTableCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 24; + + /// + /// Gets or sets the file offset of the symbol table. + /// + public uint SymbolOffset { get; set; } + + /// + /// Gets or sets the number of entries in the symbol table. + /// + public uint SymbolCount { get; set; } + + /// + /// Gets or sets the file offset of the string table. + /// + public uint StringOffset { get; set; } + + /// + /// Gets or sets the size in bytes of the string table. + /// + public uint StringSize { get; set; } + + /// + /// Gets the size in bytes of one symbol table entry for the given image width. + /// + /// Whether the containing image is 64-bit. + /// 16 for a 64-bit image, 12 for a 32-bit one. + public static uint GetSymbolSize(bool is64Bit) => is64Bit ? 16u : 12u; + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + if (SymbolOffset != 0) SymbolOffset = mapper(SymbolOffset); + if (StringOffset != 0) StringOffset = mapper(StringOffset); + } + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawSymtabCommand), out RawSymtabCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_SYMTAB at 0x{Position:X}"); + return; + } + + SymbolOffset = raw.SymbolOffset; + SymbolCount = raw.SymbolCount; + StringOffset = raw.StringOffset; + StringSize = raw.StringSize; + } + + /// + public override void Write(MachOWriter writer) + { + writer.Write(new RawSymtabCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + SymbolOffset = SymbolOffset, + SymbolCount = SymbolCount, + StringOffset = StringOffset, + StringSize = StringSize, + }); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Symbols = {SymbolCount} at 0x{SymbolOffset:X}, Strings = 0x{StringSize:X} bytes at 0x{StringOffset:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOThreadCommand.cs b/src/LibObjectFile/MachO/MachOThreadCommand.cs new file mode 100644 index 0000000..93f89c2 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOThreadCommand.cs @@ -0,0 +1,133 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +/// +/// A thread state load command (LC_THREAD or LC_UNIXTHREAD). +/// +/// +/// Before LC_MAIN, an executable specified its entry point by handing the kernel a whole +/// register state to restore, with the program counter set to the entry. Which register holds +/// the program counter depends on the architecture and the flavour, so the register words are +/// kept as they are rather than being interpreted here. +/// +/// LC_UNIXTHREAD also asks the kernel to set up a stack, which is the difference from +/// LC_THREAD. +/// +/// +public sealed class MachOThreadCommand : MachOLoadCommand +{ + /// + /// Gets the thread states carried by this command. The format allows more than one, though + /// a linker emits a single state. + /// + public List States { get; } = []; + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) + { + uint total = 8; + foreach (var state in States) + { + total += 8 + (uint)state.Registers.Length * 4; + } + Size = total; + } + + /// + public override void Read(MachOReader reader) + { + var commandPosition = reader.Position; + reader.ReadU32(); + reader.ReadU32(); + + States.Clear(); + var end = commandPosition + Size; + while (reader.Position + 8 <= end) + { + var flavor = reader.ReadU32(); + var count = reader.ReadU32(); + + if (reader.Position + (ulong)count * 4 > end) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_TruncatedLoadCommand, + $"The thread state at 0x{commandPosition:X} claims {count} registers, which do not fit in the command"); + return; + } + + var registers = new uint[count]; + for (var i = 0; i < count; i++) + { + registers[i] = reader.ReadU32(); + } + + States.Add(new MachOThreadState(flavor, registers)); + } + } + + /// + public override void Write(MachOWriter writer) + { + writer.WriteU32((uint)Type); + writer.WriteU32((uint)Size); + + foreach (var state in States) + { + writer.WriteU32(state.Flavor); + writer.WriteU32((uint)state.Registers.Length); + foreach (var register in state.Registers) + { + writer.WriteU32(register); + } + } + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Type = {Type}, States = {States.Count}"); + return true; + } +} + +/// +/// One register state inside a . +/// +public sealed class MachOThreadState +{ + /// + /// Initializes a new instance. + /// + /// The architecture-specific flavour identifying the register layout. + /// The register words. + /// is null. + public MachOThreadState(uint flavor, uint[] registers) + { + ArgumentNullException.ThrowIfNull(registers); + Flavor = flavor; + Registers = registers; + } + + /// + /// Gets or sets the flavour, which says which register layout the words follow. The values + /// are architecture-specific, so the same number means different things on x86 and ARM. + /// + public uint Flavor { get; set; } + + /// + /// Gets or sets the register words, counted in 32-bit units even on a 64-bit architecture + /// where each register spans two of them. + /// + public uint[] Registers { get; set; } + + /// + public override string ToString() => $"{nameof(MachOThreadState)} {{ Flavor = {Flavor}, Registers = {Registers.Length} }}"; +} diff --git a/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs b/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs new file mode 100644 index 0000000..8606497 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs @@ -0,0 +1,79 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The two-level namespace hint table load command (LC_TWOLEVEL_HINTS). +/// +/// +/// The table lets the loader skip searching for a symbol it already knows the library and index +/// of. It is a relic of the prebinding era and current linkers no longer emit it, but images +/// from that era still carry one, and it records a file offset that has to move with the table. +/// +public sealed class MachOTwoLevelHintsCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 16; + + /// The size of one hint entry (twolevel_hint). + public const uint HintSize = 4; + + /// + /// Gets or sets the file offset of the hint table. + /// + public uint Offset { get; set; } + + /// + /// Gets or sets the number of hints, each a 4-byte entry. + /// + public uint HintCount { get; set; } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + if (Offset != 0) Offset = mapper(Offset); + } + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawTwoLevelHintsCommand), out RawTwoLevelHintsCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_TWOLEVEL_HINTS at 0x{Position:X}"); + return; + } + + Offset = raw.Offset; + HintCount = raw.HintCount; + } + + /// + public override void Write(MachOWriter writer) + => writer.Write(new RawTwoLevelHintsCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + Offset = Offset, + HintCount = HintCount, + }); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Hints = {HintCount} at 0x{Offset:X}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOUnknownLoadCommand.cs b/src/LibObjectFile/MachO/MachOUnknownLoadCommand.cs new file mode 100644 index 0000000..da8a47c --- /dev/null +++ b/src/LibObjectFile/MachO/MachOUnknownLoadCommand.cs @@ -0,0 +1,43 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// A load command that is kept as raw bytes rather than being decoded. +/// +/// +/// This is the fallback for command types the library does not model, and it is what makes a +/// byte-exact round-trip possible for images using commands added after this code was written. +/// The payload excludes the cmd and cmdsize fields, which are rebuilt on write. +/// +public sealed class MachOUnknownLoadCommand : MachOLoadCommand +{ + /// + /// Gets or sets the raw payload following the cmd and cmdsize fields. + /// + public byte[] Payload { get; set; } = []; + + /// + public override void Read(MachOReader reader) + { + reader.ReadU32(); + reader.ReadU32(); + Payload = new byte[Size - 8]; + reader.ReadExactly(Payload); + } + + /// + public override void Write(MachOWriter writer) + { + writer.WriteU32((uint)Type); + writer.WriteU32((uint)(8 + Payload.Length)); + writer.Write(Payload); + } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = (ulong)(8 + Payload.Length); +} diff --git a/src/LibObjectFile/MachO/MachOUuidCommand.cs b/src/LibObjectFile/MachO/MachOUuidCommand.cs new file mode 100644 index 0000000..f585ffc --- /dev/null +++ b/src/LibObjectFile/MachO/MachOUuidCommand.cs @@ -0,0 +1,70 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// The image identifier load command (LC_UUID). +/// +/// +/// The identifier is what pairs a binary with its separate debug companion, so a debugger will +/// refuse a dSYM whose value does not match. +/// +public sealed class MachOUuidCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 24; + + /// + /// Gets or sets the identifier of this image. + /// + /// + /// The 16 bytes are stored in the order they appear in the file, which is the big-endian + /// order reads with bigEndian: true. + /// Reading them the other way round would reverse the first three fields and print an + /// identifier that no other tool agrees with. + /// + public Guid Uuid { get; set; } + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawUuidCommand), out RawUuidCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated LC_UUID at 0x{Position:X}"); + return; + } + + Uuid = new Guid(new ReadOnlySpan(raw.Uuid, 16), bigEndian: true); + } + + /// + public override unsafe void Write(MachOWriter writer) + { + var raw = new RawUuidCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + }; + Uuid.TryWriteBytes(new Span(raw.Uuid, 16), bigEndian: true, out _); + writer.Write(raw); + } + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Uuid = {Uuid:D}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOVersionMinCommand.cs b/src/LibObjectFile/MachO/MachOVersionMinCommand.cs new file mode 100644 index 0000000..dfeb02e --- /dev/null +++ b/src/LibObjectFile/MachO/MachOVersionMinCommand.cs @@ -0,0 +1,80 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +/// +/// A minimum OS version load command, covering LC_VERSION_MIN_MACOSX, +/// LC_VERSION_MIN_IPHONEOS, LC_VERSION_MIN_TVOS and LC_VERSION_MIN_WATCHOS. +/// +/// +/// The platform is encoded in the command type, which is why there is one command per platform. +/// replaced the whole family with a single command +/// carrying the platform as a field. +/// +public sealed class MachOVersionMinCommand : MachOLoadCommand +{ + /// + /// The size of this command, which is fixed. + /// + public const uint CommandSize = 16; + + /// + /// Gets or sets the packed minimum OS version. + /// + public uint MinOSVersion { get; set; } + + /// + /// Gets or sets the packed SDK version the image was built against. + /// + public uint SdkVersion { get; set; } + + /// + /// Gets the minimum OS version. + /// + public Version MinOS => MachOVersion.Decode(MinOSVersion); + + /// + /// Gets the SDK version. + /// + public Version Sdk => MachOVersion.Decode(SdkVersion); + + /// + protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; + + /// + public override unsafe void Read(MachOReader reader) + { + if (!reader.TryReadData(sizeof(RawVersionMinCommand), out RawVersionMinCommand raw)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Truncated {Type} at 0x{Position:X}"); + return; + } + + MinOSVersion = raw.Version; + SdkVersion = raw.Sdk; + } + + /// + public override void Write(MachOWriter writer) + => writer.Write(new RawVersionMinCommand + { + Cmd = (uint)Type, + CmdSize = (uint)Size, + Version = MinOSVersion, + Sdk = SdkVersion, + }); + + /// + protected override bool PrintMembers(StringBuilder builder) + { + builder.Append($"Type = {Type}, MinOS = {MinOS}, Sdk = {Sdk}"); + return true; + } +} diff --git a/src/LibObjectFile/MachO/MachOVisitorContext.cs b/src/LibObjectFile/MachO/MachOVisitorContext.cs new file mode 100644 index 0000000..e137022 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOVisitorContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +/// +/// Context used when laying out or verifying a . +/// +public sealed class MachOVisitorContext : VisitorContextBase +{ + internal MachOVisitorContext(MachOFile file, DiagnosticBag diagnostics) : base(file, diagnostics) + { + } +} diff --git a/src/LibObjectFile/MachO/MachOWriter.cs b/src/LibObjectFile/MachO/MachOWriter.cs new file mode 100644 index 0000000..3fd7185 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOWriter.cs @@ -0,0 +1,38 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.IO; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +/// +/// Writes a to a . +/// +public sealed class MachOWriter : ObjectFileReaderWriter +{ + internal MachOWriter(MachOFile file, Stream stream, DiagnosticBag diagnostics) : base(file, stream, diagnostics) + { + VisitorContext = new MachOVisitorContext(file, Diagnostics); + } + + /// + /// Gets the file being written. + /// + public new MachOFile File => (MachOFile)base.File; + + /// + /// Gets the context carrying the diagnostics collected while writing. + /// + public MachOVisitorContext VisitorContext { get; } + + /// + public override bool KeepOriginalStreamForSubStreams => false; + + /// + /// Converts a writer to the visitor context it carries. + /// + /// The writer. + public static implicit operator MachOVisitorContext(MachOWriter writer) => writer.VisitorContext; +} From 4d170781ef1912b47720cd962dcc8c5080cc5cb6 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:24:45 -0400 Subject: [PATCH 04/32] Add Mach-O writer Reading an image was of no use without being able to write one back. Adds the write path, which lays the content out and then writes each element at its position. Only the header, the load command table and the padding after it are placed by that layout; everything else keeps the position recorded for it, because moving content in a Mach-O moves the addresses that refer to it. The padding is what absorbs a command table that has grown, so writing fails when the commands no longer fit rather than moving content and invalidating the image. - src/LibObjectFile/MachO/MachOFile.Write.cs: writes the content list - src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs: byte-exact round-trip per fixture, and every recorded file offset being reachable --- .../MachO/MachOSimpleTests.cs | 87 +++++++++++++++++++ .../MachO/MachOTestBase.cs | 8 ++ src/LibObjectFile/MachO/MachOFile.Write.cs | 77 ++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/LibObjectFile/MachO/MachOFile.Write.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index e860d58..c9fb61e 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -127,4 +127,91 @@ public void RejectsANonMachOStream() Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); } + + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("helloworld_arm64")] + [DataRow("chainedfixups_arm64")] + [DataRow("libhelloworld_x86_64.dylib")] + [DataRow("helloworld_x86_64.o")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public void ReadWriteIsByteExact(string name) + { + var original = File.ReadAllBytes(GetFile(name)); + + using var input = new MemoryStream(original); + var file = MachOFile.Read(input); + + var output = new MemoryStream(); + file.Write(output); + + ByteArrayAssert.AreEqual(original, output.ToArray(), $"Invalid binary diff for {name} after read -> write"); + } + + /// + /// Commands this code does not model have to survive verbatim, which is what keeps the reader + /// usable against images from a newer linker. The type used here is a made-up one, so that + /// modelling more real commands later cannot quietly void the test. + /// + [TestMethod] + public void KeepsUnmodelledLoadCommandsVerbatim() + { + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; + + var file = LoadMachO("unixthread_i386"); + file.LoadCommands.Add(new MachOUnknownLoadCommand + { + Type = (MachOLoadCommandType)0x7F000001, + Size = 16, + Payload = payload, + }); + file.LoadCommands.Add(new MachOTwoLevelHintsCommand + { + Type = MachOLoadCommandType.TwoLevelHints, + Size = MachOTwoLevelHintsCommand.CommandSize, + Offset = 0x2000, + HintCount = 7, + }); + + var stream = new MemoryStream(); + file.Write(stream); + var reread = MachOFile.Read(new MemoryStream(stream.ToArray())); + + var survivor = reread.LoadCommands.OfType().Single(c => c.Type == (MachOLoadCommandType)0x7F000001); + CollectionAssert.AreEqual(payload, survivor.Payload); + + var hints = reread.LoadCommands.OfType().Single(); + Assert.AreEqual(0x2000u, hints.Offset); + Assert.AreEqual(7u, hints.HintCount); + } + + /// + /// A layout has to be able to move everything in __LINKEDIT, so no command may keep a + /// file offset the walk does not reach. Shifting them all and reading them back catches a + /// missed field, which would otherwise still point at where the data used to be. + /// + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("chainedfixups_arm64")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public void EveryRecordedFileOffsetIsReachable(string name) + { + const uint delta = 0x1000; + + var originals = CollectOffsets(LoadMachO(name)); + Assert.AreNotEqual(0, originals.Count, "the fixture records no file offsets at all"); + + var shiftedFile = LoadMachO(name); + shiftedFile.UpdateFileOffsets(offset => offset + delta); + CollectionAssert.AreEqual(originals.Select(o => o + delta).ToArray(), CollectOffsets(shiftedFile).ToArray()); + + // An identity remap has to leave the image untouched. + var identity = LoadMachO(name); + identity.UpdateFileOffsets(offset => offset); + var stream = new MemoryStream(); + identity.Write(stream); + ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), stream.ToArray(), "an identity remap changed the image"); + } } diff --git a/src/LibObjectFile.Tests/MachO/MachOTestBase.cs b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs index 9777ad4..ac13191 100644 --- a/src/LibObjectFile.Tests/MachO/MachOTestBase.cs +++ b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs @@ -14,4 +14,12 @@ public abstract class MachOTestBase : VerifyBase protected static string GetFile(string name) => Path.Combine(AppContext.BaseDirectory, "MachO", name); protected static MachOFile LoadMachO(string name) => MachOFile.Read(new MemoryStream(File.ReadAllBytes(GetFile(name)))); + + protected static byte[] WriteToArray(MachOFile file) + { + var stream = new MemoryStream(); + file.Write(stream); + return stream.ToArray(); + } + } diff --git a/src/LibObjectFile/MachO/MachOFile.Write.cs b/src/LibObjectFile/MachO/MachOFile.Write.cs new file mode 100644 index 0000000..ec710e1 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Write.cs @@ -0,0 +1,77 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Writes this image to a stream. + /// + /// The stream to write to. + /// is null. + /// + /// The load commands no longer fit in the space before the content after them. See + /// . + /// + public void Write(Stream stream) + { + if (!TryWrite(stream, out var diagnostics)) + { + throw new ObjectFileException($"Unexpected error while writing the Mach-O file", diagnostics); + } + } + + /// + /// Tries to write this image to a stream. + /// + /// The stream to write to. + /// The diagnostics collected while writing. + /// true if the image was written without errors; otherwise false. + /// is null. + public bool TryWrite(Stream stream, out DiagnosticBag diagnostics) + { + ArgumentNullException.ThrowIfNull(stream); + + var writer = new MachOWriter(this, stream, new DiagnosticBag()); + diagnostics = writer.Diagnostics; + Write(writer); + return !diagnostics.HasErrors; + } + + /// + public override void Write(MachOWriter writer) + { + UpdateLayout(writer.VisitorContext); + + if (IsCodeSignatureStale) + { + writer.Diagnostics.Error( + DiagnosticId.MACHO_ERR_StaleCodeSignature, + "The image has been edited since it was signed, so its signature no longer covers what it contains. Sign it again before writing."); + return; + } + + if (LoadCommandsEndOffset > ContentStartOffset) + { + writer.Diagnostics.Error( + DiagnosticId.MACHO_ERR_NoRoomForLoadCommands, + $"The load commands need {LoadCommandsEndOffset} bytes but the content after them starts at {ContentStartOffset}. The image has {AvailableLoadCommandSpace} bytes of space left."); + return; + } + + var basePosition = writer.Position; + + foreach (var content in Content) + { + writer.Position = basePosition + content.Position; + content.WriteContent(writer); + if (writer.Diagnostics.HasErrors) return; + } + } +} From 6753bfcdc4d4696feb78bfff16130a5cef1beeeb Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:25:11 -0400 Subject: [PATCH 05/32] Add Mach-O verification Every other backend implements Verify, twenty-four types between ELF and PE, and Mach-O implemented none of it, so nothing checked the invariant the format rests on: a section's address is its segment's address plus its distance from the segment's file offset. Break that and the loader maps a section somewhere other than where the code expects, which no round-trip test would notice because the bytes still match. Checks that relationship, that sections stay inside their segment, that a section header agrees with the content holding its bytes, that the content list covers the file with no gap or overlap, and that load command sizes are walkable by dyld. Seventy-five real images pass it. - src/LibObjectFile/MachO/MachOFile.Verify.cs: the checks --- .../MachO/MachOSimpleTests.cs | 36 ++++ src/LibObjectFile/MachO/MachOFile.Verify.cs | 165 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/LibObjectFile/MachO/MachOFile.Verify.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index c9fb61e..3f6b76a 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -214,4 +214,40 @@ public void EveryRecordedFileOffsetIsReachable(string name) identity.Write(stream); ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), stream.ToArray(), "an identity remap changed the image"); } + + /// + /// Verification exists to catch the invariants nothing else does. The address check is the + /// one the format rests on: break it and the loader maps a section somewhere other than + /// where the code expects, which no round-trip test would notice. + /// + [TestMethod] + public void VerifyAcceptsRealImagesAndCatchesABrokenOne() + { + foreach (var name in new[] { "helloworld_x86_64", "helloworld_arm64", "unixthread_i386", "helloworld_x86_64.o" }) + { + var diagnostics = new DiagnosticBag(); + LoadMachO(name).Verify(diagnostics); + Assert.IsFalse(diagnostics.HasErrors, $"{name} should verify: {string.Join("; ", diagnostics.Messages)}"); + } + + var moved = LoadMachO("unixthread_i386"); + moved.FindSegment("__TEXT")!.Sections[0].Address += 4; + var broken = new DiagnosticBag(); + moved.Verify(broken); + Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); + + var overlong = LoadMachO("unixthread_i386"); + overlong.LoadCommands[0].Size += 1; + var misaligned = new DiagnosticBag(); + overlong.Verify(misaligned); + Assert.IsTrue(misaligned.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidCommandAlignment)); + + // A header edited on its own describes a section that is not there, and the round-trip + // would still match because the header and the bytes are written from what each holds. + var resized = LoadMachO("unixthread_i386"); + resized.FindSegment("__TEXT")!.Sections[0].Size += 8; + var mismatched = new DiagnosticBag(); + resized.Verify(mismatched); + Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); + } } diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs new file mode 100644 index 0000000..7de63c5 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -0,0 +1,165 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Checks this image for inconsistencies. + /// + /// Receives what was found. + /// is null. + public void Verify(DiagnosticBag diagnostics) + { + ArgumentNullException.ThrowIfNull(diagnostics); + Verify(new MachOVisitorContext(this, diagnostics)); + } + + /// + public override void Verify(MachOVisitorContext context) + { + VerifyContentCoversTheFile(context); + VerifySegments(context); + VerifySectionContent(context); + VerifyLoadCommands(context); + + if (IsCodeSignatureStale) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_StaleCodeSignature, + "The image has been edited since it was signed, so its signature no longer covers what it contains."); + } + } + + /// + /// A section header says where its bytes are and how many there are, while the bytes + /// themselves are a separate piece of content. Nothing keeps the two in step, so a header + /// edited on its own would describe a section that is not there, and the round-trip would + /// still match because both were written from what they each hold. + /// + private void VerifySectionContent(MachOVisitorContext context) + { + var byPosition = new Dictionary(); + foreach (var content in Content) + { + if (content is MachOSectionData data) + { + byPosition[data.Position] = data; + } + } + + foreach (var segment in Segments) + { + foreach (var section in segment.Sections) + { + if (section.IsZeroFill || section.Size == 0) continue; + + if (!byPosition.TryGetValue(section.FileOffset, out var data)) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_SectionContentMismatch, + $"Section {section.SegmentName},{section.Name} says its bytes are at 0x{section.FileOffset:X}, but no content of this image is there"); + continue; + } + + if (!ReferenceEquals(data.Section, section)) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_SectionContentMismatch, + $"The content at 0x{section.FileOffset:X} belongs to {data.Section.SegmentName},{data.Section.Name} rather than to {section.SegmentName},{section.Name}"); + } + else if (data.Size != section.Size) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_SectionContentMismatch, + $"Section {section.SegmentName},{section.Name} says it is 0x{section.Size:X} bytes but its content is 0x{data.Size:X}"); + } + } + } + } + + /// + /// Every byte of the file belongs to exactly one content, so the list has to run from the + /// start of the file with no gap and no overlap. A gap would be bytes nothing writes, and an + /// overlap would be bytes written twice with the later one winning silently. + /// + private void VerifyContentCoversTheFile(MachOVisitorContext context) + { + ulong expected = 0; + foreach (var content in Content) + { + if (content.Position != expected) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_ContentNotContiguous, + $"{content} starts at 0x{content.Position:X} but the content before it ends at 0x{expected:X}"); + return; + } + + expected = content.Position + content.Size; + } + } + + private void VerifySegments(MachOVisitorContext context) + { + foreach (var segment in Segments) + { + if (segment.Is64Bit != Is64Bit) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + $"Segment {segment.Name} is {(segment.Is64Bit ? "64" : "32")}-bit in a {(Is64Bit ? "64" : "32")}-bit image"); + } + + foreach (var section in segment.Sections) + { + if (section.IsZeroFill || section.Size == 0) continue; + + // This is the invariant the whole format rests on: a section's address is its + // segment's address plus its distance from the segment's file offset. Break it + // and the loader maps the section somewhere other than where the code expects. + if (section.Address - segment.VmAddress != section.FileOffset - segment.FileOffset) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_SectionAddressMismatch, + $"Section {section.SegmentName},{section.Name} is at address 0x{section.Address:X} and file offset 0x{section.FileOffset:X}, which do not agree with segment {segment.Name} at address 0x{segment.VmAddress:X} and file offset 0x{segment.FileOffset:X}"); + } + + if (section.FileOffset < segment.FileOffset || section.FileOffset + section.Size > segment.FileEndOffset) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_SectionOutsideSegment, + $"Section {section.SegmentName},{section.Name} spans [0x{section.FileOffset:X}, 0x{section.FileOffset + section.Size:X}) which is outside segment {segment.Name} at [0x{segment.FileOffset:X}, 0x{segment.FileEndOffset:X})"); + } + } + } + } + + private void VerifyLoadCommands(MachOVisitorContext context) + { + var alignment = MachOLoadCommand.GetSizeAlignment(Is64Bit); + + foreach (var command in LoadCommands) + { + if (command.Size < 8 || command.Size % alignment != 0) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidCommandAlignment, + $"{command} has a size of {command.Size}, which must be at least 8 and a multiple of {alignment} so that dyld can walk the table"); + } + } + + if (AvailableLoadCommandSpace < 0) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_NoRoomForLoadCommands, + $"The load commands end at {LoadCommandsEndOffset} but the content after them starts at {ContentStartOffset}"); + } + } +} From a316249f91453a982381cb2d1956aa668784c122 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:25:32 -0400 Subject: [PATCH 06/32] Add address-preserving load command injection to Mach-O There was no way to add a dependency or a runpath to an existing Mach-O image, which is what makes a shipped binary load a library it was not linked against. Adds the operations install_name_tool offers, appending commands into the padding the linker left ahead of the first section so no content moves and every address in the image stays valid. A dependency is appended rather than inserted, because dyld identifies a library by its position among the load commands and the symbol table binds against that number. When the padding runs out the edit throws and names the shortfall, since the alternative is moving content and invalidating the image. Removing a dependency is deliberately not offered for the same numbering reason, matching what install_name_tool exposes. - src/LibObjectFile/MachO/MachOFile.Edit.cs: the add, change and remove operations and the space check - src/LibObjectFile.Tests/MachO/MachOEditingTests.cs: compares against the install_name_tool reference fixture, and asserts no section moves --- .../MachO/MachOEditingTests.cs | 143 ++++++++++++ .../MachO/MachOSimpleTests.cs | 33 +++ src/LibObjectFile/MachO/MachOFile.Edit.cs | 204 ++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 src/LibObjectFile.Tests/MachO/MachOEditingTests.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.Edit.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs new file mode 100644 index 0000000..37a12ed --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; +using System.Linq; +using LibObjectFile.MachO; + +namespace LibObjectFile.Tests.MachO; + +[TestClass] +public class MachOEditingTests : MachOTestBase +{ + /// + /// The reference fixture is the same input run through Apple's install_name_tool, so + /// this pins the encoding against the tool the platform actually ships rather than against + /// this library's own idea of what a runpath command looks like. + /// + [TestMethod] + public void AddRPathMatchesInstallNameTool() + { + var file = LoadMachO("unixthread_i386"); + file.AddRPath("@executable_path/../Frameworks"); + + var expected = File.ReadAllBytes(GetFile("unixthread_i386_rpath")); + ByteArrayAssert.AreEqual(expected, WriteToArray(file), "Adding a runpath does not match install_name_tool"); + } + + /// + /// The whole point of editing in place is that nothing moves. A section that shifted would + /// invalidate every address and relocation already baked into the image. + /// + [TestMethod] + public void AddingCommandsLeavesEverySectionWhereItWas() + { + var before = LoadMachO("unixthread_i386"); + var originalLength = new FileInfo(GetFile("unixthread_i386")).Length; + var originalOffsets = before.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray(); + + var file = LoadMachO("unixthread_i386"); + file.AddLoadDylib("@executable_path/../Frameworks/injected.dylib"); + file.AddRPath("@executable_path/../Frameworks"); + var written = WriteToArray(file); + + Assert.AreEqual(originalLength, written.Length, "the file changed size"); + + var reread = MachOFile.Read(new MemoryStream(written)); + var newOffsets = reread.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray(); + CollectionAssert.AreEqual(originalOffsets, newOffsets, "a section moved"); + + foreach (var segment in reread.Segments) + { + var original = before.FindSegment(segment.Name); + Assert.IsNotNull(original); + Assert.AreEqual(original.FileOffset, segment.FileOffset, $"segment {segment.Name} moved"); + Assert.AreEqual(original.VmAddress, segment.VmAddress, $"segment {segment.Name} was remapped"); + } + } + + /// + /// dyld refers to a library by its position in the command list, so a new dependency has to + /// go last or every existing binding would point at the wrong library. + /// + [TestMethod] + public void AddLoadDylibAppendsAfterTheExistingLibraries() + { + var file = LoadMachO("unixthread_i386"); + var before = file.LinkedLibraries.Select(d => d.Name).ToArray(); + + file.AddLoadDylib("@rpath/injected.dylib"); + + var after = file.LinkedLibraries.Select(d => d.Name).ToArray(); + CollectionAssert.AreEqual(before, after.Take(before.Length).ToArray(), "existing libraries were renumbered"); + Assert.AreEqual("@rpath/injected.dylib", after[^1]); + Assert.AreSame(file.LoadCommands[^1], file.LinkedLibraries.Last()); + + // A weak dependency and a runpath added alongside have to survive being written out. + file.AddLoadDylib("@rpath/weak.dylib", MachOLoadCommandType.LoadWeakDylib); + file.AddRPath("@loader_path/../lib"); + var reread = MachOFile.Read(new MemoryStream(WriteToArray(file))); + Assert.IsTrue(reread.LinkedLibraries.Single(d => d.Name == "@rpath/weak.dylib").IsWeak); + Assert.AreEqual("@loader_path/../lib", reread.RunPaths.Single().Path); + } + + /// + /// Once the padding runs out the only way to continue would be to move content, so the edit + /// has to fail rather than silently invalidate the image. + /// + [TestMethod] + public void AddingBeyondTheAvailableSpaceThrows() + { + var file = LoadMachO("unixthread_i386"); + var space = file.AvailableLoadCommandSpace; + + // Each of these costs 12 bytes of header plus the padded path. + var path = new string('a', 100); + while (file.AvailableLoadCommandSpace >= 120) + { + file.AddRPath("/" + path + file.LoadCommands.Count); + } + + Assert.IsTrue(file.AvailableLoadCommandSpace < space); + var exception = Assert.ThrowsExactly(() => file.AddRPath("/" + path)); + StringAssert.Contains(exception.Message, "free before the first section"); + } + + [TestMethod] + public void ChangeDylibNameRepointsTheReference() + { + var file = LoadMachO("unixthread_i386"); + + Assert.AreEqual(1, file.ChangeDylibName("/usr/lib/libstdc++.6.dylib", "@rpath/libstdc++.6.dylib")); + Assert.AreEqual(0, file.ChangeDylibName("/nothing/here.dylib", "/other.dylib")); + + var reread = MachOFile.Read(new MemoryStream(WriteToArray(file))); + CollectionAssert.Contains(reread.LinkedLibraries.Select(d => d.Name).ToArray(), "@rpath/libstdc++.6.dylib"); + CollectionAssert.DoesNotContain(reread.LinkedLibraries.Select(d => d.Name).ToArray(), "/usr/lib/libstdc++.6.dylib"); + + // The same rewrite applied to a dylib's own name, which lives in a different command. + var dylib = LoadMachO("libhelloworld_x86_64.dylib"); + Assert.AreEqual("/usr/local/lib/libhelloworld.dylib", dylib.IdDylib!.Name); + dylib.SetInstallName("@rpath/libhelloworld.dylib"); + Assert.AreEqual("@rpath/libhelloworld.dylib", MachOFile.Read(new MemoryStream(WriteToArray(dylib))).IdDylib!.Name); + + // An executable has no install name to rewrite. + Assert.ThrowsExactly(() => LoadMachO("unixthread_i386").SetInstallName("@rpath/whatever.dylib")); + } + + [TestMethod] + public void RemoveRPathDropsOnlyTheMatchingRunPath() + { + var file = LoadMachO("unixthread_i386"); + file.AddRPath("/one"); + file.AddRPath("/two"); + + Assert.IsTrue(file.RemoveRPath("/one")); + Assert.IsFalse(file.RemoveRPath("/one")); + + var reread = MachOFile.Read(new MemoryStream(WriteToArray(file))); + CollectionAssert.AreEqual(new[] { "/two" }, reread.RunPaths.Select(r => r.Path).ToArray()); + } +} diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 3f6b76a..ad71403 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -250,4 +250,37 @@ public void VerifyAcceptsRealImagesAndCatchesABrokenOne() resized.Verify(mismatched); Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); } + + /// + /// The padding after the load command table is the only elastic thing in the file, so it has + /// to absorb exactly what the table gains and nothing else may shift. This is what an + /// install_name_tool-style edit depends on. + /// + [TestMethod] + public void GrowingTheCommandTableConsumesOnlyThePadding() + { + var file = LoadMachO("unixthread_i386"); + + var paddingBefore = file.LoadCommandPadding!.Size; + var tableEndBefore = file.LoadCommandsEndOffset; + var contentStart = file.ContentStartOffset; + var positionsBefore = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + + var added = file.AddRPath("@executable_path/../Frameworks"); + var image = WriteToArray(file); + + Assert.AreEqual(tableEndBefore + added.Size, file.LoadCommandsEndOffset); + Assert.AreEqual(paddingBefore - added.Size, file.LoadCommandPadding!.Size, "the padding did not absorb the new command"); + Assert.AreEqual(contentStart, file.ContentStartOffset, "content after the padding moved"); + + // Only the padding may have moved; everything after it stays exactly where it was. + var positionsAfter = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + for (var i = 0; i < positionsBefore.Length; i++) + { + if (positionsBefore[i].Name == nameof(MachOLoadCommandPadding)) continue; + Assert.AreEqual(positionsBefore[i], positionsAfter[i], $"content {i} moved"); + } + + Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); + } } diff --git a/src/LibObjectFile/MachO/MachOFile.Edit.cs b/src/LibObjectFile/MachO/MachOFile.Edit.cs new file mode 100644 index 0000000..95804eb --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Edit.cs @@ -0,0 +1,204 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Adds a dependency on , so dyld loads that library at startup. + /// + /// + /// The install name of the library. It is resolved by dyld rather than opened as a path, so + /// it may start with @rpath, @executable_path or @loader_path. + /// + /// + /// Which dylib command to add. makes the + /// library optional at runtime. + /// + /// The command that was added. + /// + /// The command is appended, never inserted, because dyld numbers libraries by their command + /// order and every binding in the symbol table refers to a library by that number. Appending + /// leaves the existing numbering alone. + /// + /// is null. + /// is empty, or is not a dylib command. + /// + /// The image has no room left before its first section. The message states the shortfall. + /// + public MachODylibCommand AddLoadDylib(string name, MachOLoadCommandType type = MachOLoadCommandType.LoadDylib) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + if (type is not (MachOLoadCommandType.LoadDylib + or MachOLoadCommandType.LoadWeakDylib + or MachOLoadCommandType.ReexportDylib + or MachOLoadCommandType.LoadUpwardDylib + or MachOLoadCommandType.LazyLoadDylib)) + { + throw new ArgumentException($"{type} is not a dylib load command", nameof(type)); + } + + var command = new MachODylibCommand + { + Type = type, + Is64Bit = Is64Bit, + Name = name, + Timestamp = 0, + // A dependency the loader has no version expectations of. dyld only compares these + // against the target library's own values, and 1.0.0 always satisfies the comparison. + CurrentVersion = 0x10000, + CompatibilityVersion = 0x10000, + }; + + AppendCommand(command); + return command; + } + + /// + /// Adds a runpath that @rpath expands to when resolving dependencies. + /// + /// The directory to add, commonly relative to @executable_path. + /// The command that was added. + /// is null. + /// is empty. + /// + /// The image has no room left before its first section. The message states the shortfall. + /// + public MachOPathCommand AddRPath(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + var command = new MachOPathCommand + { + Type = MachOLoadCommandType.RPath, + Is64Bit = Is64Bit, + Path = path, + }; + + AppendCommand(command); + return command; + } + + /// + /// Removes a runpath. + /// + /// The runpath to remove. + /// true if a matching runpath was removed; otherwise false. + /// + /// Unlike the dylib commands, runpaths are unnumbered, so dropping one changes nothing but + /// the search order. + /// + /// is null. + public bool RemoveRPath(string path) + { + ArgumentNullException.ThrowIfNull(path); + + var command = RunPaths.FirstOrDefault(c => c.Path == path); + if (command is null) return false; + + LoadCommands.Remove(command); + MarkCodeSignatureStale(); + return true; + } + + /// + /// Repoints every reference to at , + /// which is what install_name_tool -change does. + /// + /// The install name currently referenced. + /// The install name to reference instead. + /// The number of commands that were repointed. + /// or is null. + /// is empty. + /// + /// The longer name does not fit in the room left before the first section. + /// + public int ChangeDylibName(string oldName, string newName) + { + ArgumentNullException.ThrowIfNull(oldName); + ArgumentException.ThrowIfNullOrEmpty(newName); + + var matches = LoadCommands.OfType().Where(c => c.Name == oldName).ToArray(); + if (matches.Length == 0) return 0; + + long extra = 0; + foreach (var command in matches) + { + command.Name = newName; + extra += Math.Max(0, (long)command.MinimumSize - (long)command.Size); + } + + EnsureLoadCommandSpace(extra); + foreach (var command in matches) + { + command.Size = Math.Max(command.Size, command.MinimumSize); + } + + MarkCodeSignatureStale(); + return matches.Length; + } + + /// + /// Sets this image's own install name, which is what install_name_tool -id does. + /// + /// The install name to record. + /// is null. + /// is empty. + /// + /// This image is not a dylib and so has no install name, or the longer name does not fit. + /// + public void SetInstallName(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var command = IdDylib + ?? throw new InvalidOperationException("This image has no LC_ID_DYLIB command, so it is not a dylib and has no install name."); + + command.Name = name; + EnsureLoadCommandSpace(Math.Max(0, (long)command.MinimumSize - (long)command.Size)); + command.Size = Math.Max(command.Size, command.MinimumSize); + MarkCodeSignatureStale(); + } + + private void AppendCommand(MachOLoadCommand command) + { + MarkCodeSignatureStale(); + + command.Size = command is MachOPathLoadCommand path ? path.MinimumSize : command.Size; + EnsureLoadCommandSpace((long)command.Size); + + LoadCommands.Add(command); + } + + /// + /// Records that the image no longer matches its signature, if it has one. + /// + private void MarkCodeSignatureStale() + { + if (CodeSignature is not null) + { + IsCodeSignatureStale = true; + } + } + + /// + /// Checks that the command table can grow by without + /// pushing into the first section. + /// + private void EnsureLoadCommandSpace(long additionalBytes) + { + if (additionalBytes <= AvailableLoadCommandSpace) return; + + throw new InvalidOperationException( + $"The load commands need {additionalBytes} more bytes but only {AvailableLoadCommandSpace} are free before the first section at 0x{ContentStartOffset:X}. " + + "Making room would mean moving content, which would invalidate the addresses in this image."); + } + +} From 9f3da5981d42f41e0746e30c7b04085e9c98d2a9 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:25:52 -0400 Subject: [PATCH 07/32] Add ad-hoc code signing for Mach-O images Apple Silicon refuses to execute an unsigned image, and any edit invalidates an existing signature, so an arm64 binary could be modified by this library only to become unrunnable. Adds ad-hoc signing: a code directory of SHA-256 page digests plus an empty requirement set, appended to __LINKEDIT with the load command added when the image was previously unsigned. The signature covers exactly the bytes preceding it, so its size is derived from the identifier and the signed length before any digest is taken, and the image is laid out once and then hashed as it will finally be written. Signing has to be the last thing done, since editing afterwards leaves the digests covering bytes that are no longer there. The editing operations record that, and writing fails while it is set, so the library cannot hand back a file that looks signed and would be refused at execution. - src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs: builds the superblob, which is big-endian throughout unlike the rest of the format - src/LibObjectFile/MachO/MachOFile.Sign.cs: appends the signature as content and grows __LINKEDIT to cover it - src/LibObjectFile.Tests/MachO/MachOSigningTests.cs: recomputes every page digest from the written image, which is the property the kernel checks --- .../MachO/MachOSigningTests.cs | 224 ++++++++++++++++++ .../CodeSign/MachOAdHocSignatureBuilder.cs | 214 +++++++++++++++++ .../CodeSign/MachOCodeSignatureConstants.cs | 66 ++++++ src/LibObjectFile/MachO/MachOFile.Sign.cs | 102 ++++++++ 4 files changed, 606 insertions(+) create mode 100644 src/LibObjectFile.Tests/MachO/MachOSigningTests.cs create mode 100644 src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs create mode 100644 src/LibObjectFile/MachO/CodeSign/MachOCodeSignatureConstants.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.Sign.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs new file mode 100644 index 0000000..0938cc9 --- /dev/null +++ b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs @@ -0,0 +1,224 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Buffers.Binary; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using LibObjectFile.MachO; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.CodeSign; + +namespace LibObjectFile.Tests.MachO; + +using static MachOCodeSignatureConstants; + +[TestClass] +public class MachOSigningTests : MachOTestBase +{ + private static byte[] SignAndWrite(string fixture, string identifier) + { + var file = LoadMachO(fixture); + file.AdHocSign(identifier); + var stream = new MemoryStream(); + file.Write(stream); + return stream.ToArray(); + } + + /// + /// Recomputes every page digest from the written image and compares it with what the code + /// directory claims. This is the property the kernel checks, so getting it wrong produces a + /// file that looks signed and is refused at execution. + /// + [TestMethod] + [DataRow("unixthread_i386")] + [DataRow("helloworld_x86_64")] + [DataRow("helloworld_arm64")] + [DataRow("libhelloworld_x86_64.dylib")] + public void EveryPageDigestMatchesTheImage(string fixture) + { + var image = SignAndWrite(fixture, fixture); + var file = MachOFile.Read(new MemoryStream(image)); + + var command = file.CodeSignature; + Assert.IsNotNull(command); + + var codeDirectory = FindCodeDirectory(image, command.DataOffset); + var hashOffset = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16)); + var codeSlots = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(28)); + var codeLimit = BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(32)); + + Assert.AreEqual(command.DataOffset, codeLimit, "the signature must cover exactly the bytes before it"); + Assert.AreEqual((codeLimit + PageSize - 1) / PageSize, codeSlots); + + for (var slot = 0; slot < codeSlots; slot++) + { + var start = slot * PageSize; + var length = Math.Min(PageSize, (int)codeLimit - start); + var expected = SHA256.HashData(image.AsSpan(start, length)); + var stored = codeDirectory.Slice((int)hashOffset + slot * Sha256Size, Sha256Size); + CollectionAssert.AreEqual(expected, stored.ToArray(), $"page {slot} digest does not match"); + } + } + + [TestMethod] + public void SignatureUsesTheAdHocCodeDirectoryFormat() + { + var image = SignAndWrite("unixthread_i386", "swkotor"); + var file = MachOFile.Read(new MemoryStream(image)); + var command = file.CodeSignature!; + + var superBlob = image.AsSpan((int)command.DataOffset); + Assert.AreEqual(EmbeddedSignatureMagic, BinaryPrimitives.ReadUInt32BigEndian(superBlob)); + Assert.AreEqual(3u, BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8)), "code directory, requirements and CMS slots"); + + var codeDirectory = FindCodeDirectory(image, command.DataOffset); + Assert.AreEqual(CodeDirectoryMagic, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory)); + Assert.AreEqual(CodeDirectoryVersionExecSeg, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(8))); + Assert.AreEqual(AdHocFlag, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(12))); + Assert.AreEqual((uint)CodeDirectoryHeaderSize, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(20)), "identifier follows the fixed header"); + Assert.AreEqual(2u, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(24)), "Info and requirements special slots"); + Assert.AreEqual(Sha256Size, codeDirectory[36]); + Assert.AreEqual(HashTypeSha256, codeDirectory[37]); + Assert.AreEqual(PageSizeLog2, codeDirectory[39]); + + var identifier = codeDirectory.Slice(CodeDirectoryHeaderSize); + Assert.AreEqual("swkotor", Encoding.UTF8.GetString(identifier.Slice(0, identifier.IndexOf((byte)0)))); + + // The executable range comes from __TEXT, and an executable is flagged as a main binary. + var text = file.FindSegment("__TEXT")!; + Assert.AreEqual(text.FileOffset, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(64))); + Assert.AreEqual(text.FileSize, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(72))); + Assert.AreEqual(ExecSegMainBinary, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(80))); + + var hashOffset = (int)BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16)); + + var info = codeDirectory.Slice(hashOffset - Sha256Size, Sha256Size); + Assert.IsTrue(info.ToArray().All(b => b == 0), "the Info.plist slot should be zero"); + + var requirements = FindBlob(image, command.DataOffset, RequirementsSlot); + Assert.AreEqual(RequirementsMagic, BinaryPrimitives.ReadUInt32BigEndian(requirements)); + Assert.AreEqual(0u, BinaryPrimitives.ReadUInt32BigEndian(requirements.Slice(8)), "an ad-hoc signature states no requirements"); + + var stored = codeDirectory.Slice(hashOffset - 2 * Sha256Size, Sha256Size); + CollectionAssert.AreEqual(SHA256.HashData(requirements.ToArray()), stored.ToArray()); + } + + /// + /// Re-signing has to replace the previous signature rather than stack a new one behind it, + /// otherwise repeated edits would grow the file without bound. + /// + [TestMethod] + public void ReSigningReplacesTheExistingSignature() + { + var file = LoadMachO("helloworld_arm64"); + Assert.IsNotNull(file.CodeSignature); + var commands = file.LoadCommands.Count; + + file.AdHocSign("helloworld"); + var first = new MemoryStream(); + file.Write(first); + + var again = MachOFile.Read(new MemoryStream(first.ToArray())); + again.AdHocSign("helloworld"); + var second = new MemoryStream(); + again.Write(second); + + Assert.AreEqual(commands, again.LoadCommands.Count, "re-signing added a load command"); + Assert.AreEqual(first.Length, second.Length, "re-signing grew the file"); + ByteArrayAssert.AreEqual(first.ToArray(), second.ToArray(), "re-signing is not idempotent"); + } + + [TestMethod] + public void SignedImagesRoundTrip() + { + var image = SignAndWrite("unixthread_i386", "swkotor"); + + var reread = MachOFile.Read(new MemoryStream(image)); + var again = new MemoryStream(); + reread.Write(again); + + ByteArrayAssert.AreEqual(image, again.ToArray(), "a signed image does not round-trip"); + } + + /// + /// Signing appends to __LINKEDIT, so it must not disturb anything mapped before it. + /// + [TestMethod] + public void SigningLeavesEverySectionWhereItWas() + { + var before = LoadMachO("unixthread_i386"); + var originalOffsets = before.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray(); + + var image = SignAndWrite("unixthread_i386", "swkotor"); + var after = MachOFile.Read(new MemoryStream(image)); + + CollectionAssert.AreEqual(originalOffsets, after.Segments.SelectMany(s => s.Sections).Select(s => (s.Name, s.FileOffset)).ToArray()); + + var linkEdit = after.FindSegment("__LINKEDIT")!; + Assert.AreEqual(before.FindSegment("__LINKEDIT")!.FileOffset, linkEdit.FileOffset, "__LINKEDIT moved"); + Assert.IsTrue(linkEdit.FileSize > before.FindSegment("__LINKEDIT")!.FileSize, "__LINKEDIT did not grow to cover the signature"); + Assert.AreEqual(linkEdit.FileEndOffset, after.CodeSignature!.DataOffset + after.CodeSignature.DataSize); + + // The image was unsigned, so signing had to add the command as well as the signature. + Assert.IsNull(before.CodeSignature); + Assert.AreEqual(before.LoadCommands.Count + 1, after.LoadCommands.Count); + Assert.AreEqual(0u, after.CodeSignature.DataOffset % SignatureAlignment, "the signature must be 16-byte aligned"); + } + + /// + /// Editing after signing leaves the signature covering bytes that are no longer there. The + /// result would look signed and be refused at execution, so writing it has to fail rather + /// than hand back something broken. + /// + [TestMethod] + public void EditingAfterSigningIsRejectedUntilSignedAgain() + { + var file = LoadMachO("unixthread_i386"); + file.AdHocSign("swkotor"); + Assert.IsFalse(file.IsCodeSignatureStale); + + file.AddRPath("@executable_path/../Frameworks"); + Assert.IsTrue(file.IsCodeSignatureStale); + + var exception = Assert.ThrowsExactly(() => WriteToArray(file)); + Assert.IsTrue(exception.Diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_StaleCodeSignature)); + + // Signing again makes it whole, and the digests then cover the edit. + file.AdHocSign("swkotor"); + Assert.IsFalse(file.IsCodeSignatureStale); + var image = WriteToArray(file); + Assert.AreEqual("@executable_path/../Frameworks", MachOFile.Read(new MemoryStream(image)).RunPaths.Single().Path); + } + + [TestMethod] + public void RejectsWhatCannotBeSigned() + { + // An object file has no __LINKEDIT, so there is nowhere for a signature to live. + Assert.ThrowsExactly(() => LoadMachO("helloworld_x86_64.o").AdHocSign("helloworld")); + Assert.ThrowsExactly(() => LoadMachO("unixthread_i386").AdHocSign(string.Empty)); + } + + private static Span FindCodeDirectory(byte[] image, uint signatureOffset) + => FindBlob(image, signatureOffset, CodeDirectorySlot); + + private static Span FindBlob(byte[] image, uint signatureOffset, uint slot) + { + var superBlob = image.AsSpan((int)signatureOffset); + var count = BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8)); + for (var i = 0; i < count; i++) + { + var entry = superBlob.Slice(12 + i * 8); + if (BinaryPrimitives.ReadUInt32BigEndian(entry) != slot) continue; + + var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(4)); + var length = (int)BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(offset + 4)); + return superBlob.Slice(offset, length); + } + + throw new InvalidOperationException($"The signature has no blob in slot {slot}"); + } +} diff --git a/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs new file mode 100644 index 0000000..cb468d2 --- /dev/null +++ b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs @@ -0,0 +1,214 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using LibObjectFile.Utils; + +namespace LibObjectFile.MachO.CodeSign; + +using static MachOCodeSignatureConstants; + +/// +/// Builds an ad-hoc embedded code signature. +/// +/// +/// An ad-hoc signature carries no certificate. It states only that the image hashes to what the +/// code directory says, which is what lets the kernel give the process a stable identity. Apple +/// Silicon refuses to execute an unsigned image at all, so this is a requirement there rather +/// than a hardening measure. +/// +public sealed class MachOAdHocSignatureBuilder +{ + private readonly byte[] _identifier; + + /// + /// Initializes a builder for an image identified by . + /// + /// + /// The signing identity recorded in the code directory. Apple's tooling uses the file name + /// of the binary, and any process asking for this image's identity gets this string back. + /// + /// is null. + /// is empty. + public MachOAdHocSignatureBuilder(string identifier) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + Identifier = identifier; + var bytes = Encoding.UTF8.GetBytes(identifier); + _identifier = new byte[bytes.Length + 1]; + bytes.CopyTo(_identifier, 0); + } + + /// + /// Gets the signing identity recorded in the code directory. + /// + public string Identifier { get; } + + /// + /// Gets or sets the number of bytes of the image covered by the signature, which is the file + /// offset the signature itself starts at. + /// + public uint CodeLimit { get; set; } + + /// + /// Gets or sets the file offset of the executable segment, normally __TEXT. + /// + public ulong ExecSegmentBase { get; set; } + + /// + /// Gets or sets the file size of the executable segment. + /// + public ulong ExecSegmentLimit { get; set; } + + /// + /// Gets or sets the executable segment flags, which mark whether the image is a main binary. + /// + public ulong ExecSegmentFlags { get; set; } + + /// + /// Gets the number of page hashes the code directory will hold for the current . + /// + public uint CodeSlotCount => (CodeLimit + PageSize - 1) / PageSize; + + /// + /// Gets the total size of the signature for the current and identifier. + /// + /// + /// The size depends only on those two, so a caller can reserve room for the signature and + /// lay out the image before any hash has been computed. + /// + public uint ComputeSize() + { + var total = SuperBlobSize + CodeDirectorySize + RequirementsSize + BlobWrapperSize; + return AlignHelper.AlignUp((uint)total, (uint)SignatureAlignment); + } + + // Header plus one index entry for each of the code directory, requirements and CMS slots. + private const int SuperBlobSize = 12 + 3 * 8; + private const int RequirementsSize = 12; + private const int BlobWrapperSize = 8; + + // Two special slots are described: the Info.plist hash and the requirements hash. + private const int SpecialSlotCount = 2; + + private int HashOffset => CodeDirectoryHeaderSize + _identifier.Length + SpecialSlotCount * Sha256Size; + + private int CodeDirectorySize => HashOffset + (int)CodeSlotCount * Sha256Size; + + /// + /// Produces the signature for an image whose first bytes are + /// . + /// + /// + /// The image content up to . The signature is not part of what it + /// covers, so this excludes the signature itself. + /// + /// The signature, of exactly bytes. + /// is shorter than . + public byte[] Build(ReadOnlySpan image) + { + if (image.Length < CodeLimit) + { + throw new ArgumentException($"The image is {image.Length} bytes but the signature covers {CodeLimit}", nameof(image)); + } + + var result = new byte[ComputeSize()]; + var span = result.AsSpan(); + + var codeDirectoryOffset = SuperBlobSize; + var requirementsOffset = codeDirectoryOffset + CodeDirectorySize; + var blobWrapperOffset = requirementsOffset + RequirementsSize; + + WriteRequirements(span.Slice(requirementsOffset, RequirementsSize)); + WriteBlobWrapper(span.Slice(blobWrapperOffset, BlobWrapperSize)); + WriteCodeDirectory(span.Slice(codeDirectoryOffset, CodeDirectorySize), image, span.Slice(requirementsOffset, RequirementsSize)); + WriteSuperBlob(span, codeDirectoryOffset, requirementsOffset, blobWrapperOffset, result.Length); + + return result; + } + + private static void WriteSuperBlob(Span span, int codeDirectoryOffset, int requirementsOffset, int blobWrapperOffset, int totalLength) + { + BinaryPrimitives.WriteUInt32BigEndian(span, EmbeddedSignatureMagic); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(4), (uint)totalLength); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(8), 3); + + WriteIndexEntry(span.Slice(12), CodeDirectorySlot, codeDirectoryOffset); + WriteIndexEntry(span.Slice(20), RequirementsSlot, requirementsOffset); + WriteIndexEntry(span.Slice(28), SignatureSlot, blobWrapperOffset); + } + + private static void WriteIndexEntry(Span span, uint slot, int offset) + { + BinaryPrimitives.WriteUInt32BigEndian(span, slot); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(4), (uint)offset); + } + + /// + /// Writes an empty requirement set. Ad-hoc signing states no requirements, but the slot is + /// still present and hashed, because its absence is not the same as it being empty. + /// + private static void WriteRequirements(Span span) + { + BinaryPrimitives.WriteUInt32BigEndian(span, RequirementsMagic); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(4), RequirementsSize); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(8), 0); + } + + /// + /// Writes the empty wrapper that would hold a CMS signature. It stays empty for an ad-hoc + /// signature, which is exactly what marks the signature as ad-hoc to a verifier. + /// + private static void WriteBlobWrapper(Span span) + { + BinaryPrimitives.WriteUInt32BigEndian(span, BlobWrapperMagic); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(4), BlobWrapperSize); + } + + private void WriteCodeDirectory(Span span, ReadOnlySpan image, ReadOnlySpan requirements) + { + BinaryPrimitives.WriteUInt32BigEndian(span, CodeDirectoryMagic); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(4), (uint)CodeDirectorySize); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(8), CodeDirectoryVersionExecSeg); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(12), AdHocFlag); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(16), (uint)HashOffset); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(20), CodeDirectoryHeaderSize); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(24), SpecialSlotCount); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(28), CodeSlotCount); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(32), CodeLimit); + span[36] = Sha256Size; + span[37] = HashTypeSha256; + span[38] = 0; + span[39] = PageSizeLog2; + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(40), 0); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(44), 0); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(48), 0); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(52), 0); + BinaryPrimitives.WriteUInt64BigEndian(span.Slice(56), 0); + BinaryPrimitives.WriteUInt64BigEndian(span.Slice(64), ExecSegmentBase); + BinaryPrimitives.WriteUInt64BigEndian(span.Slice(72), ExecSegmentLimit); + BinaryPrimitives.WriteUInt64BigEndian(span.Slice(80), ExecSegmentFlags); + + _identifier.CopyTo(span.Slice(CodeDirectoryHeaderSize)); + + // Special slots are indexed backwards from the code hashes, so slot n sits n hashes + // before the start of the code hashes. Slot 1 is the Info.plist, which a bare executable + // does not have, and a slot with nothing in it is recorded as zero rather than omitted. + var hashOffset = HashOffset; + span.Slice(hashOffset - SpecialSlotCount * Sha256Size, SpecialSlotCount * Sha256Size).Clear(); + SHA256.HashData(requirements, span.Slice(hashOffset - (int)RequirementsSlot * Sha256Size, Sha256Size)); + + for (var slot = 0; slot < CodeSlotCount; slot++) + { + var start = slot * PageSize; + var length = Math.Min(PageSize, (int)CodeLimit - start); + SHA256.HashData(image.Slice(start, length), span.Slice(hashOffset + slot * Sha256Size, Sha256Size)); + } + } + +} diff --git a/src/LibObjectFile/MachO/CodeSign/MachOCodeSignatureConstants.cs b/src/LibObjectFile/MachO/CodeSign/MachOCodeSignatureConstants.cs new file mode 100644 index 0000000..86c8f8a --- /dev/null +++ b/src/LibObjectFile/MachO/CodeSign/MachOCodeSignatureConstants.cs @@ -0,0 +1,66 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO.CodeSign; + +/// +/// Constants of the embedded code signature format. +/// +/// +/// Every structure in a code signature is stored big-endian, unlike the rest of a Mach-O image, +/// because the format predates Apple's move to little-endian hardware and was never changed. +/// +public static class MachOCodeSignatureConstants +{ + /// Magic of the outer blob holding all the others (CSMAGIC_EMBEDDED_SIGNATURE). + public const uint EmbeddedSignatureMagic = 0xfade0cc0; + + /// Magic of a code directory (CSMAGIC_CODEDIRECTORY). + public const uint CodeDirectoryMagic = 0xfade0c02; + + /// Magic of a requirement set (CSMAGIC_REQUIREMENTS). + public const uint RequirementsMagic = 0xfade0c01; + + /// Magic of the blob wrapping a CMS signature (CSMAGIC_BLOBWRAPPER). + public const uint BlobWrapperMagic = 0xfade0b01; + + /// Slot of the code directory (CSSLOT_CODEDIRECTORY). + public const uint CodeDirectorySlot = 0; + + /// Slot of the bundle's Info.plist hash (CSSLOT_INFOSLOT). + public const uint InfoSlot = 1; + + /// Slot of the requirement set (CSSLOT_REQUIREMENTS). + public const uint RequirementsSlot = 2; + + /// Slot of the CMS signature (CSSLOT_SIGNATURESLOT). + public const uint SignatureSlot = 0x10000; + + /// Code directory version understanding the executable segment fields (CS_SUPPORTSEXECSEG). + public const uint CodeDirectoryVersionExecSeg = 0x20400; + + /// Size of the fixed part of a code directory. + public const int CodeDirectoryHeaderSize = 88; + + /// The signature carries no CMS signer and is trusted only by its own hashes (CS_ADHOC). + public const uint AdHocFlag = 0x0002; + + /// SHA-256 code hashes (CS_HASHTYPE_SHA256). + public const byte HashTypeSha256 = 2; + + /// Size in bytes of a SHA-256 hash. + public const int Sha256Size = 32; + + /// The signed pages are 4 KB, stored as the log2 of the size. + public const byte PageSizeLog2 = 12; + + /// Size of a signed page, derived from . + public const int PageSize = 1 << PageSizeLog2; + + /// The executable segment belongs to a main binary rather than a library (CS_EXECSEG_MAIN_BINARY). + public const ulong ExecSegMainBinary = 0x1; + + /// Alignment the signature blob starts at inside __LINKEDIT. + public const int SignatureAlignment = 16; +} diff --git a/src/LibObjectFile/MachO/MachOFile.Sign.cs b/src/LibObjectFile/MachO/MachOFile.Sign.cs new file mode 100644 index 0000000..662bbea --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Sign.cs @@ -0,0 +1,102 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; +using System.Linq; +using LibObjectFile.MachO.CodeSign; +using LibObjectFile.Utils; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Replaces this image's code signature with an ad-hoc one, adding the signature and its load + /// command if the image is unsigned. + /// + /// + /// The signing identity to record. Apple's tooling uses the binary's file name, and this is + /// the string the kernel reports as the code's identity. + /// + /// + /// An ad-hoc signature carries no certificate; it asserts only that the image matches its own + /// hashes. Apple Silicon refuses to execute an unsigned image, so an edited arm64 binary has + /// to be re-signed to stay runnable. Any change to the image invalidates the signature, so + /// this has to be the last thing done before writing. + /// + /// is null. + /// is empty. + /// + /// The image has no __LINKEDIT or __TEXT segment, has content past the point + /// the signature would occupy, or has no room left for the signature load command. + /// + public void AdHocSign(string identifier) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + var linkEdit = FindSegment("__LINKEDIT") + ?? throw new InvalidOperationException("The image has no __LINKEDIT segment, so there is nowhere to put a signature."); + var text = FindSegment("__TEXT") + ?? throw new InvalidOperationException("The image has no __TEXT segment, so the signature cannot describe its executable range."); + + var command = CodeSignature; + if (command is null) + { + command = new MachOLinkEditDataCommand + { + Type = MachOLoadCommandType.CodeSignature, + Size = MachOLinkEditDataCommand.CommandSize, + }; + AppendCommand(command); + } + + // A signature covers everything before it, so it has to be the last thing in the file. + // Whatever occupied that place before, including a previous signature, is replaced. + if (command.DataOffset != 0) + { + for (var i = Content.Count - 1; i >= 0; i--) + { + if (Content[i].Position == command.DataOffset) + { + Content.RemoveAt(i); + break; + } + } + } + + var contentEnd = ComputeFileSize(); + var signatureOffset = AlignHelper.AlignUp((uint)contentEnd, (uint)MachOCodeSignatureConstants.SignatureAlignment); + + var builder = new MachOAdHocSignatureBuilder(identifier) + { + CodeLimit = signatureOffset, + ExecSegmentBase = text.FileOffset, + ExecSegmentLimit = text.FileSize, + ExecSegmentFlags = FileType == MachOFileType.Execute ? MachOCodeSignatureConstants.ExecSegMainBinary : 0, + }; + var signatureSize = builder.ComputeSize(); + + command.DataOffset = signatureOffset; + command.DataSize = signatureSize; + + linkEdit.FileSize = signatureOffset + signatureSize - linkEdit.FileOffset; + linkEdit.VmSize = Math.Max(linkEdit.VmSize, linkEdit.FileSize); + + var placeholder = new MachOStreamContent(new MemoryStream(new byte[signatureSize])) { Position = signatureOffset }; + Content.Add(placeholder); + + // Signing is what makes the image match its signature again, so the edit is settled here + // rather than after the digests are taken. Writing checks this, and the digests are taken + // by writing the image out. + IsCodeSignatureStale = false; + + // The digests cover the image as it will finally be written, so the header, the command + // table and the signature's own offsets all have to be settled before they are taken. + var image = new MemoryStream(); + Write(image); + placeholder.Content = new MemoryStream(builder.Build(image.GetBuffer().AsSpan(0, (int)signatureOffset))); + } + +} From 851933ae33b730711e047c9bdb2e661099ed8874 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:26:21 -0400 Subject: [PATCH 08/32] Add Mach-O symbol table and relocation reading The symbol table, the indirect symbol table and the relocations of a section were bytes that nothing decoded, so there was no way to see what an image defines, imports or fixes up. Adds reading for all three, resolving symbol names against the string table and separating the four things a symbol's type byte packs together. An object file keeps its tables past the end of its only segment, so the lookup covers that as well as a linked image's __LINKEDIT. The result is a decoded snapshot rather than a live view, and the documentation says so, because resizing a table would move everything after it. Relocations come in two forms sharing eight bytes, told apart by the top bit of the first word. Encoding is implemented alongside decoding and tested to round-trip, because a field read from the wrong bit still reads back consistently on its own and would otherwise look correct. - src/LibObjectFile/MachO/MachOSymbol.cs: separates the debug, external and kind bits of the type byte - src/LibObjectFile/MachO/MachOFile.Symbols.cs: reads both symbol tables, from a segment or from the bytes past one - src/LibObjectFile/MachO/MachOFile.Relocations.cs: reads the entries a section points at --- .../MachO/MachOSimpleTests.cs | 97 ++++++++++++ .../MachO/Internal/RawNList32.cs | 27 ++++ .../MachO/Internal/RawNList64.cs | 27 ++++ .../MachO/MachOFile.Relocations.cs | 44 ++++++ src/LibObjectFile/MachO/MachOFile.Symbols.cs | 142 ++++++++++++++++++ src/LibObjectFile/MachO/MachOSymbol.cs | 98 ++++++++++++ src/LibObjectFile/MachO/MachOSymbolKind.cs | 22 +++ 7 files changed, 457 insertions(+) create mode 100644 src/LibObjectFile/MachO/Internal/RawNList32.cs create mode 100644 src/LibObjectFile/MachO/Internal/RawNList64.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.Relocations.cs create mode 100644 src/LibObjectFile/MachO/MachOFile.Symbols.cs create mode 100644 src/LibObjectFile/MachO/MachOSymbol.cs create mode 100644 src/LibObjectFile/MachO/MachOSymbolKind.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index ad71403..c836355 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -283,4 +283,101 @@ public void GrowingTheCommandTableConsumesOnlyThePadding() Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); } + + /// + /// Names and values cross-checked against llvm-nm. The object file is included because it + /// keeps its tables past the end of its only segment, so they belong to no segment and are + /// reached by a different path than a linked image's __LINKEDIT. + /// + [TestMethod] + public void ReadsTheSymbolTable() + { + var symbols = LoadMachO("helloworld_x86_64").ReadSymbolTable(); + + CollectionAssert.AreEqual( + new[] { "__mh_execute_header", "_helloworld_data", "_helloworld_twice", "_main", "_printf", "dyld_stub_binder" }, + symbols.Select(s => s.Name).ToArray()); + + var main = symbols.Single(s => s.Name == "_main"); + Assert.AreEqual(MachOSymbolKind.Section, main.Kind); + Assert.IsTrue(main.IsExternal); + Assert.IsFalse(main.IsDebug); + Assert.AreEqual(1, main.SectionIndex, "__text is the first section"); + Assert.AreEqual(0x100000f50ul, main.Value); + + var printf = symbols.Single(s => s.Name == "_printf"); + Assert.AreEqual(MachOSymbolKind.Undefined, printf.Kind); + Assert.IsTrue(printf.IsExternal); + Assert.AreEqual(0, printf.SectionIndex); + Assert.AreEqual(0ul, printf.Value); + Assert.AreEqual(1, printf.LibraryOrdinal, "resolved from the first dylib, libSystem"); + + var fromObject = LoadMachO("helloworld_x86_64.o").ReadSymbolTable(); + CollectionAssert.AreEqual( + new[] { "_helloworld_data", "_helloworld_twice", "_main", "_printf" }, + fromObject.Select(s => s.Name).ToArray()); + Assert.AreEqual(MachOSymbolKind.Undefined, fromObject.Single(s => s.Name == "_printf").Kind); + + // The stub sections index into the indirect table through reserved1, and not every entry + // there is an index: the sentinels mark slots the loader has nothing to bind. + var file = LoadMachO("helloworld_x86_64"); + var indirect = file.ReadIndirectSymbolTable(); + Assert.AreEqual(4, indirect.Count); + CollectionAssert.Contains(indirect.ToArray(), MachOFile.IndirectSymbolAbsolute); + + var stubs = file.Segments.SelectMany(s => s.Sections).Single(s => s.Name == "__stubs"); + Assert.AreEqual(MachOSectionType.SymbolStubs, stubs.SectionType); + Assert.AreEqual("_printf", symbols[(int)indirect[(int)stubs.Reserved1]].Name); + } + + /// + /// Values cross-checked against llvm-objdump. The two forms of entry pack their fields into + /// the same eight bytes in different orders, so encoding is checked to round-trip as well: + /// a field unpacked from the wrong bit still reads back consistently on its own. + /// + [TestMethod] + public void ReadsRelocations() + { + var file = LoadMachO("helloworld_x86_64.o"); + var text = file.Segments.SelectMany(s => s.Sections).Single(s => s.Name == "__text"); + var symbols = file.ReadSymbolTable(); + + var relocations = file.ReadRelocations(text); + Assert.AreEqual(4, relocations.Count); + + var branch = relocations[0]; + Assert.AreEqual(0x36, branch.Address); + Assert.AreEqual((byte)MachOX86_64RelocationType.Branch, branch.RawType); + Assert.IsTrue(branch.IsPcRelative); + Assert.IsTrue(branch.IsExternal); + Assert.AreEqual(4, branch.LengthInBytes); + Assert.IsFalse(branch.IsScattered); + Assert.AreEqual("_printf", symbols[(int)branch.SymbolOrSectionNumber].Name); + + // A local entry numbers a section instead of a symbol. + var signed = relocations[1]; + Assert.AreEqual((byte)MachOX86_64RelocationType.Signed, signed.RawType); + Assert.IsFalse(signed.IsExternal); + Assert.AreEqual(3u, signed.SymbolOrSectionNumber); + + Assert.AreEqual("_helloworld_data", symbols[(int)relocations[3].SymbolOrSectionNumber].Name); + + foreach (var relocation in relocations) + { + var (word0, word1) = relocation.Encode(); + var again = MachORelocation.Decode(word0, word1); + Assert.AreEqual(relocation.ToString(), again.ToString()); + Assert.AreEqual(relocation.SymbolOrSectionNumber, again.SymbolOrSectionNumber); + } + + // The scattered form has no external flag and carries a target address instead. + var scattered = MachORelocation.Decode(MachORelocation.ScatteredMask | (2u << 28) | (1u << 24) | 0x123, 0x4567); + Assert.IsTrue(scattered.IsScattered); + Assert.AreEqual(0x123, scattered.Address); + Assert.AreEqual(1, scattered.RawType); + Assert.AreEqual(4, scattered.LengthInBytes); + Assert.AreEqual(0x4567, scattered.Value); + Assert.IsFalse(scattered.IsExternal); + Assert.AreEqual((MachORelocation.ScatteredMask | (2u << 28) | (1u << 24) | 0x123, 0x4567u), scattered.Encode()); + } } diff --git a/src/LibObjectFile/MachO/Internal/RawNList32.cs b/src/LibObjectFile/MachO/Internal/RawNList32.cs new file mode 100644 index 0000000..c8f4ea0 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawNList32.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 32-bit symbol table entry (nlist). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawNList32 +{ + public uint StringIndex; + public byte Type; + public byte SectionIndex; + /// + /// The description field. The reference declares this signed for nlist and unsigned + /// for nlist_64; it is read unsigned in both cases because it is a set of bit fields + /// rather than a number, and its high byte is the ordinal of the library defining the symbol. + /// + public ushort Description; + public uint Value; +} diff --git a/src/LibObjectFile/MachO/Internal/RawNList64.cs b/src/LibObjectFile/MachO/Internal/RawNList64.cs new file mode 100644 index 0000000..2dacf03 --- /dev/null +++ b/src/LibObjectFile/MachO/Internal/RawNList64.cs @@ -0,0 +1,27 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace LibObjectFile.MachO.Internal; + +#pragma warning disable CS0649 + +/// +/// A 64-bit symbol table entry (nlist_64). +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct RawNList64 +{ + public uint StringIndex; + public byte Type; + public byte SectionIndex; + /// + /// The description field. The reference declares this signed for nlist and unsigned + /// for nlist_64; it is read unsigned in both cases because it is a set of bit fields + /// rather than a number, and its high byte is the ordinal of the library defining the symbol. + /// + public ushort Description; + public ulong Value; +} diff --git a/src/LibObjectFile/MachO/MachOFile.Relocations.cs b/src/LibObjectFile/MachO/MachOFile.Relocations.cs new file mode 100644 index 0000000..06adc77 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Relocations.cs @@ -0,0 +1,44 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Reads the relocations of a section. + /// + /// The section whose relocations to read. + /// The relocations, or an empty list if the section has none. + /// + /// Only an object file normally carries these. A linked image resolves them at link time and + /// keeps just what the loader still needs, as dyld info opcodes or chained fixups. + /// + /// A pair of entries can describe one fixup between them, with the second carrying the other + /// half. They are returned in file order so a caller can pair them up. + /// + /// + /// is null. + /// The relocations lie outside the content of this image. + public IReadOnlyList ReadRelocations(MachOSection section) + { + ArgumentNullException.ThrowIfNull(section); + + if (section.NumberOfRelocations == 0) return []; + + var bytes = ReadFileBytes(section.RelocationOffset, section.NumberOfRelocations * MachORelocation.EntrySize, $"relocations of {section.SegmentName},{section.Name}"); + var relocations = new MachORelocation[section.NumberOfRelocations]; + for (var i = 0; i < relocations.Length; i++) + { + relocations[i] = MachORelocation.Decode( + BitConverter.ToUInt32(bytes, i * (int)MachORelocation.EntrySize), + BitConverter.ToUInt32(bytes, i * (int)MachORelocation.EntrySize + 4)); + } + + return relocations; + } +} diff --git a/src/LibObjectFile/MachO/MachOFile.Symbols.cs b/src/LibObjectFile/MachO/MachOFile.Symbols.cs new file mode 100644 index 0000000..1ec5d23 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFile.Symbols.cs @@ -0,0 +1,142 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using LibObjectFile.Diagnostics; +using LibObjectFile.MachO.Internal; + +namespace LibObjectFile.MachO; + +partial class MachOFile +{ + /// + /// Reads the symbol table. + /// + /// + /// The symbols in table order, or an empty list if the image has no LC_SYMTAB. The + /// order matters, because LC_DYSYMTAB describes runs of it by index. + /// + /// + /// This decodes a snapshot rather than returning a live view. Writing the file does not carry + /// changes made to the returned symbols back into it, since resizing the table would move + /// everything after it in __LINKEDIT. + /// + /// The table lies outside the content of this image. + public IReadOnlyList ReadSymbolTable() + { + var command = LoadCommands.OfType().FirstOrDefault(); + if (command is null || command.SymbolCount == 0) return []; + + var entrySize = MachOSymbolTableCommand.GetSymbolSize(Is64Bit); + var entries = ReadFileBytes(command.SymbolOffset, command.SymbolCount * entrySize, "symbol table"); + var strings = ReadFileBytes(command.StringOffset, command.StringSize, "string table"); + + var symbols = new List((int)command.SymbolCount); + for (var i = 0; i < command.SymbolCount; i++) + { + var span = entries.AsSpan((int)(i * entrySize)); + var symbol = Is64Bit ? ReadSymbol64(span) : ReadSymbol32(span); + symbol.Name = ReadString(strings, symbol.NameOffset); + symbols.Add(symbol); + } + + return symbols; + } + + /// + /// Reads the indirect symbol table, which is what the stub and symbol pointer sections index + /// into through their reserved1 field. + /// + /// The symbol table indices, or an empty list if the image has no indirect table. + /// + /// Two values are not indices: and + /// mark entries the loader has nothing to bind. + /// + /// The table lies outside the content of this image. + public IReadOnlyList ReadIndirectSymbolTable() + { + var command = LoadCommands.OfType().FirstOrDefault(); + if (command is null || command.IndirectSymbolCount == 0) return []; + + var bytes = ReadFileBytes(command.IndirectSymbolOffset, command.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize, "indirect symbol table"); + var indices = new uint[command.IndirectSymbolCount]; + for (var i = 0; i < indices.Length; i++) + { + indices[i] = BitConverter.ToUInt32(bytes, i * (int)MachODynamicSymbolTableCommand.IndirectSymbolEntrySize); + } + + return indices; + } + + /// The entry is a local symbol the loader does not bind (INDIRECT_SYMBOL_LOCAL). + public const uint IndirectSymbolLocal = 0x80000000; + + /// The entry is an absolute symbol the loader does not bind (INDIRECT_SYMBOL_ABS). + public const uint IndirectSymbolAbsolute = 0x40000000; + + private static unsafe MachOSymbol ReadSymbol64(ReadOnlySpan span) + { + var raw = System.Runtime.InteropServices.MemoryMarshal.Read(span); + return new MachOSymbol + { + NameOffset = raw.StringIndex, + RawType = raw.Type, + SectionIndex = raw.SectionIndex, + Description = raw.Description, + Value = raw.Value, + }; + } + + private static unsafe MachOSymbol ReadSymbol32(ReadOnlySpan span) + { + var raw = System.Runtime.InteropServices.MemoryMarshal.Read(span); + return new MachOSymbol + { + NameOffset = raw.StringIndex, + RawType = raw.Type, + SectionIndex = raw.SectionIndex, + Description = raw.Description, + Value = raw.Value, + }; + } + + private static string ReadString(byte[] strings, uint offset) + { + if (offset >= strings.Length) return string.Empty; + + var span = strings.AsSpan((int)offset); + var end = span.IndexOf((byte)0); + if (end < 0) end = span.Length; + return Encoding.UTF8.GetString(span.Slice(0, end)); + } + + /// + /// Reads a run of bytes at a file offset out of whichever content covers it. + /// + private byte[] ReadFileBytes(uint offset, uint length, string what) + { + if (length == 0) return []; + + foreach (var content in Content) + { + if (content is not MachOStreamContent stream || offset < content.Position) continue; + + var start = offset - content.Position; + if (start + length > (ulong)stream.Content.Length) continue; + + var buffer = new byte[length]; + stream.Content.Position = (long)start; + stream.Content.ReadExactly(buffer); + return buffer; + } + + var diagnostics = new DiagnosticBag(); + diagnostics.Error(DiagnosticId.MACHO_ERR_DataOutsideImage, $"The {what} at 0x{offset:X} for 0x{length:X} bytes is not covered by any content of this image."); + throw new ObjectFileException($"The {what} at 0x{offset:X} for 0x{length:X} bytes is not covered by any content of this image.", diagnostics); + } +} diff --git a/src/LibObjectFile/MachO/MachOSymbol.cs b/src/LibObjectFile/MachO/MachOSymbol.cs new file mode 100644 index 0000000..bb8d5dd --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSymbol.cs @@ -0,0 +1,98 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// An entry of the symbol table. +/// +/// +/// The type byte packs four things at once: whether the entry is debug information, whether the +/// symbol is external or private external, and what it refers to. They are separated here, since +/// reading the byte as a single value gets the common cases wrong. +/// +public sealed class MachOSymbol +{ + /// Bits of the type byte holding debug information rather than a symbol (N_STAB). + public const byte StabMask = 0xe0; + + /// Bit of the type byte marking a symbol as private external (N_PEXT). + public const byte PrivateExternalMask = 0x10; + + /// Bits of the type byte holding the (N_TYPE). + public const byte KindMask = 0x0e; + + /// Bit of the type byte marking a symbol as external (N_EXT). + public const byte ExternalMask = 0x01; + + /// + /// Gets or sets the name of the symbol. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the offset of the name in the string table. + /// + public uint NameOffset { get; set; } + + /// + /// Gets or sets the raw type byte. + /// + public byte RawType { get; set; } + + /// + /// Gets or sets the one-based index of the section defining this symbol, or zero when no + /// section does (NO_SECT). + /// + public byte SectionIndex { get; set; } + + /// + /// Gets or sets the description field, whose meaning depends on the symbol. For an undefined + /// symbol in a two-level image its high byte is the ordinal of the library defining it. + /// + public ushort Description { get; set; } + + /// + /// Gets or sets the value of the symbol, which is its address when + /// is . + /// + public ulong Value { get; set; } + + /// + /// Gets a value indicating whether this entry is debug information rather than a symbol. + /// + /// + /// A debug entry reuses the other fields for its own purposes, so the rest of this type does + /// not describe it. + /// + public bool IsDebug => (RawType & StabMask) != 0; + + /// + /// Gets what this symbol refers to. Meaningless when is true. + /// + public MachOSymbolKind Kind => (MachOSymbolKind)(RawType & KindMask); + + /// + /// Gets a value indicating whether the symbol is visible outside this image. + /// + public bool IsExternal => (RawType & ExternalMask) != 0; + + /// + /// Gets a value indicating whether the symbol is visible only to what was linked with it. + /// + public bool IsPrivateExternal => (RawType & PrivateExternalMask) != 0; + + /// + /// Gets the ordinal of the library expected to define this symbol, for an undefined symbol + /// in a two-level namespace image. The ordinal is a one-based index into the dylib commands + /// in the order they appear, which is why inserting one anywhere but last renumbers them. + /// + public int LibraryOrdinal => (Description >> 8) & 0xff; + + /// + public override string ToString() + => $"{nameof(MachOSymbol)} {{ {Name}, {Kind}{(IsExternal ? ", external" : string.Empty)}, Value = 0x{Value:X} }}"; +} diff --git a/src/LibObjectFile/MachO/MachOSymbolKind.cs b/src/LibObjectFile/MachO/MachOSymbolKind.cs new file mode 100644 index 0000000..f1fe63a --- /dev/null +++ b/src/LibObjectFile/MachO/MachOSymbolKind.cs @@ -0,0 +1,22 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +namespace LibObjectFile.MachO; + +/// +/// What a symbol refers to, held in the N_TYPE bits of a symbol's type byte. +/// +public enum MachOSymbolKind : byte +{ + /// The symbol is not defined in this image (N_UNDF). + Undefined = 0x0, + /// The symbol has an absolute value that no section owns (N_ABS). + Absolute = 0x2, + /// The symbol is an alias for another, named by its string index (N_INDR). + Indirect = 0xa, + /// The symbol was undefined in a prebound image (N_PBUD). + PreboundUndefined = 0xc, + /// The symbol is defined in the section given by its section index (N_SECT). + Section = 0xe, +} From 7b687cad3a98908127a42c25aecfe21371f39788 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:26:44 -0400 Subject: [PATCH 09/32] Add Mach-O universal binary support A universal binary could not be read at all, so an image shipping for both Intel and Apple Silicon was out of reach even though each slice inside it was already readable. Adds reading and writing of the container, exposing one image per architecture. The header and its slice table are big-endian whatever the architectures inside are, which is the one place the format departs from the image's own byte order, so they are read through explicit big-endian primitives rather than by copying a structure. Each slice is read through a view bounded to it, so a malformed slice cannot reach into its neighbours. The slices are laid out before writing, so one that changed size since it was read is placed and recorded correctly rather than overrunning the one after it. The space between them is left zeroed: every universal binary examined pads with zeros, and slices are page aligned rather than packed. - src/LibObjectFile/MachO/MachOFatFile.cs: the container, its slice table and the bounded reads - src/LibObjectFile/MachO/MachOFatSlice.cs: one architecture's placement and image --- .../MachO/MachOSimpleTests.cs | 29 ++ src/LibObjectFile/MachO/MachOFatFile.cs | 289 ++++++++++++++++++ src/LibObjectFile/MachO/MachOFatSlice.cs | 54 ++++ 3 files changed, 372 insertions(+) create mode 100644 src/LibObjectFile/MachO/MachOFatFile.cs create mode 100644 src/LibObjectFile/MachO/MachOFatSlice.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index c836355..0d5f8bf 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -380,4 +380,33 @@ public void ReadsRelocations() Assert.IsFalse(scattered.IsExternal); Assert.AreEqual((MachORelocation.ScatteredMask | (2u << 28) | (1u << 24) | 0x123, 0x4567u), scattered.Encode()); } + + /// + /// A universal binary stores its header big-endian whatever the architectures inside are, + /// which is the one place the format departs from the image's own byte order. + /// + [TestMethod] + public void ReadsAndWritesAUniversalBinary() + { + var original = File.ReadAllBytes(GetFile("helloworld_fat")); + + using var input = new MemoryStream(original); + Assert.IsTrue(MachOFatFile.IsFat(input)); + Assert.IsFalse(MachOFile.IsMachO(input), "a universal binary is not itself a Mach-O image"); + + var fat = MachOFatFile.Read(input); + Assert.IsFalse(fat.Is64BitOffsets); + CollectionAssert.AreEqual(new[] { MachOCpuType.X86_64, MachOCpuType.Arm64 }, fat.Slices.Select(s => s.CpuType).ToArray()); + + foreach (var slice in fat.Slices) + { + Assert.IsNotNull(slice.File); + Assert.AreEqual(slice.CpuType, slice.File.CpuType, "the slice table has to agree with the image it points at"); + Assert.AreEqual(0ul, slice.FileOffset % slice.Alignment, "a slice is mapped directly, so it has to be page aligned"); + } + + var output = new MemoryStream(); + fat.Write(output); + ByteArrayAssert.AreEqual(original, output.ToArray(), "Invalid binary diff for helloworld_fat after read -> write"); + } } diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs new file mode 100644 index 0000000..3dce920 --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -0,0 +1,289 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using LibObjectFile.Diagnostics; +using LibObjectFile.IO; +using LibObjectFile.MachO.Internal; +using LibObjectFile.Utils; + +namespace LibObjectFile.MachO; + +/// +/// A universal binary, holding one per architecture. +/// +/// +/// The header and its slice table are stored big-endian whatever the architectures inside are, +/// which is the one place the format does not follow the image's own byte order. +/// +/// Slices are aligned to the page size of their architecture so the loader can map one straight +/// out of the containing file, which is why a universal binary is larger than its slices put +/// together. +/// +/// +public sealed class MachOFatFile +{ + /// + /// The size of the header preceding the slice table (fat_header). + /// + public static unsafe int HeaderSize => sizeof(RawFatHeader); + + /// + /// Gets the size of one slice table entry for the given offset width. + /// + /// Whether slice offsets are stored as 64-bit values. + /// The size of a fat_arch_64 or a fat_arch. + public static unsafe int GetSliceEntrySize(bool is64BitOffsets) + => is64BitOffsets ? sizeof(RawFatArch64) : sizeof(RawFatArch); + + /// + /// Gets the slices of this universal binary, in the order the header lists them. + /// + public List Slices { get; } = []; + + /// + /// Gets or sets whether slice offsets are stored as 64-bit values, which is needed once a + /// slice starts beyond 4GB. + /// + public bool Is64BitOffsets { get; set; } + + /// + /// Checks whether a stream starts with a universal binary magic, without consuming it. + /// + /// The stream to inspect. + /// true if the stream starts with a universal binary magic. + /// is null. + public static bool IsFat(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + var position = stream.Position; + try + { + Span magic = stackalloc byte[4]; + if (stream.Read(magic) != 4) return false; + var value = BinaryPrimitives.ReadUInt32BigEndian(magic); + return value is MachOMagic.FatMagic or MachOMagic.FatMagic64; + } + finally + { + stream.Position = position; + } + } + + /// + /// Reads a universal binary from a stream. + /// + /// The stream positioned at the start of the file. + /// Options controlling how each slice is read, or null for the defaults. + /// The universal binary read. + /// is null. + /// The stream does not contain a readable universal binary. + public static MachOFatFile Read(Stream stream, MachOReaderOptions? options = null) + { + if (!TryRead(stream, out var file, out var diagnostics, options)) + { + throw new ObjectFileException($"Unexpected error while reading the Mach-O universal binary", diagnostics); + } + return file; + } + + /// + /// Tries to read a universal binary from a stream. + /// + /// The stream positioned at the start of the file. + /// The universal binary read, if reading succeeded. + /// The diagnostics collected, if reading failed. + /// Options controlling how each slice is read, or null for the defaults. + /// true if the file was read; otherwise false. + /// is null. + public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? file, [NotNullWhen(false)] out DiagnosticBag? diagnostics, MachOReaderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(stream); + + var bag = new DiagnosticBag(); + file = new MachOFatFile(); + diagnostics = bag; + + var basePosition = stream.Position; + Span header = stackalloc byte[HeaderSize]; + stream.Position = basePosition; + if (stream.Read(header) != HeaderSize) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, "The stream is too short to hold a universal binary header"); + file = null; + return false; + } + + var magic = BinaryPrimitives.ReadUInt32BigEndian(header); + if (magic is not (MachOMagic.FatMagic or MachOMagic.FatMagic64)) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, $"Invalid universal binary magic 0x{magic:X8}"); + file = null; + return false; + } + + file.Is64BitOffsets = magic == MachOMagic.FatMagic64; + var count = BinaryPrimitives.ReadUInt32BigEndian(header.Slice(4)); + + var entrySize = GetSliceEntrySize(file.Is64BitOffsets); + var entries = new byte[count * (uint)entrySize]; + try + { + stream.ReadExactly(entries); + } + catch (EndOfStreamException) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, $"The universal binary header claims {count} architectures, which do not fit in the file"); + file = null; + return false; + } + + for (var i = 0; i < count; i++) + { + var entry = entries.AsSpan(i * entrySize); + var slice = new MachOFatSlice + { + CpuType = (MachOCpuType)BinaryPrimitives.ReadUInt32BigEndian(entry), + CpuSubType = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(4)), + }; + + if (file.Is64BitOffsets) + { + slice.FileOffset = BinaryPrimitives.ReadUInt64BigEndian(entry.Slice(8)); + slice.Size = BinaryPrimitives.ReadUInt64BigEndian(entry.Slice(16)); + slice.AlignLog2 = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(24)); + } + else + { + slice.FileOffset = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(8)); + slice.Size = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(12)); + slice.AlignLog2 = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(16)); + } + + if (slice.FileOffset + slice.Size > (ulong)stream.Length) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatArchRange, $"Slice {i} spans [0x{slice.FileOffset:X}, 0x{slice.FileOffset + slice.Size:X}) which extends past the end of the file"); + file = null; + return false; + } + + // Each slice is a complete image, so it is read through a view bounded to the slice. + // Handing it the whole stream would let a malformed slice read its neighbours. + var sliceStream = new SubStream(stream, basePosition + (long)slice.FileOffset, (long)slice.Size); + if (!MachOFile.TryRead(sliceStream, out var sliceFile, out var sliceDiagnostics, options)) + { + foreach (var message in sliceDiagnostics.Messages) + { + bag.Log(message); + } + file = null; + return false; + } + + slice.File = sliceFile; + file.Slices.Add(slice); + } + + diagnostics = null; + return true; + } + + /// + /// Writes this universal binary to a stream. + /// + /// The stream to write to. + /// is null. + /// A slice could not be written. + /// + /// The slices are laid out first, so a slice that changed size since it was read is placed + /// and recorded correctly rather than overrunning the one after it. The space between them + /// is left zeroed: every universal binary examined pads with zeros, and slices are aligned + /// rather than packed, so the gaps hold nothing worth preserving. + /// + public void Write(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + UpdateLayout(); + var basePosition = stream.Position; + var entrySize = GetSliceEntrySize(Is64BitOffsets); + + var header = new byte[HeaderSize + Slices.Count * entrySize]; + BinaryPrimitives.WriteUInt32BigEndian(header, Is64BitOffsets ? MachOMagic.FatMagic64 : MachOMagic.FatMagic); + BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(4), (uint)Slices.Count); + + for (var i = 0; i < Slices.Count; i++) + { + var slice = Slices[i]; + var entry = header.AsSpan(HeaderSize + i * entrySize); + BinaryPrimitives.WriteUInt32BigEndian(entry, (uint)slice.CpuType); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(4), slice.CpuSubType); + + if (Is64BitOffsets) + { + BinaryPrimitives.WriteUInt64BigEndian(entry.Slice(8), slice.FileOffset); + BinaryPrimitives.WriteUInt64BigEndian(entry.Slice(16), slice.Size); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(24), slice.AlignLog2); + } + else + { + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(8), (uint)slice.FileOffset); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(12), (uint)slice.Size); + BinaryPrimitives.WriteUInt32BigEndian(entry.Slice(16), slice.AlignLog2); + } + } + + stream.Write(header); + + foreach (var slice in Slices) + { + if (slice.File is null) continue; + stream.Position = basePosition + (long)slice.FileOffset; + slice.File.Write(stream); + } + + var end = Slices.Count == 0 ? 0 : Slices.Max(s => (long)(s.FileOffset + s.Size)); + if (stream.Length < basePosition + end) + { + stream.SetLength(basePosition + end); + } + } + + /// + /// Recomputes where each slice sits and how large it is. + /// + /// + /// A slice is mapped straight out of the containing file, so it has to start on a page + /// boundary for its architecture. Sizes are computed from each slice rather than taken from + /// what it was read as, since an edited slice no longer matches that, and the header would + /// otherwise point the loader at a slice that overruns the one after it. + /// + public void UpdateLayout() + { + var cursor = (ulong)(HeaderSize + Slices.Count * GetSliceEntrySize(Is64BitOffsets)); + + foreach (var slice in Slices) + { + if (slice.File is null) + { + slice.Size = 0; + continue; + } + + var alignment = slice.Alignment; + slice.FileOffset = AlignHelper.AlignUp(cursor, alignment); + slice.Size = slice.File.ComputeFileSize(); + cursor = slice.FileOffset + slice.Size; + } + } + + /// + public override string ToString() => $"{nameof(MachOFatFile)} {{ Slices = {Slices.Count} }}"; +} diff --git a/src/LibObjectFile/MachO/MachOFatSlice.cs b/src/LibObjectFile/MachO/MachOFatSlice.cs new file mode 100644 index 0000000..8152c3b --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFatSlice.cs @@ -0,0 +1,54 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; + +namespace LibObjectFile.MachO; + +/// +/// One architecture's image inside a . +/// +public sealed class MachOFatSlice +{ + /// + /// Gets or sets the CPU architecture of this slice. It repeats what the slice's own header + /// says, so that a loader can pick a slice without parsing it. + /// + public MachOCpuType CpuType { get; set; } + + /// + /// Gets or sets the CPU subtype of this slice. + /// + public uint CpuSubType { get; set; } + + /// + /// Gets or sets the alignment of this slice as a power of two. It is the page size of the + /// architecture, because the slice is mapped directly out of the containing file. + /// + public uint AlignLog2 { get; set; } + + /// + /// Gets or sets the offset of this slice in the containing file. + /// + public ulong FileOffset { get; set; } + + /// + /// Gets or sets the size of this slice in the containing file. + /// + public ulong Size { get; set; } + + /// + /// Gets or sets the image this slice holds. + /// + public MachOFile? File { get; set; } + + /// + /// Gets the alignment of this slice in bytes. + /// + public ulong Alignment => 1ul << (int)AlignLog2; + + /// + public override string ToString() + => $"{nameof(MachOFatSlice)} {{ {CpuType}, FileOffset = 0x{FileOffset:X}, Size = 0x{Size:X}, Align = 2^{AlignLog2} }}"; +} From 0d7501c4044774ea85fe4f5b71a7d36e0b541977 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:27:17 -0400 Subject: [PATCH 10/32] Add otool-style printing for Mach-O images A decoded image could only be inspected through a debugger. Adds printing of the header and every load command, and snapshots the result for each fixture, so decoding a new command extends the snapshot rather than needing another test and a change to how anything is read shows up as a diff. Commands are named by their LC_ spelling rather than the enumeration's, so output can be read next to otool's. That mapping is written out rather than derived from the enumeration names, because several do not follow from them: LC_UNIXTHREAD is one word and LC_VERSION_MIN_MACOSX breaks in places the casing does not. The command sequence each fixture prints was checked against otool before the snapshots were taken. - src/LibObjectFile/MachO/MachOPrinter.cs: the printer - src/LibObjectFile.Tests/Verified: one snapshot per fixture --- .../MachO/MachOSimpleTests.cs | 394 +++++++++--------- .../MachO/MachOTestBase.cs | 8 + ...ints_name=chainedfixups_arm64.verified.txt | 216 ++++++++++ ...sts.Prints_name=dyldinfo_i386.verified.txt | 153 +++++++ ....Prints_name=helloworld_arm64.verified.txt | 247 +++++++++++ ...ints_name=helloworld_x86_64.o.verified.txt | 106 +++++ ...Prints_name=helloworld_x86_64.verified.txt | 239 +++++++++++ ...me=libhelloworld_x86_64.dylib.verified.txt | 212 ++++++++++ ...s.Prints_name=unixthread_i386.verified.txt | 125 ++++++ src/LibObjectFile/MachO/MachOPrinter.cs | 275 ++++++++++++ 10 files changed, 1787 insertions(+), 188 deletions(-) create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=chainedfixups_arm64.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_arm64.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.o.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=libhelloworld_x86_64.dylib.verified.txt create mode 100644 src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=unixthread_i386.verified.txt create mode 100644 src/LibObjectFile/MachO/MachOPrinter.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 0d5f8bf..203fdea 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -17,20 +17,6 @@ namespace LibObjectFile.Tests.MachO; [TestClass] public class MachOSimpleTests : MachOTestBase { - private static List CollectOffsets(MachOFile file) - { - var offsets = new List(); - foreach (var command in file.LoadCommands) - { - command.UpdateFileOffsets(offset => - { - offsets.Add(offset); - return offset; - }); - } - return offsets; - } - /// /// The raw structures are copied by value straight out of the file, so a wrong size or an /// unexpected padding byte would silently shift every field after it. @@ -89,23 +75,6 @@ public void ReportsSpaceLeftForNewLoadCommands() Assert.IsTrue(file.LoadCommands.OfType().All(d => d.Size >= d.MinimumSize)); } - /// - /// The snapshot shows the thread state's flavour and length, but not that the eleventh word - /// is the entry point, nor that the symbol table runs straight into the string table. - /// - [TestMethod] - public void ReadsThe32BitEntryPointAndTableLayout() - { - var file = LoadMachO("unixthread_i386"); - - var state = file.LoadCommands.OfType().Single().States.Single(); - Assert.AreEqual(1u, state.Flavor, "x86_THREAD_STATE32"); - Assert.AreEqual((uint)file.FindSegment("__TEXT")!.Sections.Single().Address, state.Registers[10]); - - var symtab = file.LoadCommands.OfType().Single(); - Assert.AreEqual(symtab.StringOffset, symtab.SymbolOffset + symtab.SymbolCount * MachOSymbolTableCommand.GetSymbolSize(file.Is64Bit)); - } - /// /// Packing is not visible in a printed image, since only the decoded version is shown. /// @@ -118,170 +87,21 @@ public void VersionPackingRoundTrips() Assert.ThrowsExactly(() => MachOVersion.Encode(new Version(1, 256, 0))); } - [TestMethod] - public void RejectsANonMachOStream() - { - using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); - - Assert.IsFalse(MachOFile.IsMachO(input)); - Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); - Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); - } - - [TestMethod] - [DataRow("helloworld_x86_64")] - [DataRow("helloworld_arm64")] - [DataRow("chainedfixups_arm64")] - [DataRow("libhelloworld_x86_64.dylib")] - [DataRow("helloworld_x86_64.o")] - [DataRow("unixthread_i386")] - [DataRow("dyldinfo_i386")] - public void ReadWriteIsByteExact(string name) - { - var original = File.ReadAllBytes(GetFile(name)); - - using var input = new MemoryStream(original); - var file = MachOFile.Read(input); - - var output = new MemoryStream(); - file.Write(output); - - ByteArrayAssert.AreEqual(original, output.ToArray(), $"Invalid binary diff for {name} after read -> write"); - } - /// - /// Commands this code does not model have to survive verbatim, which is what keeps the reader - /// usable against images from a newer linker. The type used here is a made-up one, so that - /// modelling more real commands later cannot quietly void the test. - /// - [TestMethod] - public void KeepsUnmodelledLoadCommandsVerbatim() - { - var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; - - var file = LoadMachO("unixthread_i386"); - file.LoadCommands.Add(new MachOUnknownLoadCommand - { - Type = (MachOLoadCommandType)0x7F000001, - Size = 16, - Payload = payload, - }); - file.LoadCommands.Add(new MachOTwoLevelHintsCommand - { - Type = MachOLoadCommandType.TwoLevelHints, - Size = MachOTwoLevelHintsCommand.CommandSize, - Offset = 0x2000, - HintCount = 7, - }); - - var stream = new MemoryStream(); - file.Write(stream); - var reread = MachOFile.Read(new MemoryStream(stream.ToArray())); - - var survivor = reread.LoadCommands.OfType().Single(c => c.Type == (MachOLoadCommandType)0x7F000001); - CollectionAssert.AreEqual(payload, survivor.Payload); - - var hints = reread.LoadCommands.OfType().Single(); - Assert.AreEqual(0x2000u, hints.Offset); - Assert.AreEqual(7u, hints.HintCount); - } - - /// - /// A layout has to be able to move everything in __LINKEDIT, so no command may keep a - /// file offset the walk does not reach. Shifting them all and reading them back catches a - /// missed field, which would otherwise still point at where the data used to be. - /// - [TestMethod] - [DataRow("helloworld_x86_64")] - [DataRow("chainedfixups_arm64")] - [DataRow("unixthread_i386")] - [DataRow("dyldinfo_i386")] - public void EveryRecordedFileOffsetIsReachable(string name) - { - const uint delta = 0x1000; - - var originals = CollectOffsets(LoadMachO(name)); - Assert.AreNotEqual(0, originals.Count, "the fixture records no file offsets at all"); - - var shiftedFile = LoadMachO(name); - shiftedFile.UpdateFileOffsets(offset => offset + delta); - CollectionAssert.AreEqual(originals.Select(o => o + delta).ToArray(), CollectOffsets(shiftedFile).ToArray()); - - // An identity remap has to leave the image untouched. - var identity = LoadMachO(name); - identity.UpdateFileOffsets(offset => offset); - var stream = new MemoryStream(); - identity.Write(stream); - ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), stream.ToArray(), "an identity remap changed the image"); - } - - /// - /// Verification exists to catch the invariants nothing else does. The address check is the - /// one the format rests on: break it and the loader maps a section somewhere other than - /// where the code expects, which no round-trip test would notice. - /// - [TestMethod] - public void VerifyAcceptsRealImagesAndCatchesABrokenOne() - { - foreach (var name in new[] { "helloworld_x86_64", "helloworld_arm64", "unixthread_i386", "helloworld_x86_64.o" }) - { - var diagnostics = new DiagnosticBag(); - LoadMachO(name).Verify(diagnostics); - Assert.IsFalse(diagnostics.HasErrors, $"{name} should verify: {string.Join("; ", diagnostics.Messages)}"); - } - - var moved = LoadMachO("unixthread_i386"); - moved.FindSegment("__TEXT")!.Sections[0].Address += 4; - var broken = new DiagnosticBag(); - moved.Verify(broken); - Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); - - var overlong = LoadMachO("unixthread_i386"); - overlong.LoadCommands[0].Size += 1; - var misaligned = new DiagnosticBag(); - overlong.Verify(misaligned); - Assert.IsTrue(misaligned.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidCommandAlignment)); - - // A header edited on its own describes a section that is not there, and the round-trip - // would still match because the header and the bytes are written from what each holds. - var resized = LoadMachO("unixthread_i386"); - resized.FindSegment("__TEXT")!.Sections[0].Size += 8; - var mismatched = new DiagnosticBag(); - resized.Verify(mismatched); - Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); - } - - /// - /// The padding after the load command table is the only elastic thing in the file, so it has - /// to absorb exactly what the table gains and nothing else may shift. This is what an - /// install_name_tool-style edit depends on. + /// The snapshot shows the thread state's flavour and length, but not that the eleventh word + /// is the entry point, nor that the symbol table runs straight into the string table. /// [TestMethod] - public void GrowingTheCommandTableConsumesOnlyThePadding() + public void ReadsThe32BitEntryPointAndTableLayout() { var file = LoadMachO("unixthread_i386"); - var paddingBefore = file.LoadCommandPadding!.Size; - var tableEndBefore = file.LoadCommandsEndOffset; - var contentStart = file.ContentStartOffset; - var positionsBefore = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); - - var added = file.AddRPath("@executable_path/../Frameworks"); - var image = WriteToArray(file); - - Assert.AreEqual(tableEndBefore + added.Size, file.LoadCommandsEndOffset); - Assert.AreEqual(paddingBefore - added.Size, file.LoadCommandPadding!.Size, "the padding did not absorb the new command"); - Assert.AreEqual(contentStart, file.ContentStartOffset, "content after the padding moved"); - - // Only the padding may have moved; everything after it stays exactly where it was. - var positionsAfter = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); - for (var i = 0; i < positionsBefore.Length; i++) - { - if (positionsBefore[i].Name == nameof(MachOLoadCommandPadding)) continue; - Assert.AreEqual(positionsBefore[i], positionsAfter[i], $"content {i} moved"); - } + var state = file.LoadCommands.OfType().Single().States.Single(); + Assert.AreEqual(1u, state.Flavor, "x86_THREAD_STATE32"); + Assert.AreEqual((uint)file.FindSegment("__TEXT")!.Sections.Single().Address, state.Registers[10]); - Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); + var symtab = file.LoadCommands.OfType().Single(); + Assert.AreEqual(symtab.StringOffset, symtab.SymbolOffset + symtab.SymbolCount * MachOSymbolTableCommand.GetSymbolSize(file.Is64Bit)); } /// @@ -409,4 +229,202 @@ public void ReadsAndWritesAUniversalBinary() fat.Write(output); ByteArrayAssert.AreEqual(original, output.ToArray(), "Invalid binary diff for helloworld_fat after read -> write"); } + + /// + /// The padding after the load command table is the only elastic thing in the file, so it has + /// to absorb exactly what the table gains and nothing else may shift. This is what an + /// install_name_tool-style edit depends on. + /// + [TestMethod] + public void GrowingTheCommandTableConsumesOnlyThePadding() + { + var file = LoadMachO("unixthread_i386"); + + var paddingBefore = file.LoadCommandPadding!.Size; + var tableEndBefore = file.LoadCommandsEndOffset; + var contentStart = file.ContentStartOffset; + var positionsBefore = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + + var added = file.AddRPath("@executable_path/../Frameworks"); + var image = WriteToArray(file); + + Assert.AreEqual(tableEndBefore + added.Size, file.LoadCommandsEndOffset); + Assert.AreEqual(paddingBefore - added.Size, file.LoadCommandPadding!.Size, "the padding did not absorb the new command"); + Assert.AreEqual(contentStart, file.ContentStartOffset, "content after the padding moved"); + + // Only the padding may have moved; everything after it stays exactly where it was. + var positionsAfter = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + for (var i = 0; i < positionsBefore.Length; i++) + { + if (positionsBefore[i].Name == nameof(MachOLoadCommandPadding)) continue; + Assert.AreEqual(positionsBefore[i], positionsAfter[i], $"content {i} moved"); + } + + Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); + } + + /// + /// Verification exists to catch the invariants nothing else does. The address check is the + /// one the format rests on: break it and the loader maps a section somewhere other than + /// where the code expects, which no round-trip test would notice. + /// + [TestMethod] + public void VerifyAcceptsRealImagesAndCatchesABrokenOne() + { + foreach (var name in new[] { "helloworld_x86_64", "helloworld_arm64", "unixthread_i386", "helloworld_x86_64.o" }) + { + var diagnostics = new DiagnosticBag(); + LoadMachO(name).Verify(diagnostics); + Assert.IsFalse(diagnostics.HasErrors, $"{name} should verify: {string.Join("; ", diagnostics.Messages)}"); + } + + var moved = LoadMachO("unixthread_i386"); + moved.FindSegment("__TEXT")!.Sections[0].Address += 4; + var broken = new DiagnosticBag(); + moved.Verify(broken); + Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); + + var overlong = LoadMachO("unixthread_i386"); + overlong.LoadCommands[0].Size += 1; + var misaligned = new DiagnosticBag(); + overlong.Verify(misaligned); + Assert.IsTrue(misaligned.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidCommandAlignment)); + + // A header edited on its own describes a section that is not there, and the round-trip + // would still match because the header and the bytes are written from what each holds. + var resized = LoadMachO("unixthread_i386"); + resized.FindSegment("__TEXT")!.Sections[0].Size += 8; + var mismatched = new DiagnosticBag(); + resized.Verify(mismatched); + Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); + } + + [TestMethod] + public void RejectsANonMachOStream() + { + using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); + + Assert.IsFalse(MachOFile.IsMachO(input)); + Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); + Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); + } + + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("helloworld_arm64")] + [DataRow("chainedfixups_arm64")] + [DataRow("libhelloworld_x86_64.dylib")] + [DataRow("helloworld_x86_64.o")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public void ReadWriteIsByteExact(string name) + { + var original = File.ReadAllBytes(GetFile(name)); + + using var input = new MemoryStream(original); + var file = MachOFile.Read(input); + + var output = new MemoryStream(); + file.Write(output); + + ByteArrayAssert.AreEqual(original, output.ToArray(), $"Invalid binary diff for {name} after read -> write"); + } + + /// + /// Commands this code does not model have to survive verbatim, which is what keeps the reader + /// usable against images from a newer linker. The type used here is a made-up one, so that + /// modelling more real commands later cannot quietly void the test. + /// + [TestMethod] + public void KeepsUnmodelledLoadCommandsVerbatim() + { + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; + + var file = LoadMachO("unixthread_i386"); + file.LoadCommands.Add(new MachOUnknownLoadCommand + { + Type = (MachOLoadCommandType)0x7F000001, + Size = 16, + Payload = payload, + }); + file.LoadCommands.Add(new MachOTwoLevelHintsCommand + { + Type = MachOLoadCommandType.TwoLevelHints, + Size = MachOTwoLevelHintsCommand.CommandSize, + Offset = 0x2000, + HintCount = 7, + }); + + var stream = new MemoryStream(); + file.Write(stream); + var reread = MachOFile.Read(new MemoryStream(stream.ToArray())); + + var survivor = reread.LoadCommands.OfType().Single(c => c.Type == (MachOLoadCommandType)0x7F000001); + CollectionAssert.AreEqual(payload, survivor.Payload); + + var hints = reread.LoadCommands.OfType().Single(); + Assert.AreEqual(0x2000u, hints.Offset); + Assert.AreEqual(7u, hints.HintCount); + } + + /// + /// A layout has to be able to move everything in __LINKEDIT, so no command may keep a + /// file offset the walk does not reach. Shifting them all and reading them back catches a + /// missed field, which would otherwise still point at where the data used to be. + /// + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("chainedfixups_arm64")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public void EveryRecordedFileOffsetIsReachable(string name) + { + const uint delta = 0x1000; + + var originals = CollectOffsets(LoadMachO(name)); + Assert.AreNotEqual(0, originals.Count, "the fixture records no file offsets at all"); + + var shiftedFile = LoadMachO(name); + shiftedFile.UpdateFileOffsets(offset => offset + delta); + CollectionAssert.AreEqual(originals.Select(o => o + delta).ToArray(), CollectOffsets(shiftedFile).ToArray()); + + // An identity remap has to leave the image untouched. + var identity = LoadMachO(name); + identity.UpdateFileOffsets(offset => offset); + var stream = new MemoryStream(); + identity.Write(stream); + ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), stream.ToArray(), "an identity remap changed the image"); + } + + private static List CollectOffsets(MachOFile file) + { + var offsets = new List(); + foreach (var command in file.LoadCommands) + { + command.UpdateFileOffsets(offset => + { + offsets.Add(offset); + return offset; + }); + } + return offsets; + } + + /// + /// Snapshots the decoded form of each fixture. This covers every field of every command at + /// once, so decoding a new command extends the snapshot rather than needing another test, + /// and a change to how anything is read shows up as a diff rather than passing unnoticed. + /// + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("helloworld_arm64")] + [DataRow("chainedfixups_arm64")] + [DataRow("libhelloworld_x86_64.dylib")] + [DataRow("helloworld_x86_64.o")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public async Task Prints(string name) + { + await VerifyMachO(LoadMachO(name), name); + } } diff --git a/src/LibObjectFile.Tests/MachO/MachOTestBase.cs b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs index ac13191..559a13c 100644 --- a/src/LibObjectFile.Tests/MachO/MachOTestBase.cs +++ b/src/LibObjectFile.Tests/MachO/MachOTestBase.cs @@ -4,8 +4,10 @@ using System; using System.IO; +using System.Threading.Tasks; using LibObjectFile.MachO; using VerifyMSTest; +using VerifyTests; namespace LibObjectFile.Tests.MachO; @@ -22,4 +24,10 @@ protected static byte[] WriteToArray(MachOFile file) return stream.ToArray(); } + protected async Task VerifyMachO(MachOFile file, string name) + { + var writer = new StringWriter(); + file.Print(writer); + await Verifier.Verify(writer.ToString()).UseParameters(name); + } } diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=chainedfixups_arm64.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=chainedfixups_arm64.verified.txt new file mode 100644 index 0000000..c0b6a15 --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=chainedfixups_arm64.verified.txt @@ -0,0 +1,216 @@ +Mach header: + magic MH_MAGIC_64 + cputype Arm64 + cpusubtype 0x00000000 + filetype Execute + ncmds 17 + sizeofcmds 1192 + flags 0x200085 NoUndefs, DyldLink, TwoLevel, PositionIndependent + +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __PAGEZERO + vmaddr 0x00000000 + vmsize 0x100000000 + fileoff 0 + filesize 0 + maxprot --- + initprot --- + nsects 0 + flags 0x0 +Load command 1 + cmd LC_SEGMENT_64 + cmdsize 392 + segname __TEXT + vmaddr 0x100000000 + vmsize 0x00004000 + fileoff 0 + filesize 16384 + maxprot r-x + initprot r-x + nsects 4 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x1000004e8 + size 0x00000064 + offset 1256 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __stubs + segname __TEXT + addr 0x10000054c + size 0x0000000c + offset 1356 + align 2^2 (4) + reloff 0 + nreloc 0 + type SymbolStubs + attributes SomeInstructions, PureInstructions + reserved1 1 + reserved2 12 +Section + sectname __cstring + segname __TEXT + addr 0x100000558 + size 0x00000010 + offset 1368 + align 2^0 (1) + reloff 0 + nreloc 0 + type CStringLiterals + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __unwind_info + segname __TEXT + addr 0x100000568 + size 0x0000103c + offset 1384 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 2 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __DATA_CONST + vmaddr 0x100004000 + vmsize 0x00004000 + fileoff 16384 + filesize 16384 + maxprot rw- + initprot rw- + nsects 1 + flags 0x10 +Section + sectname __got + segname __DATA_CONST + addr 0x100004000 + size 0x00000008 + offset 16384 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 0 + reserved2 0 +Load command 3 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __DATA + vmaddr 0x100008000 + vmsize 0x00004000 + fileoff 32768 + filesize 16384 + maxprot rw- + initprot rw- + nsects 1 + flags 0x0 +Section + sectname __data + segname __DATA + addr 0x100008000 + size 0x00000004 + offset 32768 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 4 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __LINKEDIT + vmaddr 0x10000c000 + vmsize 0x000003b0 + fileoff 49152 + filesize 944 + maxprot r-- + initprot r-- + nsects 0 + flags 0x0 +Load command 5 + cmd LC_DYLD_CHAINED_FIXUPS + cmdsize 16 + dataoff 49152 + datasize 96 +Load command 6 + cmd LC_DYLD_EXPORTS_TRIE + cmdsize 16 + dataoff 49248 + datasize 88 +Load command 7 + cmd LC_SYMTAB + cmdsize 24 + symoff 49344 + nsyms 6 + stroff 49448 + strsize 88 +Load command 8 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 4 + nundefsym 2 + indirectsymoff 49440 + nindirectsyms 2 +Load command 9 + cmd LC_LOAD_DYLINKER + cmdsize 32 + path /usr/lib/dyld +Load command 10 + cmd LC_UUID + cmdsize 24 + uuid 4C4C4468-5555-3144-A159-D3B12CF35C25 +Load command 11 + cmd LC_BUILD_VERSION + cmdsize 32 + platform MacOS + minos 12.0.0 + sdk 12.0.0 + ntools 1 + tool Lld + version 22.1.8 +Load command 12 + cmd LC_MAIN + cmdsize 24 + entryoff 1280 + stacksize 0 +Load command 13 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libSystem.B.dylib + timestamp 0 + current version 1336.61.1 + compatibility version 1.0.0 +Load command 14 + cmd LC_FUNCTION_STARTS + cmdsize 16 + dataoff 49336 + datasize 8 +Load command 15 + cmd LC_DATA_IN_CODE + cmdsize 16 + dataoff 49344 + datasize 0 +Load command 16 + cmd LC_CODE_SIGNATURE + cmdsize 16 + dataoff 49536 + datasize 560 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt new file mode 100644 index 0000000..b06ac77 --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt @@ -0,0 +1,153 @@ +Mach header: + magic MH_MAGIC + cputype X86 + cpusubtype 0x00000003 + filetype Execute + ncmds 16 + sizeofcmds 720 + flags 0x218085 NoUndefs, DyldLink, TwoLevel, WeakDefines, BindsToWeak, PositionIndependent + +Load command 0 + cmd LC_SEGMENT + cmdsize 56 + segname __PAGEZERO + vmaddr 0x00000000 + vmsize 0x00001000 + fileoff 0 + filesize 0 + maxprot --- + initprot --- + nsects 0 + flags 0x0 +Load command 1 + cmd LC_SEGMENT + cmdsize 124 + segname __TEXT + vmaddr 0x00001000 + vmsize 0x00001000 + fileoff 0 + filesize 4096 + maxprot rwx + initprot r-x + nsects 1 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x00001800 + size 0x00000006 + offset 2048 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Load command 2 + cmd LC_SEGMENT + cmdsize 124 + segname __DATA + vmaddr 0x00002000 + vmsize 0x00001000 + fileoff 4096 + filesize 4096 + maxprot rwx + initprot rw- + nsects 1 + flags 0x0 +Section + sectname __data + segname __DATA + addr 0x00002000 + size 0x00000004 + offset 4096 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 3 + cmd LC_SEGMENT + cmdsize 56 + segname __LINKEDIT + vmaddr 0x00003000 + vmsize 0x00001000 + fileoff 8192 + filesize 64 + maxprot rwx + initprot r-- + nsects 0 + flags 0x0 +Load command 4 + cmd LC_DYLD_INFO_ONLY + cmdsize 48 + rebase_off 0 + rebase_size 0 + bind_off 0 + bind_size 0 + lazy_bind_off 0 + lazy_bind_size 0 + export_off 0 + export_size 0 +Load command 5 + cmd LC_SYMTAB + cmdsize 24 + symoff 8192 + nsyms 2 + stroff 8216 + strsize 32 +Load command 6 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 2 + nundefsym 0 + indirectsymoff 0 + nindirectsyms 0 +Load command 7 + cmd LC_LOAD_DYLINKER + cmdsize 28 + path /usr/lib/dyld +Load command 8 + cmd LC_UUID + cmdsize 24 + uuid 4D4F4445-524E-3332-4249-540000000002 +Load command 9 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.7.0 + sdk 10.7.0 +Load command 10 + cmd LC_SOURCE_VERSION + cmdsize 16 + version 0.0.0.0 +Load command 11 + cmd LC_MAIN + cmdsize 24 + entryoff 2048 + stacksize 0 +Load command 12 + cmd LC_FUNCTION_STARTS + cmdsize 16 + dataoff 8248 + datasize 8 +Load command 13 + cmd LC_DATA_IN_CODE + cmdsize 16 + dataoff 8256 + datasize 0 +Load command 14 + cmd LC_DYLIB_CODE_SIGN_DRS + cmdsize 16 + dataoff 8256 + datasize 0 +Load command 15 + cmd LC_LOAD_DYLIB + cmdsize 52 + name /usr/lib/libSystem.B.dylib + timestamp 2 + current version 79.9.1 + compatibility version 1.0.0 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_arm64.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_arm64.verified.txt new file mode 100644 index 0000000..37bcde3 --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_arm64.verified.txt @@ -0,0 +1,247 @@ +Mach header: + magic MH_MAGIC_64 + cputype Arm64 + cpusubtype 0x00000000 + filetype Execute + ncmds 17 + sizeofcmds 1384 + flags 0x200085 NoUndefs, DyldLink, TwoLevel, PositionIndependent + +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __PAGEZERO + vmaddr 0x00000000 + vmsize 0x100000000 + fileoff 0 + filesize 0 + maxprot --- + initprot --- + nsects 0 + flags 0x0 +Load command 1 + cmd LC_SEGMENT_64 + cmdsize 472 + segname __TEXT + vmaddr 0x100000000 + vmsize 0x00004000 + fileoff 0 + filesize 16384 + maxprot r-x + initprot r-x + nsects 5 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x100003f0c + size 0x00000064 + offset 16140 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __stubs + segname __TEXT + addr 0x100003f70 + size 0x0000000c + offset 16240 + align 2^2 (4) + reloff 0 + nreloc 0 + type SymbolStubs + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 12 +Section + sectname __stub_helper + segname __TEXT + addr 0x100003f7c + size 0x00000024 + offset 16252 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __cstring + segname __TEXT + addr 0x100003fa0 + size 0x00000010 + offset 16288 + align 2^0 (1) + reloff 0 + nreloc 0 + type CStringLiterals + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __unwind_info + segname __TEXT + addr 0x100003fb0 + size 0x00000050 + offset 16304 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 2 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __DATA_CONST + vmaddr 0x100004000 + vmsize 0x00004000 + fileoff 16384 + filesize 16384 + maxprot rw- + initprot rw- + nsects 1 + flags 0x10 +Section + sectname __got + segname __DATA_CONST + addr 0x100004000 + size 0x00000008 + offset 16384 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 1 + reserved2 0 +Load command 3 + cmd LC_SEGMENT_64 + cmdsize 232 + segname __DATA + vmaddr 0x100008000 + vmsize 0x00004000 + fileoff 32768 + filesize 16384 + maxprot rw- + initprot rw- + nsects 2 + flags 0x0 +Section + sectname __la_symbol_ptr + segname __DATA + addr 0x100008000 + size 0x00000008 + offset 32768 + align 2^3 (8) + reloff 0 + nreloc 0 + type LazySymbolPointers + attributes (none) + reserved1 2 + reserved2 0 +Section + sectname __data + segname __DATA + addr 0x100008008 + size 0x0000000c + offset 32776 + align 2^3 (8) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 4 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __LINKEDIT + vmaddr 0x10000c000 + vmsize 0x00004000 + fileoff 49152 + filesize 925 + maxprot r-- + initprot r-- + nsects 0 + flags 0x0 +Load command 5 + cmd LC_DYLD_INFO_ONLY + cmdsize 48 + rebase_off 49152 + rebase_size 8 + bind_off 49160 + bind_size 24 + lazy_bind_off 49184 + lazy_bind_size 16 + export_off 49200 + export_size 88 +Load command 6 + cmd LC_SYMTAB + cmdsize 24 + symoff 49296 + nsyms 7 + stroff 49424 + strsize 104 +Load command 7 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 1 + nextdefsym 4 + nundefsym 2 + indirectsymoff 49408 + nindirectsyms 3 +Load command 8 + cmd LC_LOAD_DYLINKER + cmdsize 32 + path /usr/lib/dyld +Load command 9 + cmd LC_UUID + cmdsize 24 + uuid CCF0B44A-CF74-3987-962B-B2EB1FD51B9A +Load command 10 + cmd LC_BUILD_VERSION + cmdsize 32 + platform MacOS + minos 11.0.0 + sdk 14.2.0 + ntools 1 + tool Ld + version 711.0.0 +Load command 11 + cmd LC_SOURCE_VERSION + cmdsize 16 + version 0.0.0.0 +Load command 12 + cmd LC_MAIN + cmdsize 24 + entryoff 16164 + stacksize 0 +Load command 13 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libSystem.B.dylib + timestamp 2 + current version 1336.61.1 + compatibility version 1.0.0 +Load command 14 + cmd LC_FUNCTION_STARTS + cmdsize 16 + dataoff 49288 + datasize 8 +Load command 15 + cmd LC_DATA_IN_CODE + cmdsize 16 + dataoff 49296 + datasize 0 +Load command 16 + cmd LC_CODE_SIGNATURE + cmdsize 16 + dataoff 49536 + datasize 541 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.o.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.o.verified.txt new file mode 100644 index 0000000..57766ec --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.o.verified.txt @@ -0,0 +1,106 @@ +Mach header: + magic MH_MAGIC_64 + cputype X86_64 + cpusubtype 0x00000003 + filetype Object + ncmds 4 + sizeofcmds 592 + flags 0x2000 SubsectionsViaSymbols + +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 472 + segname + vmaddr 0x00000000 + vmsize 0x00000100 + fileoff 624 + filesize 256 + maxprot rwx + initprot rwx + nsects 5 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x00000000 + size 0x00000042 + offset 624 + align 2^4 (16) + reloff 880 + nreloc 4 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __data + segname __DATA + addr 0x00000044 + size 0x00000004 + offset 692 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __cstring + segname __TEXT + addr 0x00000048 + size 0x00000010 + offset 696 + align 2^0 (1) + reloff 0 + nreloc 0 + type CStringLiterals + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __compact_unwind + segname __LD + addr 0x00000058 + size 0x00000040 + offset 712 + align 2^3 (8) + reloff 912 + nreloc 2 + type Regular + attributes Debug + reserved1 0 + reserved2 0 +Section + sectname __eh_frame + segname __TEXT + addr 0x00000098 + size 0x00000068 + offset 776 + align 2^3 (8) + reloff 0 + nreloc 0 + type Coalesced + attributes LiveSupport, StripStaticSyms, NoToc + reserved1 0 + reserved2 0 +Load command 1 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.13.0 + sdk 14.2.0 +Load command 2 + cmd LC_SYMTAB + cmdsize 24 + symoff 928 + nsyms 4 + stroff 992 + strsize 56 +Load command 3 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 3 + nundefsym 1 + indirectsymoff 0 + nindirectsyms 0 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.verified.txt new file mode 100644 index 0000000..2e8b939 --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=helloworld_x86_64.verified.txt @@ -0,0 +1,239 @@ +Mach header: + magic MH_MAGIC_64 + cputype X86_64 + cpusubtype 0x00000003 + filetype Execute + ncmds 15 + sizeofcmds 1360 + flags 0x200085 NoUndefs, DyldLink, TwoLevel, PositionIndependent + +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __PAGEZERO + vmaddr 0x00000000 + vmsize 0x100000000 + fileoff 0 + filesize 0 + maxprot --- + initprot --- + nsects 0 + flags 0x0 +Load command 1 + cmd LC_SEGMENT_64 + cmdsize 472 + segname __TEXT + vmaddr 0x100000000 + vmsize 0x00001000 + fileoff 0 + filesize 4096 + maxprot r-x + initprot r-x + nsects 5 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x100000f40 + size 0x00000042 + offset 3904 + align 2^4 (16) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __stubs + segname __TEXT + addr 0x100000f82 + size 0x00000006 + offset 3970 + align 2^1 (2) + reloff 0 + nreloc 0 + type SymbolStubs + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 6 +Section + sectname __stub_helper + segname __TEXT + addr 0x100000f88 + size 0x0000001a + offset 3976 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __cstring + segname __TEXT + addr 0x100000fa2 + size 0x00000010 + offset 4002 + align 2^0 (1) + reloff 0 + nreloc 0 + type CStringLiterals + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __unwind_info + segname __TEXT + addr 0x100000fb4 + size 0x00000048 + offset 4020 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 2 + cmd LC_SEGMENT_64 + cmdsize 392 + segname __DATA + vmaddr 0x100001000 + vmsize 0x00001000 + fileoff 4096 + filesize 4096 + maxprot rw- + initprot rw- + nsects 4 + flags 0x0 +Section + sectname __nl_symbol_ptr + segname __DATA + addr 0x100001000 + size 0x00000008 + offset 4096 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 1 + reserved2 0 +Section + sectname __got + segname __DATA + addr 0x100001008 + size 0x00000008 + offset 4104 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 2 + reserved2 0 +Section + sectname __la_symbol_ptr + segname __DATA + addr 0x100001010 + size 0x00000008 + offset 4112 + align 2^3 (8) + reloff 0 + nreloc 0 + type LazySymbolPointers + attributes (none) + reserved1 3 + reserved2 0 +Section + sectname __data + segname __DATA + addr 0x100001018 + size 0x00000004 + offset 4120 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 3 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __LINKEDIT + vmaddr 0x100002000 + vmsize 0x00001000 + fileoff 8192 + filesize 336 + maxprot r-- + initprot r-- + nsects 0 + flags 0x0 +Load command 4 + cmd LC_DYLD_INFO_ONLY + cmdsize 48 + rebase_off 8192 + rebase_size 8 + bind_off 8200 + bind_size 24 + lazy_bind_off 8224 + lazy_bind_size 16 + export_off 8240 + export_size 80 +Load command 5 + cmd LC_SYMTAB + cmdsize 24 + symoff 8328 + nsyms 6 + stroff 8440 + strsize 88 +Load command 6 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 4 + nundefsym 2 + indirectsymoff 8424 + nindirectsyms 4 +Load command 7 + cmd LC_LOAD_DYLINKER + cmdsize 32 + path /usr/lib/dyld +Load command 8 + cmd LC_UUID + cmdsize 24 + uuid 0DDA3E66-624D-3432-9527-F96F79E505F5 +Load command 9 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.13.0 + sdk 14.2.0 +Load command 10 + cmd LC_SOURCE_VERSION + cmdsize 16 + version 0.0.0.0 +Load command 11 + cmd LC_MAIN + cmdsize 24 + entryoff 3920 + stacksize 0 +Load command 12 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libSystem.B.dylib + timestamp 2 + current version 1336.61.1 + compatibility version 1.0.0 +Load command 13 + cmd LC_FUNCTION_STARTS + cmdsize 16 + dataoff 8320 + datasize 8 +Load command 14 + cmd LC_DATA_IN_CODE + cmdsize 16 + dataoff 8328 + datasize 0 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=libhelloworld_x86_64.dylib.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=libhelloworld_x86_64.dylib.verified.txt new file mode 100644 index 0000000..42a0382 --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=libhelloworld_x86_64.dylib.verified.txt @@ -0,0 +1,212 @@ +Mach header: + magic MH_MAGIC_64 + cputype X86_64 + cpusubtype 0x00000003 + filetype Dylib + ncmds 13 + sizeofcmds 1216 + flags 0x100085 NoUndefs, DyldLink, TwoLevel, NoReexportedDylibs + +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 472 + segname __TEXT + vmaddr 0x00000000 + vmsize 0x00001000 + fileoff 0 + filesize 4096 + maxprot r-x + initprot r-x + nsects 5 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x00000f60 + size 0x00000028 + offset 3936 + align 2^4 (16) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __stubs + segname __TEXT + addr 0x00000f88 + size 0x00000006 + offset 3976 + align 2^1 (2) + reloff 0 + nreloc 0 + type SymbolStubs + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 6 +Section + sectname __stub_helper + segname __TEXT + addr 0x00000f90 + size 0x0000001a + offset 3984 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Section + sectname __cstring + segname __TEXT + addr 0x00000faa + size 0x0000000b + offset 4010 + align 2^0 (1) + reloff 0 + nreloc 0 + type CStringLiterals + attributes (none) + reserved1 0 + reserved2 0 +Section + sectname __unwind_info + segname __TEXT + addr 0x00000fb8 + size 0x00000048 + offset 4024 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 1 + cmd LC_SEGMENT_64 + cmdsize 312 + segname __DATA + vmaddr 0x00001000 + vmsize 0x00001000 + fileoff 4096 + filesize 4096 + maxprot rw- + initprot rw- + nsects 3 + flags 0x0 +Section + sectname __nl_symbol_ptr + segname __DATA + addr 0x00001000 + size 0x00000008 + offset 4096 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 1 + reserved2 0 +Section + sectname __got + segname __DATA + addr 0x00001008 + size 0x00000008 + offset 4104 + align 2^3 (8) + reloff 0 + nreloc 0 + type NonLazySymbolPointers + attributes (none) + reserved1 2 + reserved2 0 +Section + sectname __la_symbol_ptr + segname __DATA + addr 0x00001010 + size 0x00000008 + offset 4112 + align 2^3 (8) + reloff 0 + nreloc 0 + type LazySymbolPointers + attributes (none) + reserved1 3 + reserved2 0 +Load command 2 + cmd LC_SEGMENT_64 + cmdsize 72 + segname __LINKEDIT + vmaddr 0x00002000 + vmsize 0x00001000 + fileoff 8192 + filesize 200 + maxprot r-- + initprot r-- + nsects 0 + flags 0x0 +Load command 3 + cmd LC_ID_DYLIB + cmdsize 64 + name /usr/local/lib/libhelloworld.dylib + timestamp 1 + current version 0.0.0 + compatibility version 0.0.0 +Load command 4 + cmd LC_DYLD_INFO_ONLY + cmdsize 48 + rebase_off 8192 + rebase_size 8 + bind_off 8200 + bind_size 24 + lazy_bind_off 8224 + lazy_bind_size 16 + export_off 8240 + export_size 32 +Load command 5 + cmd LC_SYMTAB + cmdsize 24 + symoff 8280 + nsyms 3 + stroff 8344 + strsize 48 +Load command 6 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 1 + nundefsym 2 + indirectsymoff 8328 + nindirectsyms 4 +Load command 7 + cmd LC_UUID + cmdsize 24 + uuid 81A0F075-659E-3944-9A81-0B05568D0635 +Load command 8 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.13.0 + sdk 14.2.0 +Load command 9 + cmd LC_SOURCE_VERSION + cmdsize 16 + version 0.0.0.0 +Load command 10 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libSystem.B.dylib + timestamp 2 + current version 1336.61.1 + compatibility version 1.0.0 +Load command 11 + cmd LC_FUNCTION_STARTS + cmdsize 16 + dataoff 8272 + datasize 8 +Load command 12 + cmd LC_DATA_IN_CODE + cmdsize 16 + dataoff 8280 + datasize 0 diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=unixthread_i386.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=unixthread_i386.verified.txt new file mode 100644 index 0000000..bfbb83a --- /dev/null +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=unixthread_i386.verified.txt @@ -0,0 +1,125 @@ +Mach header: + magic MH_MAGIC + cputype X86 + cpusubtype 0x00000003 + filetype Execute + ncmds 11 + sizeofcmds 700 + flags 0x12085 NoUndefs, DyldLink, TwoLevel, SubsectionsViaSymbols, BindsToWeak + +Load command 0 + cmd LC_SEGMENT + cmdsize 56 + segname __PAGEZERO + vmaddr 0x00000000 + vmsize 0x00001000 + fileoff 0 + filesize 0 + maxprot --- + initprot --- + nsects 0 + flags 0x4 +Load command 1 + cmd LC_SEGMENT + cmdsize 124 + segname __TEXT + vmaddr 0x00001000 + vmsize 0x00001000 + fileoff 0 + filesize 4096 + maxprot rwx + initprot r-x + nsects 1 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x00001800 + size 0x00000010 + offset 2048 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes SomeInstructions, PureInstructions + reserved1 0 + reserved2 0 +Load command 2 + cmd LC_SEGMENT + cmdsize 124 + segname __DATA + vmaddr 0x00002000 + vmsize 0x00001000 + fileoff 4096 + filesize 4096 + maxprot rwx + initprot rw- + nsects 1 + flags 0x0 +Section + sectname __data + segname __DATA + addr 0x00002000 + size 0x00000004 + offset 4096 + align 2^2 (4) + reloff 0 + nreloc 0 + type Regular + attributes (none) + reserved1 0 + reserved2 0 +Load command 3 + cmd LC_SEGMENT + cmdsize 56 + segname __LINKEDIT + vmaddr 0x00003000 + vmsize 0x00001000 + fileoff 8192 + filesize 56 + maxprot rwx + initprot r-- + nsects 0 + flags 0x0 +Load command 4 + cmd LC_SYMTAB + cmdsize 24 + symoff 8192 + nsyms 2 + stroff 8216 + strsize 32 +Load command 5 + cmd LC_DYSYMTAB + cmdsize 80 + nlocalsym 0 + nextdefsym 2 + nundefsym 0 + indirectsymoff 0 + nindirectsyms 0 +Load command 6 + cmd LC_LOAD_DYLINKER + cmdsize 28 + path /usr/lib/dyld +Load command 7 + cmd LC_UUID + cmdsize 24 + uuid 4B4F544F-5231-4D41-4300-000000000001 +Load command 8 + cmd LC_UNIXTHREAD + cmdsize 80 + flavor 1 + count 16 +Load command 9 + cmd LC_LOAD_DYLIB + cmdsize 52 + name /usr/lib/libSystem.B.dylib + timestamp 2 + current version 7.208.1 + compatibility version 1.0.0 +Load command 10 + cmd LC_LOAD_DYLIB + cmdsize 52 + name /usr/lib/libstdc++.6.dylib + timestamp 2 + current version 7.0.0 + compatibility version 7.0.0 diff --git a/src/LibObjectFile/MachO/MachOPrinter.cs b/src/LibObjectFile/MachO/MachOPrinter.cs new file mode 100644 index 0000000..7fa000a --- /dev/null +++ b/src/LibObjectFile/MachO/MachOPrinter.cs @@ -0,0 +1,275 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.IO; +using System.Linq; + +namespace LibObjectFile.MachO; + +/// +/// Extension methods for to print their layout in text form, similar to +/// otool -h -l. +/// +/// +/// The field names are the ones the format uses rather than the ones this library exposes, so +/// that output can be read next to otool's without translating between them. +/// +public static class MachOPrinter +{ + /// + /// Prints a to the specified writer. + /// + /// The image to print. + /// The destination text writer. + /// or is null. + public static void Print(this MachOFile file, TextWriter writer) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentNullException.ThrowIfNull(writer); + + PrintHeader(file, writer); + PrintLoadCommands(file, writer); + } + + /// + /// Prints the Mach-O header. + /// + /// The image to print. + /// The destination text writer. + /// or is null. + public static void PrintHeader(MachOFile file, TextWriter writer) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentNullException.ThrowIfNull(writer); + + writer.WriteLine("Mach header:"); + Field(writer, "magic", file.Is64Bit ? "MH_MAGIC_64" : "MH_MAGIC"); + Field(writer, "cputype", file.CpuType.ToString()); + Field(writer, "cpusubtype", $"0x{file.CpuSubType:x8}"); + Field(writer, "filetype", file.FileType.ToString()); + Field(writer, "ncmds", file.LoadCommands.Count.ToString()); + Field(writer, "sizeofcmds", file.SizeOfCommands.ToString()); + Field(writer, "flags", file.Flags == MachOHeaderFlags.None ? "0x0" : $"0x{(uint)file.Flags:x} {file.Flags}"); + writer.WriteLine(); + } + + /// + /// Prints every load command, in the order the image stores them. + /// + /// The image to print. + /// The destination text writer. + /// or is null. + public static void PrintLoadCommands(MachOFile file, TextWriter writer) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentNullException.ThrowIfNull(writer); + + for (var i = 0; i < file.LoadCommands.Count; i++) + { + var command = file.LoadCommands[i]; + writer.WriteLine($"Load command {i}"); + Field(writer, "cmd", DescribeType(command.Type)); + Field(writer, "cmdsize", command.Size.ToString()); + PrintCommandBody(command, writer); + } + } + + private static void PrintCommandBody(MachOLoadCommand command, TextWriter writer) + { + switch (command) + { + case MachOSegment segment: + PrintSegment(segment, writer); + break; + case MachODylibCommand dylib: + Field(writer, "name", dylib.Name); + Field(writer, "timestamp", dylib.Timestamp.ToString()); + Field(writer, "current version", MachOVersion.Decode(dylib.CurrentVersion).ToString()); + Field(writer, "compatibility version", MachOVersion.Decode(dylib.CompatibilityVersion).ToString()); + break; + case MachOPathCommand path: + Field(writer, "path", path.Path); + break; + case MachOSymbolTableCommand symtab: + Field(writer, "symoff", symtab.SymbolOffset.ToString()); + Field(writer, "nsyms", symtab.SymbolCount.ToString()); + Field(writer, "stroff", symtab.StringOffset.ToString()); + Field(writer, "strsize", symtab.StringSize.ToString()); + break; + case MachODynamicSymbolTableCommand dysymtab: + Field(writer, "nlocalsym", dysymtab.LocalSymbolCount.ToString()); + Field(writer, "nextdefsym", dysymtab.ExternalSymbolCount.ToString()); + Field(writer, "nundefsym", dysymtab.UndefinedSymbolCount.ToString()); + Field(writer, "indirectsymoff", dysymtab.IndirectSymbolOffset.ToString()); + Field(writer, "nindirectsyms", dysymtab.IndirectSymbolCount.ToString()); + break; + case MachODyldInfoCommand dyldInfo: + Field(writer, "rebase_off", dyldInfo.RebaseOffset.ToString()); + Field(writer, "rebase_size", dyldInfo.RebaseSize.ToString()); + Field(writer, "bind_off", dyldInfo.BindOffset.ToString()); + Field(writer, "bind_size", dyldInfo.BindSize.ToString()); + Field(writer, "lazy_bind_off", dyldInfo.LazyBindOffset.ToString()); + Field(writer, "lazy_bind_size", dyldInfo.LazyBindSize.ToString()); + Field(writer, "export_off", dyldInfo.ExportOffset.ToString()); + Field(writer, "export_size", dyldInfo.ExportSize.ToString()); + break; + case MachOLinkEditDataCommand linkEdit: + Field(writer, "dataoff", linkEdit.DataOffset.ToString()); + Field(writer, "datasize", linkEdit.DataSize.ToString()); + break; + case MachOMainCommand main: + Field(writer, "entryoff", main.EntryOffset.ToString()); + Field(writer, "stacksize", main.StackSize.ToString()); + break; + case MachOUuidCommand uuid: + Field(writer, "uuid", uuid.Uuid.ToString("D").ToUpperInvariant()); + break; + case MachOVersionMinCommand versionMin: + Field(writer, "version", versionMin.MinOS.ToString()); + Field(writer, "sdk", versionMin.Sdk.ToString()); + break; + case MachOBuildVersionCommand build: + Field(writer, "platform", build.Platform.ToString()); + Field(writer, "minos", build.MinOS.ToString()); + Field(writer, "sdk", build.Sdk.ToString()); + Field(writer, "ntools", build.Tools.Count.ToString()); + foreach (var tool in build.Tools) + { + Field(writer, "tool", tool.Tool.ToString()); + Field(writer, "version", tool.Version.ToString()); + } + break; + case MachOSourceVersionCommand source: + Field(writer, "version", source.Version.ToString()); + break; + case MachOTwoLevelHintsCommand hints: + Field(writer, "offset", hints.Offset.ToString()); + Field(writer, "nhints", hints.HintCount.ToString()); + break; + case MachOThreadCommand thread: + foreach (var state in thread.States) + { + Field(writer, "flavor", state.Flavor.ToString()); + Field(writer, "count", state.Registers.Length.ToString()); + } + break; + case MachOUnknownLoadCommand unknown: + Field(writer, "payload", $"{unknown.Payload.Length} bytes"); + break; + } + } + + private static void PrintSegment(MachOSegment segment, TextWriter writer) + { + Field(writer, "segname", segment.Name); + Field(writer, "vmaddr", $"0x{segment.VmAddress:x8}"); + Field(writer, "vmsize", $"0x{segment.VmSize:x8}"); + Field(writer, "fileoff", segment.FileOffset.ToString()); + Field(writer, "filesize", segment.FileSize.ToString()); + Field(writer, "maxprot", DescribeProtection(segment.MaxProtection)); + Field(writer, "initprot", DescribeProtection(segment.InitProtection)); + Field(writer, "nsects", segment.Sections.Count.ToString()); + Field(writer, "flags", $"0x{(uint)segment.SegmentFlags:x}"); + + foreach (var section in segment.Sections) + { + writer.WriteLine("Section"); + Field(writer, "sectname", section.Name); + Field(writer, "segname", section.SegmentName); + Field(writer, "addr", $"0x{section.Address:x8}"); + Field(writer, "size", $"0x{section.Size:x8}"); + Field(writer, "offset", section.FileOffset.ToString()); + Field(writer, "align", $"2^{section.Align} ({1 << (int)section.Align})"); + Field(writer, "reloff", section.RelocationOffset.ToString()); + Field(writer, "nreloc", section.NumberOfRelocations.ToString()); + Field(writer, "type", section.SectionType.ToString()); + Field(writer, "attributes", section.Attributes == MachOSectionAttributes.None ? "(none)" : section.Attributes.ToString()); + Field(writer, "reserved1", section.Reserved1.ToString()); + Field(writer, "reserved2", section.Reserved2.ToString()); + } + } + + /// + /// Names a command by its LC_ spelling rather than the enumeration's, so output can be + /// compared with otool directly. The mapping is written out rather than derived from + /// the enumeration names, because several do not follow from them: LC_UNIXTHREAD is + /// one word and LC_VERSION_MIN_MACOSX breaks in places the casing does not. + /// An unmodelled command keeps its raw value, so the output stays useful against an image + /// built by a newer linker. + /// + private static string DescribeType(MachOLoadCommandType type) => type switch + { + MachOLoadCommandType.Segment => "LC_SEGMENT", + MachOLoadCommandType.SymbolTable => "LC_SYMTAB", + MachOLoadCommandType.SymbolSegment => "LC_SYMSEG", + MachOLoadCommandType.Thread => "LC_THREAD", + MachOLoadCommandType.UnixThread => "LC_UNIXTHREAD", + MachOLoadCommandType.LoadFixedVMLibrary => "LC_LOADFVMLIB", + MachOLoadCommandType.IdFixedVMLibrary => "LC_IDFVMLIB", + MachOLoadCommandType.Identification => "LC_IDENT", + MachOLoadCommandType.FixedVMFile => "LC_FVMFILE", + MachOLoadCommandType.Prepage => "LC_PREPAGE", + MachOLoadCommandType.DynamicSymbolTable => "LC_DYSYMTAB", + MachOLoadCommandType.LoadDylib => "LC_LOAD_DYLIB", + MachOLoadCommandType.IdDylib => "LC_ID_DYLIB", + MachOLoadCommandType.LoadDylinker => "LC_LOAD_DYLINKER", + MachOLoadCommandType.IdDylinker => "LC_ID_DYLINKER", + MachOLoadCommandType.PreboundDylib => "LC_PREBOUND_DYLIB", + MachOLoadCommandType.Routines => "LC_ROUTINES", + MachOLoadCommandType.SubFramework => "LC_SUB_FRAMEWORK", + MachOLoadCommandType.SubUmbrella => "LC_SUB_UMBRELLA", + MachOLoadCommandType.SubClient => "LC_SUB_CLIENT", + MachOLoadCommandType.SubLibrary => "LC_SUB_LIBRARY", + MachOLoadCommandType.TwoLevelHints => "LC_TWOLEVEL_HINTS", + MachOLoadCommandType.PrebindChecksum => "LC_PREBIND_CKSUM", + MachOLoadCommandType.LoadWeakDylib => "LC_LOAD_WEAK_DYLIB", + MachOLoadCommandType.Segment64 => "LC_SEGMENT_64", + MachOLoadCommandType.Routines64 => "LC_ROUTINES_64", + MachOLoadCommandType.Uuid => "LC_UUID", + MachOLoadCommandType.RPath => "LC_RPATH", + MachOLoadCommandType.CodeSignature => "LC_CODE_SIGNATURE", + MachOLoadCommandType.SegmentSplitInfo => "LC_SEGMENT_SPLIT_INFO", + MachOLoadCommandType.ReexportDylib => "LC_REEXPORT_DYLIB", + MachOLoadCommandType.LazyLoadDylib => "LC_LAZY_LOAD_DYLIB", + MachOLoadCommandType.EncryptionInfo => "LC_ENCRYPTION_INFO", + MachOLoadCommandType.DyldInfo => "LC_DYLD_INFO", + MachOLoadCommandType.DyldInfoOnly => "LC_DYLD_INFO_ONLY", + MachOLoadCommandType.LoadUpwardDylib => "LC_LOAD_UPWARD_DYLIB", + MachOLoadCommandType.VersionMinMacOSX => "LC_VERSION_MIN_MACOSX", + MachOLoadCommandType.VersionMinIPhoneOS => "LC_VERSION_MIN_IPHONEOS", + MachOLoadCommandType.FunctionStarts => "LC_FUNCTION_STARTS", + MachOLoadCommandType.DyldEnvironment => "LC_DYLD_ENVIRONMENT", + MachOLoadCommandType.Main => "LC_MAIN", + MachOLoadCommandType.DataInCode => "LC_DATA_IN_CODE", + MachOLoadCommandType.SourceVersion => "LC_SOURCE_VERSION", + MachOLoadCommandType.DylibCodeSignDrs => "LC_DYLIB_CODE_SIGN_DRS", + MachOLoadCommandType.EncryptionInfo64 => "LC_ENCRYPTION_INFO_64", + MachOLoadCommandType.LinkerOption => "LC_LINKER_OPTION", + MachOLoadCommandType.LinkerOptimizationHint => "LC_LINKER_OPTIMIZATION_HINT", + MachOLoadCommandType.VersionMinTvOS => "LC_VERSION_MIN_TVOS", + MachOLoadCommandType.VersionMinWatchOS => "LC_VERSION_MIN_WATCHOS", + MachOLoadCommandType.Note => "LC_NOTE", + MachOLoadCommandType.BuildVersion => "LC_BUILD_VERSION", + MachOLoadCommandType.DyldExportsTrie => "LC_DYLD_EXPORTS_TRIE", + MachOLoadCommandType.DyldChainedFixups => "LC_DYLD_CHAINED_FIXUPS", + MachOLoadCommandType.FilesetEntry => "LC_FILESET_ENTRY", + MachOLoadCommandType.AtomInfo => "LC_ATOM_INFO", + _ => $"0x{(uint)type:x8}", + }; + + private static string DescribeProtection(MachOVmProtection protection) + { + if (protection == MachOVmProtection.None) return "---"; + + Span result = ['-', '-', '-']; + if ((protection & MachOVmProtection.Read) != 0) result[0] = 'r'; + if ((protection & MachOVmProtection.Write) != 0) result[1] = 'w'; + if ((protection & MachOVmProtection.Execute) != 0) result[2] = 'x'; + return new string(result); + } + + private static void Field(TextWriter writer, string name, string value) + => writer.WriteLine($"{name,22} {value}"); +} From 1b9d7cf82bf4663bdfd3614cc59713a2e270065e Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:27:37 -0400 Subject: [PATCH 11/32] Add doc for the Mach-O API The readme listed Mach-O among the formats left for contributors, and the manual did not mention it. Adds it to both, following how the other formats are covered: a feature list in the readme and a section in the manual with an overview, reading, writing, editing and signing. The overview states the relationship a caller has to know about, that a section's address is its segment's address plus the section's distance from the segment's file offset, since that is what decides which parts of an image a layout may move and why adding a load command is bounded by the padding the linker left. - readme.md: Mach-O added to the supported formats and dropped from the longer term plan - doc/readme.md: the manual section --- doc/readme.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 13 +++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/doc/readme.md b/doc/readme.md index 160e24f..5c86bb2 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -5,6 +5,7 @@ This is the manual of LibObjectFile with the following API covered: - [ELF Object File Format](#elf-object-file-format) via the `ElfFile` API - [Archive ar File Format](#archive-ar-file-format) via the `ArArchiveFile` API - [PE File Format](#pe-object-file-format) via the `PEFile` API +- [Mach-O File Format](#mach-o-object-file-format) via the `MachOFile` API ## ELF Object File Format @@ -545,3 +546,119 @@ Assert.AreEqual(156, process.ExitCode); ### Links - [PE and COFF Specification](https://docs.microsoft.com/en-us/windows/win32/debug/pe-format) + +## Mach-O Object File Format + +### Overview + +The main entry-point for reading/writing a Mach-O file is the [`MachOFile`](https://github.com/xoofx/LibObjectFile/blob/master/src/LibObjectFile/MachO/MachOFile.cs) class. + +This class is the equivalent of the Mach-O header and contains the load commands, which describe everything else in the image, along with the content those commands point at. + +#### Content + +Everything in a Mach-O image is a `MachOContent` in the ordered `MachOFile.Content` list: the header, the load command table, the padding after it, the bytes of each section, each table in `__LINKEDIT`, and any alignment padding in between. Every byte of the file belongs to one of them, so writing the list back out reproduces the file. + +Padding is kept as the bytes that were read rather than regenerated, because a linker pads executable sections with `nop` rather than zeros. + +#### Addresses and what a layout may move + +A section's address is its segment's address plus the section's distance from the segment's file offset: + +``` +section.Address - segment.VmAddress == section.FileOffset - segment.FileOffset +``` + +Moving a section in the file therefore moves it in memory, and every instruction and relocation referring to it becomes wrong. Content carrying an address is `IsPositionPinned` and a layout leaves it where it is. Only what nothing addresses, which in practice is `__LINKEDIT` and the file tail, is free to move. + +This is why a `MachOSegment` describes a mapping rather than owning bytes, and why adding a load command is bounded by the padding the linker left after the command table. `MachOFile.AvailableLoadCommandSpace` reports how much of it is left. + +#### Universal binaries + +A universal binary holds one image per architecture and is read with [`MachOFatFile`](https://github.com/xoofx/LibObjectFile/blob/master/src/LibObjectFile/MachO/MachOFatFile.cs). Use `MachOFatFile.IsFat` to tell the two apart: + +```csharp +if (MachOFatFile.IsFat(inputStream)) +{ + var fat = MachOFatFile.Read(inputStream); + foreach (var slice in fat.Slices) + { + Console.WriteLine($"{slice.CpuType}: {slice.File!.FileType}"); + } +} +``` + +Its header and slice table are stored big-endian whatever the architectures inside are, which is the one place the format departs from the image's own byte order. + +### Reading a Mach-O File + +The Mach-O API allows to read from a `System.IO.Stream` via the method `MachOFile.Read`: + +```csharp +MachOFile machO = MachOFile.Read(inputStream); +foreach (var segment in machO.Segments) +{ + Console.WriteLine($"{segment.Name} at 0x{segment.VmAddress:X}"); +} +``` + +Load commands this library does not model are kept as a `MachOUnknownLoadCommand` and written back verbatim, so an image built by a newer linker still round-trips. + +The symbol table, the indirect symbol table and the relocations of a section are decoded on demand: + +```csharp +foreach (var symbol in machO.ReadSymbolTable()) +{ + Console.WriteLine($"{symbol.Name} {symbol.Kind}"); +} +``` + +### Writing a Mach-O File + +A `MachOFile` is written back with `MachOFile.Write`: + +```csharp +machO.Write(outputStream); +``` + +Content is written at the offset recorded for it, so an image that has not been edited comes back byte for byte. Use `MachOFile.Verify` to check an image before writing it. + +### Editing load commands + +`MachOFile` offers the operations `install_name_tool` provides, appending to the padding the linker left so that no content moves: + +```csharp +machO.AddLoadDylib("@executable_path/../Frameworks/mylib.dylib"); +machO.AddRPath("@executable_path/../Frameworks"); +machO.ChangeDylibName("/usr/lib/libfoo.dylib", "@rpath/libfoo.dylib"); +``` + +A dependency is appended rather than inserted, because dyld identifies a library by the position of its command among the others and the symbol table binds against that number. Removing a dependency is not offered for the same reason. + +When the padding runs out these throw, naming the shortfall, rather than moving content and invalidating the image. There is no way around that short of relinking with `-headerpad_max_install_names`, which is also why `install_name_tool` fails in the same situation. + +### Code signing + +Apple Silicon refuses to execute an unsigned image, so an edited `arm64` binary has to be signed again to stay runnable: + +```csharp +machO.AddRPath("@executable_path/../Frameworks"); +machO.AdHocSign("mytool"); +machO.Write(outputStream); +``` + +An ad-hoc signature carries no certificate. It states only that the image hashes to what its code directory says, which is what lets the kernel give the process a stable identity. + +Signing has to be the last thing done before writing. Editing afterwards leaves the signature covering bytes that are no longer there, so `MachOFile.IsCodeSignatureStale` records it and writing fails until the image is signed again. + +### Printing a Mach-O File + +`MachOFile.Print` writes the header and every load command in a form close to `otool -h -l`: + +```csharp +machO.Print(Console.Out); +``` + +### Links + +- [OS X ABI Mach-O File Format Reference](https://github.com/aidansteele/osx-abi-macho-file-format-reference) diff --git a/readme.md b/readme.md index ad098e5..9d9887f 100644 --- a/readme.md +++ b/readme.md @@ -8,10 +8,11 @@ LibObjectFile is a .NET library to read, manipulate and write linker and executa > > - **PE** image file format (Portable Executable / DLL) > - **ELF** object-file format +> - **Mach-O** object-file format, including universal binaries > - **DWARF** debugging format (version 4) > - **Archive `ar`** file format (Common, GNU and BSD variants) > -> There is a longer term plan to support other file formats (e.g COFF, MACH-O, .lib) but as I don't +> There is a longer term plan to support other file formats (e.g COFF, .lib) but as I don't > have a need for them right now, it is left as an exercise for PR contributors! ;) ## Usage @@ -54,6 +55,16 @@ elf.Write(outStream); - Program headers with or without sections - `ElfFile.AddNeededLibrary` injects a `DT_NEEDED` dependency into an existing image without moving any section (address-preserving, `patchelf`-style) - Print with `readelf` similar output +- Good support for the **Mach-O file format**: + - Support byte-to-byte roundtrip + - Read and write from/to a `System.IO.Stream` + - 32 and 64 bit, `i386`, `x86_64` and `arm64` + - Universal (fat) binaries via `MachOFatFile` + - All load commands are decoded, and commands this library does not model round-trip verbatim + - Symbol table, indirect symbol table and relocations + - `MachOFile.AddLoadDylib`, `AddRPath` and `ChangeDylibName` edit an existing image without moving any section (address-preserving, `install_name_tool`-style) + - `MachOFile.AdHocSign` writes an ad-hoc code signature, which Apple Silicon requires in order to execute an image + - `MachOFile.Print` to print the content of a Mach-O file with `otool` similar output - Support for **DWARF debugging format**: - Partial support of Version 4 (currently still the default for GCC) - Support for the sections: `.debug_info`, `.debug_line`, `.debug_aranges`, `.debug_abbrev` and `.debug_str` From 749245d6e4398133f5c156bad5387fbcb5e336d6 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 13:36:09 -0400 Subject: [PATCH 12/32] Add doc for Gatekeeper and bundle sealing The signing section said an ad-hoc signature is what lets an edited arm64 image run, which is true of the kernel and not of Gatekeeper. A reader could reasonably take it to mean a signed bundle will launch, and then find that a downloaded one is refused whatever this library did to it. Says what an ad-hoc signature does not buy: no Developer ID, no notarization, and no way back to notarized once it is replaced. Also that signing one image is not sealing a bundle, and which of the two is at stake depends on what was edited. A bundle's main executable is not listed in the seal, so re-signing it leaves the seal correct, while nested code is listed by hash and editing it does not. - doc/readme.md: Gatekeeper, and sealing a bundle, under code signing --- doc/readme.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/doc/readme.md b/doc/readme.md index 5c86bb2..ceac047 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -651,6 +651,29 @@ An ad-hoc signature carries no certificate. It states only that the image hashes Signing has to be the last thing done before writing. Editing afterwards leaves the signature covering bytes that are no longer there, so `MachOFile.IsCodeSignatureStale` records it and writing fails until the image is signed again. +#### Gatekeeper + +There are two checks on the way to running a binary, and an ad-hoc signature only gets past one of them. + +The kernel is the first. On Apple Silicon it will not execute an unsigned image, and an ad-hoc signature is enough to satisfy it. That is the check an edited `arm64` binary needs signing for. + +Gatekeeper is the second, and ad-hoc will not get past it. There is no Developer ID behind the signature and it cannot be notarized. Anything downloaded carries a quarantine attribute, and Gatekeeper refuses it however it was signed here. Clearing the attribute is what lets it launch: + +```sh +xattr -d com.apple.quarantine MyApp.app +``` + +One thing worth knowing before reaching for this on a shipping app: re-signing throws away any notarization it came with, and signing it again will not bring that back. + +#### Sealing a bundle + +`AdHocSign` signs a single Mach-O image. A bundle seals the rest of its files separately, in `Contents/_CodeSignature/CodeResources`, and this library does not touch that seal. + +Whether that matters depends on what you edited. + +- **The main executable.** It is not in the seal, because its own signature already covers it. Re-sign it and the bundle stays consistent. +- **A nested framework or dylib.** These are in the seal, by hash. Edit one and the seal goes stale, and the bundle will fail verification until Apple's `codesign` reseals it. + ### Printing a Mach-O File `MachOFile.Print` writes the header and every load command in a form close to `otool -h -l`: From 411b98109b890439d71a652ab13920a752e822a3 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 15:15:16 -0400 Subject: [PATCH 13/32] Bound Mach-O load command reads to their declared size The header's sizeofcmds was ignored and each command was bounded only by the whole stream, so a command declaring a cmdsize too small for its own fields still read them, taking bytes that belong to whatever follows. A count-bearing command could do the same: a segment claiming more sections than fit, or a build version claiming more tools, read section headers and tool entries out of the commands after it. The table is now bounded by sizeofcmds and has to end exactly there, each command is checked against the size its fixed part needs before it is read, and a command that reads past its own cmdsize is reported rather than trusted. The counts inside segments and build versions are checked against the space the command declares. Also states what file offset remapping covers, since the contract claimed every recorded offset and did not include a section's relocations. Those are now mapped. Placement is deliberately excluded and says why: a segment's or a section's file offset is where something is mapped, not merely stored, so moving it moves the thing in memory. The remapping test read offsets back through the same walk it was testing, which made an omitted field invisible to it. It now reads them off the commands directly, and fails if the walk misses one. - src/LibObjectFile/MachO/MachOFile.Read.cs: the bounded walk - src/LibObjectFile/MachO/MachOLoadCommand.cs: the smallest cmdsize each kind can legally have - src/LibObjectFile/MachO/MachOSegment.cs: section counts bounded, section relocations remapped --- .../MachO/MachOSimpleTests.cs | 440 +++++++++++------- src/LibObjectFile/Diagnostics/DiagnosticId.cs | 2 + .../MachO/MachOBuildVersionCommand.cs | 12 + .../MachO/MachODyldInfoCommand.cs | 3 + .../MachO/MachODynamicSymbolTableCommand.cs | 3 + src/LibObjectFile/MachO/MachOFile.Read.cs | 55 ++- src/LibObjectFile/MachO/MachOFile.cs | 17 +- .../MachO/MachOLinkEditDataCommand.cs | 3 + src/LibObjectFile/MachO/MachOLoadCommand.cs | 11 + src/LibObjectFile/MachO/MachOMainCommand.cs | 3 + .../MachO/MachOPathLoadCommand.cs | 29 +- src/LibObjectFile/MachO/MachOSegment.cs | 32 ++ .../MachO/MachOSourceVersionCommand.cs | 3 + .../MachO/MachOSymbolTableCommand.cs | 3 + .../MachO/MachOTwoLevelHintsCommand.cs | 3 + src/LibObjectFile/MachO/MachOUuidCommand.cs | 3 + .../MachO/MachOVersionMinCommand.cs | 3 + 17 files changed, 427 insertions(+), 198 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 203fdea..9713a2b 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -75,6 +75,23 @@ public void ReportsSpaceLeftForNewLoadCommands() Assert.IsTrue(file.LoadCommands.OfType().All(d => d.Size >= d.MinimumSize)); } + /// + /// The snapshot shows the thread state's flavour and length, but not that the eleventh word + /// is the entry point, nor that the symbol table runs straight into the string table. + /// + [TestMethod] + public void ReadsThe32BitEntryPointAndTableLayout() + { + var file = LoadMachO("unixthread_i386"); + + var state = file.LoadCommands.OfType().Single().States.Single(); + Assert.AreEqual(1u, state.Flavor, "x86_THREAD_STATE32"); + Assert.AreEqual((uint)file.FindSegment("__TEXT")!.Sections.Single().Address, state.Registers[10]); + + var symtab = file.LoadCommands.OfType().Single(); + Assert.AreEqual(symtab.StringOffset, symtab.SymbolOffset + symtab.SymbolCount * MachOSymbolTableCommand.GetSymbolSize(file.Is64Bit)); + } + /// /// Packing is not visible in a printed image, since only the decoded version is shown. /// @@ -87,21 +104,215 @@ public void VersionPackingRoundTrips() Assert.ThrowsExactly(() => MachOVersion.Encode(new Version(1, 256, 0))); } + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("helloworld_arm64")] + [DataRow("chainedfixups_arm64")] + [DataRow("libhelloworld_x86_64.dylib")] + [DataRow("helloworld_x86_64.o")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + public void ReadWriteIsByteExact(string name) + { + var original = File.ReadAllBytes(GetFile(name)); + + using var input = new MemoryStream(original); + var file = MachOFile.Read(input); + + var output = new MemoryStream(); + file.Write(output); + + ByteArrayAssert.AreEqual(original, output.ToArray(), $"Invalid binary diff for {name} after read -> write"); + } + /// - /// The snapshot shows the thread state's flavour and length, but not that the eleventh word - /// is the entry point, nor that the symbol table runs straight into the string table. + /// Commands this code does not model have to survive verbatim, which is what keeps the reader + /// usable against images from a newer linker. The type used here is a made-up one, so that + /// modelling more real commands later cannot quietly void the test. /// [TestMethod] - public void ReadsThe32BitEntryPointAndTableLayout() + public void KeepsUnmodelledLoadCommandsVerbatim() { + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; + var file = LoadMachO("unixthread_i386"); + file.LoadCommands.Add(new MachOUnknownLoadCommand + { + Type = (MachOLoadCommandType)0x7F000001, + Size = 16, + Payload = payload, + }); + file.LoadCommands.Add(new MachOTwoLevelHintsCommand + { + Type = MachOLoadCommandType.TwoLevelHints, + Size = MachOTwoLevelHintsCommand.CommandSize, + Offset = 0x2000, + HintCount = 7, + }); - var state = file.LoadCommands.OfType().Single().States.Single(); - Assert.AreEqual(1u, state.Flavor, "x86_THREAD_STATE32"); - Assert.AreEqual((uint)file.FindSegment("__TEXT")!.Sections.Single().Address, state.Registers[10]); + var stream = new MemoryStream(); + file.Write(stream); + var reread = MachOFile.Read(new MemoryStream(stream.ToArray())); - var symtab = file.LoadCommands.OfType().Single(); - Assert.AreEqual(symtab.StringOffset, symtab.SymbolOffset + symtab.SymbolCount * MachOSymbolTableCommand.GetSymbolSize(file.Is64Bit)); + var survivor = reread.LoadCommands.OfType().Single(c => c.Type == (MachOLoadCommandType)0x7F000001); + CollectionAssert.AreEqual(payload, survivor.Payload); + + var hints = reread.LoadCommands.OfType().Single(); + Assert.AreEqual(0x2000u, hints.Offset); + Assert.AreEqual(7u, hints.HintCount); + } + /// + /// Every offset pointing at relocatable data has to move when the map says so. The offsets + /// are read back through the typed properties rather than through the same walk being tested, + /// so a field the walk does not reach shows up here as one that failed to move. + /// + [TestMethod] + [DataRow("helloworld_x86_64")] + [DataRow("chainedfixups_arm64")] + [DataRow("unixthread_i386")] + [DataRow("dyldinfo_i386")] + [DataRow("helloworld_x86_64.o")] + public void EveryRecordedFileOffsetIsReachable(string name) + { + const uint delta = 0x1000; + + var before = ReadOffsets(LoadMachO(name)); + Assert.AreNotEqual(0, before.Count, "the fixture records no file offsets at all"); + + var file = LoadMachO(name); + var placementBefore = file.Segments.Select(s => (s.FileOffset, Sections: s.Sections.Select(x => x.FileOffset).ToArray())).ToArray(); + + file.UpdateFileOffsets(offset => offset + delta); + + var after = ReadOffsets(file); + CollectionAssert.AreEqual(before.Select(o => o + delta).ToArray(), after.ToArray(), "an offset the walk should reach did not move"); + + // Placement is outside the contract: moving a segment or a section moves it in memory. + var placementAfter = file.Segments.Select(s => (s.FileOffset, Sections: s.Sections.Select(x => x.FileOffset).ToArray())).ToArray(); + for (var i = 0; i < placementBefore.Length; i++) + { + Assert.AreEqual(placementBefore[i].FileOffset, placementAfter[i].FileOffset, "a segment was moved"); + CollectionAssert.AreEqual(placementBefore[i].Sections, placementAfter[i].Sections, "a section was moved"); + } + + // An identity remap has to leave the image untouched. + var identity = LoadMachO(name); + identity.UpdateFileOffsets(offset => offset); + ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), WriteToArray(identity), "an identity remap changed the image"); + } + + /// + /// Reads the relocatable offsets straight off the commands, independently of the walk. + /// + private static List ReadOffsets(MachOFile file) + { + var offsets = new List(); + void Add(uint value) + { + if (value != 0) offsets.Add(value); + } + + foreach (var segment in file.Segments) + { + foreach (var section in segment.Sections) Add(section.RelocationOffset); + } + + foreach (var command in file.LoadCommands) + { + switch (command) + { + case MachOSymbolTableCommand symtab: + Add(symtab.SymbolOffset); Add(symtab.StringOffset); + break; + case MachODynamicSymbolTableCommand dysymtab: + Add(dysymtab.TableOfContentsOffset); Add(dysymtab.ModuleTableOffset); + Add(dysymtab.ExternalReferenceOffset); Add(dysymtab.IndirectSymbolOffset); + Add(dysymtab.ExternalRelocationOffset); Add(dysymtab.LocalRelocationOffset); + break; + case MachODyldInfoCommand dyldInfo: + Add(dyldInfo.RebaseOffset); Add(dyldInfo.BindOffset); Add(dyldInfo.WeakBindOffset); + Add(dyldInfo.LazyBindOffset); Add(dyldInfo.ExportOffset); + break; + case MachOLinkEditDataCommand data: + Add(data.DataOffset); + break; + case MachOTwoLevelHintsCommand hints: + Add(hints.Offset); + break; + } + } + + return offsets; + } + + + /// + /// Verification exists to catch the invariants nothing else does. The address check is the + /// one the format rests on: break it and the loader maps a section somewhere other than + /// where the code expects, which no round-trip test would notice. + /// + [TestMethod] + public void VerifyAcceptsRealImagesAndCatchesABrokenOne() + { + foreach (var name in new[] { "helloworld_x86_64", "helloworld_arm64", "unixthread_i386", "helloworld_x86_64.o" }) + { + var diagnostics = new DiagnosticBag(); + LoadMachO(name).Verify(diagnostics); + Assert.IsFalse(diagnostics.HasErrors, $"{name} should verify: {string.Join("; ", diagnostics.Messages)}"); + } + + var moved = LoadMachO("unixthread_i386"); + moved.FindSegment("__TEXT")!.Sections[0].Address += 4; + var broken = new DiagnosticBag(); + moved.Verify(broken); + Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); + + var overlong = LoadMachO("unixthread_i386"); + overlong.LoadCommands[0].Size += 1; + var misaligned = new DiagnosticBag(); + overlong.Verify(misaligned); + Assert.IsTrue(misaligned.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidCommandAlignment)); + + // A header edited on its own describes a section that is not there, and the round-trip + // would still match because the header and the bytes are written from what each holds. + var resized = LoadMachO("unixthread_i386"); + resized.FindSegment("__TEXT")!.Sections[0].Size += 8; + var mismatched = new DiagnosticBag(); + resized.Verify(mismatched); + Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); + } + + /// + /// The padding after the load command table is the only elastic thing in the file, so it has + /// to absorb exactly what the table gains and nothing else may shift. This is what an + /// install_name_tool-style edit depends on. + /// + [TestMethod] + public void GrowingTheCommandTableConsumesOnlyThePadding() + { + var file = LoadMachO("unixthread_i386"); + + var paddingBefore = file.LoadCommandPadding!.Size; + var tableEndBefore = file.LoadCommandsEndOffset; + var contentStart = file.ContentStartOffset; + var positionsBefore = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + + var added = file.AddRPath("@executable_path/../Frameworks"); + var image = WriteToArray(file); + + Assert.AreEqual(tableEndBefore + added.Size, file.LoadCommandsEndOffset); + Assert.AreEqual(paddingBefore - added.Size, file.LoadCommandPadding!.Size, "the padding did not absorb the new command"); + Assert.AreEqual(contentStart, file.ContentStartOffset, "content after the padding moved"); + + // Only the padding may have moved; everything after it stays exactly where it was. + var positionsAfter = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); + for (var i = 0; i < positionsBefore.Length; i++) + { + if (positionsBefore[i].Name == nameof(MachOLoadCommandPadding)) continue; + Assert.AreEqual(positionsBefore[i], positionsAfter[i], $"content {i} moved"); + } + + Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); } /// @@ -231,84 +442,10 @@ public void ReadsAndWritesAUniversalBinary() } /// - /// The padding after the load command table is the only elastic thing in the file, so it has - /// to absorb exactly what the table gains and nothing else may shift. This is what an - /// install_name_tool-style edit depends on. - /// - [TestMethod] - public void GrowingTheCommandTableConsumesOnlyThePadding() - { - var file = LoadMachO("unixthread_i386"); - - var paddingBefore = file.LoadCommandPadding!.Size; - var tableEndBefore = file.LoadCommandsEndOffset; - var contentStart = file.ContentStartOffset; - var positionsBefore = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); - - var added = file.AddRPath("@executable_path/../Frameworks"); - var image = WriteToArray(file); - - Assert.AreEqual(tableEndBefore + added.Size, file.LoadCommandsEndOffset); - Assert.AreEqual(paddingBefore - added.Size, file.LoadCommandPadding!.Size, "the padding did not absorb the new command"); - Assert.AreEqual(contentStart, file.ContentStartOffset, "content after the padding moved"); - - // Only the padding may have moved; everything after it stays exactly where it was. - var positionsAfter = file.Content.Select(c => (c.GetType().Name, c.Position)).ToArray(); - for (var i = 0; i < positionsBefore.Length; i++) - { - if (positionsBefore[i].Name == nameof(MachOLoadCommandPadding)) continue; - Assert.AreEqual(positionsBefore[i], positionsAfter[i], $"content {i} moved"); - } - - Assert.AreEqual(new FileInfo(GetFile("unixthread_i386")).Length, image.Length, "the file changed size"); - } - - /// - /// Verification exists to catch the invariants nothing else does. The address check is the - /// one the format rests on: break it and the loader maps a section somewhere other than - /// where the code expects, which no round-trip test would notice. + /// Snapshots the decoded form of each fixture. This covers every field of every command at + /// once, so decoding a new command extends the snapshot rather than needing another test, + /// and a change to how anything is read shows up as a diff rather than passing unnoticed. /// - [TestMethod] - public void VerifyAcceptsRealImagesAndCatchesABrokenOne() - { - foreach (var name in new[] { "helloworld_x86_64", "helloworld_arm64", "unixthread_i386", "helloworld_x86_64.o" }) - { - var diagnostics = new DiagnosticBag(); - LoadMachO(name).Verify(diagnostics); - Assert.IsFalse(diagnostics.HasErrors, $"{name} should verify: {string.Join("; ", diagnostics.Messages)}"); - } - - var moved = LoadMachO("unixthread_i386"); - moved.FindSegment("__TEXT")!.Sections[0].Address += 4; - var broken = new DiagnosticBag(); - moved.Verify(broken); - Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); - - var overlong = LoadMachO("unixthread_i386"); - overlong.LoadCommands[0].Size += 1; - var misaligned = new DiagnosticBag(); - overlong.Verify(misaligned); - Assert.IsTrue(misaligned.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidCommandAlignment)); - - // A header edited on its own describes a section that is not there, and the round-trip - // would still match because the header and the bytes are written from what each holds. - var resized = LoadMachO("unixthread_i386"); - resized.FindSegment("__TEXT")!.Sections[0].Size += 8; - var mismatched = new DiagnosticBag(); - resized.Verify(mismatched); - Assert.IsTrue(mismatched.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionContentMismatch)); - } - - [TestMethod] - public void RejectsANonMachOStream() - { - using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); - - Assert.IsFalse(MachOFile.IsMachO(input)); - Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); - Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); - } - [TestMethod] [DataRow("helloworld_x86_64")] [DataRow("helloworld_arm64")] @@ -317,114 +454,57 @@ public void RejectsANonMachOStream() [DataRow("helloworld_x86_64.o")] [DataRow("unixthread_i386")] [DataRow("dyldinfo_i386")] - public void ReadWriteIsByteExact(string name) + public async Task Prints(string name) { - var original = File.ReadAllBytes(GetFile(name)); - - using var input = new MemoryStream(original); - var file = MachOFile.Read(input); - - var output = new MemoryStream(); - file.Write(output); - - ByteArrayAssert.AreEqual(original, output.ToArray(), $"Invalid binary diff for {name} after read -> write"); + await VerifyMachO(LoadMachO(name), name); } - /// - /// Commands this code does not model have to survive verbatim, which is what keeps the reader - /// usable against images from a newer linker. The type used here is a made-up one, so that - /// modelling more real commands later cannot quietly void the test. - /// - [TestMethod] - public void KeepsUnmodelledLoadCommandsVerbatim() - { - var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; - - var file = LoadMachO("unixthread_i386"); - file.LoadCommands.Add(new MachOUnknownLoadCommand - { - Type = (MachOLoadCommandType)0x7F000001, - Size = 16, - Payload = payload, - }); - file.LoadCommands.Add(new MachOTwoLevelHintsCommand - { - Type = MachOLoadCommandType.TwoLevelHints, - Size = MachOTwoLevelHintsCommand.CommandSize, - Offset = 0x2000, - HintCount = 7, - }); - - var stream = new MemoryStream(); - file.Write(stream); - var reread = MachOFile.Read(new MemoryStream(stream.ToArray())); - - var survivor = reread.LoadCommands.OfType().Single(c => c.Type == (MachOLoadCommandType)0x7F000001); - CollectionAssert.AreEqual(payload, survivor.Payload); - - var hints = reread.LoadCommands.OfType().Single(); - Assert.AreEqual(0x2000u, hints.Offset); - Assert.AreEqual(7u, hints.HintCount); - } /// - /// A layout has to be able to move everything in __LINKEDIT, so no command may keep a - /// file offset the walk does not reach. Shifting them all and reading them back catches a - /// missed field, which would otherwise still point at where the data used to be. + /// A command is bounded by its own cmdsize and the table by the header's sizeofcmds. Without + /// that, a command declaring a size too small for its own fields still reads them, taking + /// bytes that belong to whatever follows and decoding something the file does not say. /// [TestMethod] - [DataRow("helloworld_x86_64")] - [DataRow("chainedfixups_arm64")] - [DataRow("unixthread_i386")] - [DataRow("dyldinfo_i386")] - public void EveryRecordedFileOffsetIsReachable(string name) - { - const uint delta = 0x1000; - - var originals = CollectOffsets(LoadMachO(name)); - Assert.AreNotEqual(0, originals.Count, "the fixture records no file offsets at all"); - - var shiftedFile = LoadMachO(name); - shiftedFile.UpdateFileOffsets(offset => offset + delta); - CollectionAssert.AreEqual(originals.Select(o => o + delta).ToArray(), CollectOffsets(shiftedFile).ToArray()); - - // An identity remap has to leave the image untouched. - var identity = LoadMachO(name); - identity.UpdateFileOffsets(offset => offset); - var stream = new MemoryStream(); - identity.Write(stream); - ByteArrayAssert.AreEqual(File.ReadAllBytes(GetFile(name)), stream.ToArray(), "an identity remap changed the image"); - } - - private static List CollectOffsets(MachOFile file) + public void RejectsMalformedLoadCommands() { - var offsets = new List(); - foreach (var command in file.LoadCommands) + static byte[] Patch(Action corrupt) { - command.UpdateFileOffsets(offset => - { - offsets.Add(offset); - return offset; - }); + var bytes = File.ReadAllBytes(GetFile("unixthread_i386")); + corrupt(bytes); + return bytes; } - return offsets; + + static uint Read(byte[] b, int offset) => BitConverter.ToUInt32(b, offset); + static void Write(byte[] b, int offset, uint value) => BitConverter.GetBytes(value).CopyTo(b, offset); + + // The first command is LC_SEGMENT at offset 28. Shrinking its cmdsize below its fixed + // part would have it read fields out of the command after it. + var tooSmall = Patch(b => Write(b, 32, 8)); + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(tooSmall), out _, out var d1)); + Assert.IsTrue(d1.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d1.Messages)); + + // A table that does not end where the header says means the walk and the header disagree + // about which bytes are commands. + var shortTable = Patch(b => Write(b, 20, Read(b, 20) - 8)); + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(shortTable), out _, out var d2)); + Assert.IsTrue(d2.Messages.Any(m => m.Id is DiagnosticId.MACHO_ERR_LoadCommandTableSizeMismatch + or DiagnosticId.MACHO_ERR_TruncatedLoadCommand), string.Join("; ", d2.Messages)); + + // A section count that does not fit the command would read section headers out of the + // commands after it. + var tooManySections = Patch(b => Write(b, 28 + 56 + 48, 40)); + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(tooManySections), out _, out var d3)); + Assert.IsTrue(d3.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d3.Messages)); } - /// - /// Snapshots the decoded form of each fixture. This covers every field of every command at - /// once, so decoding a new command extends the snapshot rather than needing another test, - /// and a change to how anything is read shows up as a diff rather than passing unnoticed. - /// [TestMethod] - [DataRow("helloworld_x86_64")] - [DataRow("helloworld_arm64")] - [DataRow("chainedfixups_arm64")] - [DataRow("libhelloworld_x86_64.dylib")] - [DataRow("helloworld_x86_64.o")] - [DataRow("unixthread_i386")] - [DataRow("dyldinfo_i386")] - public async Task Prints(string name) + public void RejectsANonMachOStream() { - await VerifyMachO(LoadMachO(name), name); + using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); + + Assert.IsFalse(MachOFile.IsMachO(input)); + Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); + Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); } } diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 9789903..8c69da2 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -233,4 +233,6 @@ public enum DiagnosticId MACHO_ERR_SectionOutsideSegment = 5015, MACHO_ERR_StaleCodeSignature = 5016, MACHO_ERR_SectionContentMismatch = 5017, + MACHO_ERR_LoadCommandTableSizeMismatch = 5018, + MACHO_ERR_LoadCommandOverread = 5019, } \ No newline at end of file diff --git a/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs index 24f2d95..8c0b407 100644 --- a/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs +++ b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs @@ -59,6 +59,9 @@ public sealed class MachOBuildVersionCommand : MachOLoadCommand /// public Version Sdk => MachOVersion.Decode(SdkVersion); + /// + public override uint MinimumCommandSize => HeaderSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = HeaderSize + (uint)Tools.Count * ToolSize; @@ -77,6 +80,15 @@ public override unsafe void Read(MachOReader reader) SdkVersion = raw.Sdk; Tools.Clear(); + + if (HeaderSize + raw.ToolCount * ToolSize > Size) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, + $"LC_BUILD_VERSION declares {raw.ToolCount} tools, which do not fit in its cmdsize of {Size}"); + return; + } + for (uint i = 0; i < raw.ToolCount; i++) { if (!reader.TryReadData(sizeof(RawBuildToolVersion), out RawBuildToolVersion tool)) diff --git a/src/LibObjectFile/MachO/MachODyldInfoCommand.cs b/src/LibObjectFile/MachO/MachODyldInfoCommand.cs index b3a8aed..06d70d7 100644 --- a/src/LibObjectFile/MachO/MachODyldInfoCommand.cs +++ b/src/LibObjectFile/MachO/MachODyldInfoCommand.cs @@ -65,6 +65,9 @@ public sealed class MachODyldInfoCommand : MachOLoadCommand /// public bool IsOnly => Type == MachOLoadCommandType.DyldInfoOnly; + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs b/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs index 9210ea6..8219526 100644 --- a/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs +++ b/src/LibObjectFile/MachO/MachODynamicSymbolTableCommand.cs @@ -97,6 +97,9 @@ public sealed class MachODynamicSymbolTableCommand : MachOLoadCommand /// Gets or sets the number of local relocation entries. public uint LocalRelocationCount { get; set; } + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs index c9c4c3b..5b4a353 100644 --- a/src/LibObjectFile/MachO/MachOFile.Read.cs +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -117,6 +117,7 @@ public override unsafe void Read(MachOReader reader) } uint numberOfCommands; + uint sizeOfCommands; if (Is64Bit) { if (!reader.TryReadData(sizeof(RawMachHeader64), out RawMachHeader64 header)) @@ -130,6 +131,7 @@ public override unsafe void Read(MachOReader reader) Flags = (MachOHeaderFlags)header.Flags; Reserved = header.Reserved; numberOfCommands = header.NumberOfCommands; + sizeOfCommands = header.SizeOfCommands; } else { @@ -143,24 +145,39 @@ public override unsafe void Read(MachOReader reader) FileType = (MachOFileType)header.FileType; Flags = (MachOHeaderFlags)header.Flags; numberOfCommands = header.NumberOfCommands; + sizeOfCommands = header.SizeOfCommands; } - ReadLoadCommands(reader, numberOfCommands); + ReadLoadCommands(reader, numberOfCommands, sizeOfCommands); if (reader.Diagnostics.HasErrors) return; ReadContent(reader); } - private void ReadLoadCommands(MachOReader reader, uint numberOfCommands) + /// + /// Walks the load command table. The table is bounded by the sizeofcmds the header + /// declares, each command by its own cmdsize, so a command cannot reach into the one + /// after it or into the content beyond the table. + /// + private void ReadLoadCommands(MachOReader reader, uint numberOfCommands, uint sizeOfCommands) { var commandAlignment = MachOLoadCommand.GetSizeAlignment(Is64Bit); + var tableEnd = HeaderSize + (ulong)sizeOfCommands; + + if (tableEnd > reader.Length) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_TruncatedLoadCommand, + $"The header declares {sizeOfCommands} bytes of load commands, which extend past the end of the file"); + return; + } for (uint i = 0; i < numberOfCommands; i++) { var commandPosition = reader.Position; - if (commandPosition + 8 > reader.Length) + if (commandPosition + 8 > tableEnd) { - reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} starts past the end of the file"); + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} starts past the end of the load command table"); return; } @@ -174,9 +191,9 @@ private void ReadLoadCommands(MachOReader reader, uint numberOfCommands) return; } - if (commandPosition + commandSize > reader.Length) + if (commandPosition + commandSize > tableEnd) { - reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} of size {commandSize} extends past the end of the file"); + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_TruncatedLoadCommand, $"Load command {i} of size {commandSize} extends past the end of the load command table"); return; } @@ -221,13 +238,39 @@ or MachOLoadCommandType.DylibCodeSignDrs command.Type = type; command.Position = commandPosition; command.Size = commandSize; + + if (commandSize < command.MinimumCommandSize) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, + $"Load command {i} of type {type} has a cmdsize of {commandSize}, which is smaller than the {command.MinimumCommandSize} bytes its fixed part needs"); + return; + } + LoadCommands.Add(command); command.Read(reader); if (reader.Diagnostics.HasErrors) return; + // A command that read past its own cmdsize took bytes belonging to the next one, so + // whatever it decoded is not what the file says. + if (reader.Position > commandPosition + commandSize) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_LoadCommandOverread, + $"Load command {i} of type {type} read to 0x{reader.Position:X}, past the 0x{commandPosition + commandSize:X} its cmdsize allows"); + return; + } + reader.Position = commandPosition + commandSize; } + + if (reader.Position != tableEnd) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_LoadCommandTableSizeMismatch, + $"The load commands end at 0x{reader.Position:X} but the header declares the table ends at 0x{tableEnd:X}"); + } } /// diff --git a/src/LibObjectFile/MachO/MachOFile.cs b/src/LibObjectFile/MachO/MachOFile.cs index 81857fa..262f11a 100644 --- a/src/LibObjectFile/MachO/MachOFile.cs +++ b/src/LibObjectFile/MachO/MachOFile.cs @@ -178,14 +178,21 @@ public ulong ContentStartOffset public long AvailableLoadCommandSpace => (long)ContentStartOffset - LoadCommandsEndOffset; /// - /// Rewrites every file offset recorded by the load commands, so content in - /// __LINKEDIT can be moved without each caller knowing which commands carry offsets. + /// Rewrites the file offsets that point at relocatable data: the tables in __LINKEDIT + /// and the relocations of each section. This lets that data be moved without each caller + /// knowing which commands record where it is. /// /// Maps an old file offset to its new one. /// - /// This does not move any bytes, and it does not touch section offsets, which are fixed by - /// their segment: a section's address is its segment's address plus its distance from the - /// segment's file offset, so moving a section moves its address with it. + /// Placement is deliberately not included. A segment's file offset and a section's file + /// offset say where something is mapped, not merely where it is stored: a section's address + /// is its segment's address plus its distance from the segment's file offset, so changing + /// either moves the thing in memory and invalidates what refers to it. Moving a segment or a + /// section is a different operation from relocating a blob nothing addresses, and this is + /// only the second one. + /// + /// No bytes are moved here; only the offsets recording where they are. + /// /// /// is null. public void UpdateFileOffsets(Func mapper) diff --git a/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs b/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs index 41a88f7..e85e0d5 100644 --- a/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs +++ b/src/LibObjectFile/MachO/MachOLinkEditDataCommand.cs @@ -36,6 +36,9 @@ public sealed class MachOLinkEditDataCommand : MachOLoadCommand /// public uint DataSize { get; set; } + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOLoadCommand.cs b/src/LibObjectFile/MachO/MachOLoadCommand.cs index 9c2a6f1..c0d91ec 100644 --- a/src/LibObjectFile/MachO/MachOLoadCommand.cs +++ b/src/LibObjectFile/MachO/MachOLoadCommand.cs @@ -21,6 +21,17 @@ public abstract class MachOLoadCommand : MachOObject /// public MachOLoadCommandType Type { get; set; } + /// + /// Gets the smallest cmdsize this kind of command can legally have, which is the size + /// of its fixed part. + /// + /// + /// A command declaring less than this cannot hold the fields its type is defined to have, so + /// reading it would consume bytes belonging to whatever follows. This is checked before a + /// command is read rather than after, since by then the damage is done. + /// + public virtual uint MinimumCommandSize => 8; + /// /// Rewrites every file offset this command records, so a layout can move the data they point /// at without each caller having to know which commands carry offsets. diff --git a/src/LibObjectFile/MachO/MachOMainCommand.cs b/src/LibObjectFile/MachO/MachOMainCommand.cs index 383800d..84c3b8c 100644 --- a/src/LibObjectFile/MachO/MachOMainCommand.cs +++ b/src/LibObjectFile/MachO/MachOMainCommand.cs @@ -34,6 +34,9 @@ public sealed class MachOMainCommand : MachOLoadCommand /// public ulong StackSize { get; set; } + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOPathLoadCommand.cs b/src/LibObjectFile/MachO/MachOPathLoadCommand.cs index 39741ee..0fb1221 100644 --- a/src/LibObjectFile/MachO/MachOPathLoadCommand.cs +++ b/src/LibObjectFile/MachO/MachOPathLoadCommand.cs @@ -46,17 +46,32 @@ public string Value /// protected abstract uint FixedSize { get; } + /// + public override uint MinimumCommandSize => FixedSize + 1; + /// /// Gets the smallest valid cmdsize for the current . /// - public uint MinimumSize + public uint MinimumSize => ComputeMinimumSize(Value); + + /// + /// Gets the smallest valid cmdsize this command would need to hold + /// . + /// + /// The string to size for. + /// The cmdsize required. + /// + /// This lets a caller find out whether a new value fits before assigning it, so an edit that + /// turns out not to fit does not leave the command half changed. + /// + /// is null. + public uint ComputeMinimumSize(string value) { - get - { - // The string is stored NUL-terminated, hence the extra byte. - var unaligned = FixedSize + (uint)Encoding.UTF8.GetByteCount(Value) + 1; - return AlignHelper.AlignUp(unaligned, GetSizeAlignment(Is64Bit)); - } + ArgumentNullException.ThrowIfNull(value); + + // The string is stored NUL-terminated, hence the extra byte. + var unaligned = FixedSize + (uint)Encoding.UTF8.GetByteCount(value) + 1; + return AlignHelper.AlignUp(unaligned, GetSizeAlignment(Is64Bit)); } /// diff --git a/src/LibObjectFile/MachO/MachOSegment.cs b/src/LibObjectFile/MachO/MachOSegment.cs index 201e3b6..04422d7 100644 --- a/src/LibObjectFile/MachO/MachOSegment.cs +++ b/src/LibObjectFile/MachO/MachOSegment.cs @@ -100,10 +100,31 @@ public static unsafe uint ComputeCommandSize(bool is64Bit, int sectionCount) ? (uint)(sizeof(RawSegmentCommand64) + sectionCount * sizeof(RawSection64)) : (uint)(sizeof(RawSegmentCommand32) + sectionCount * sizeof(RawSection32)); + /// + public override unsafe uint MinimumCommandSize + => Is64Bit ? (uint)sizeof(RawSegmentCommand64) : (uint)sizeof(RawSegmentCommand32); + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = ComputeCommandSize(Is64Bit, Sections.Count); + /// + /// + /// A section's relocations are a run of bytes elsewhere in the file, so their offset moves + /// with them. The section's own file offset is not mapped here: it is placement, fixed by + /// the address the section is mapped at, and cannot be changed without moving the section in + /// memory too. + /// + public override void UpdateFileOffsets(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + + foreach (var section in Sections) + { + if (section.RelocationOffset != 0) section.RelocationOffset = mapper(section.RelocationOffset); + } + } + /// public override unsafe void Read(MachOReader reader) { @@ -148,6 +169,17 @@ public override unsafe void Read(MachOReader reader) private unsafe void ReadSections(MachOReader reader, uint count) { Sections.Clear(); + + // The section headers follow the fixed part inside this command, so a count that does not + // fit would read whatever comes after it. + if (ComputeCommandSize(Is64Bit, (int)count) > Size) + { + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, + $"Segment {Name} declares {count} sections, which do not fit in its cmdsize of {Size}"); + return; + } + for (uint i = 0; i < count; i++) { var section = new MachOSection(); diff --git a/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs b/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs index 840f368..c3e37e9 100644 --- a/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs +++ b/src/LibObjectFile/MachO/MachOSourceVersionCommand.cs @@ -30,6 +30,9 @@ public sealed class MachOSourceVersionCommand : MachOLoadCommand /// public Version Version => MachOVersion.DecodeSource(SourceVersion); + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs b/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs index 4f47cc5..7442a53 100644 --- a/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs +++ b/src/LibObjectFile/MachO/MachOSymbolTableCommand.cs @@ -51,6 +51,9 @@ public sealed class MachOSymbolTableCommand : MachOLoadCommand /// 16 for a 64-bit image, 12 for a 32-bit one. public static uint GetSymbolSize(bool is64Bit) => is64Bit ? 16u : 12u; + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs b/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs index 8606497..539f9d6 100644 --- a/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs +++ b/src/LibObjectFile/MachO/MachOTwoLevelHintsCommand.cs @@ -37,6 +37,9 @@ public sealed class MachOTwoLevelHintsCommand : MachOLoadCommand /// public uint HintCount { get; set; } + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOUuidCommand.cs b/src/LibObjectFile/MachO/MachOUuidCommand.cs index f585ffc..d9d2fde 100644 --- a/src/LibObjectFile/MachO/MachOUuidCommand.cs +++ b/src/LibObjectFile/MachO/MachOUuidCommand.cs @@ -34,6 +34,9 @@ public sealed class MachOUuidCommand : MachOLoadCommand /// public Guid Uuid { get; set; } + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; diff --git a/src/LibObjectFile/MachO/MachOVersionMinCommand.cs b/src/LibObjectFile/MachO/MachOVersionMinCommand.cs index dfeb02e..a0ef3c1 100644 --- a/src/LibObjectFile/MachO/MachOVersionMinCommand.cs +++ b/src/LibObjectFile/MachO/MachOVersionMinCommand.cs @@ -45,6 +45,9 @@ public sealed class MachOVersionMinCommand : MachOLoadCommand /// public Version Sdk => MachOVersion.Decode(SdkVersion); + /// + public override uint MinimumCommandSize => CommandSize; + /// protected override void UpdateLayoutCore(MachOVisitorContext context) => Size = CommandSize; From 85d8c29f816cf9596f40a4d03c1ab55e984122ad Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 15:15:38 -0400 Subject: [PATCH 14/32] Follow the established write pipeline for Mach-O Writing did not verify and did not flush, so a model that Verify rejects could still be emitted, and a buffered stream could be left holding the tail of the file. ELF and PE both run Verify, then the layout, then the write, then flush. TryWrite now does the same, and the universal binary container flushes once its slices are written. Wiring Verify in exposed a hole signing was leaving. Aligning the signature to sixteen bytes can leave a gap before it, and nothing occupied that gap, so the content list no longer covered every byte of the file. The gap is now content like any other rather than a hole the writer happened to skip over. - src/LibObjectFile/MachO/MachOFile.Write.cs: verify, lay out, write, flush - src/LibObjectFile/MachO/MachOFile.Sign.cs: the alignment gap before a signature is content - src/LibObjectFile/MachO/MachOFatFile.cs: flush after the slices --- src/LibObjectFile/MachO/MachOFatFile.cs | 2 ++ src/LibObjectFile/MachO/MachOFile.Sign.cs | 10 ++++++++++ src/LibObjectFile/MachO/MachOFile.Write.cs | 17 +++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs index 3dce920..ff2e189 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -254,6 +254,8 @@ public void Write(Stream stream) { stream.SetLength(basePosition + end); } + + stream.Flush(); } /// diff --git a/src/LibObjectFile/MachO/MachOFile.Sign.cs b/src/LibObjectFile/MachO/MachOFile.Sign.cs index 662bbea..2664c83 100644 --- a/src/LibObjectFile/MachO/MachOFile.Sign.cs +++ b/src/LibObjectFile/MachO/MachOFile.Sign.cs @@ -84,6 +84,16 @@ public void AdHocSign(string identifier) linkEdit.FileSize = signatureOffset + signatureSize - linkEdit.FileOffset; linkEdit.VmSize = Math.Max(linkEdit.VmSize, linkEdit.FileSize); + // Aligning the signature can leave a gap, and every byte of the file has to belong to + // some content, so the padding is added rather than left as a hole in the list. + if (signatureOffset > contentEnd) + { + Content.Add(new MachOStreamContent(new MemoryStream(new byte[signatureOffset - contentEnd])) + { + Position = contentEnd, + }); + } + var placeholder = new MachOStreamContent(new MemoryStream(new byte[signatureSize])) { Position = signatureOffset }; Content.Add(placeholder); diff --git a/src/LibObjectFile/MachO/MachOFile.Write.cs b/src/LibObjectFile/MachO/MachOFile.Write.cs index ec710e1..675051c 100644 --- a/src/LibObjectFile/MachO/MachOFile.Write.cs +++ b/src/LibObjectFile/MachO/MachOFile.Write.cs @@ -40,15 +40,28 @@ public bool TryWrite(Stream stream, out DiagnosticBag diagnostics) var writer = new MachOWriter(this, stream, new DiagnosticBag()); diagnostics = writer.Diagnostics; + + Verify(writer.VisitorContext); + if (diagnostics.HasErrors) + { + return false; + } + + UpdateLayout(writer.VisitorContext); + if (diagnostics.HasErrors) + { + return false; + } + Write(writer); + stream.Flush(); + return !diagnostics.HasErrors; } /// public override void Write(MachOWriter writer) { - UpdateLayout(writer.VisitorContext); - if (IsCodeSignatureStale) { writer.Diagnostics.Error( From 2b2ad713735b2b3a91a79204bd8d279a92b3d9a1 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 15:15:38 -0400 Subject: [PATCH 15/32] Make Mach-O load command edits atomic on failure The edits assigned before they checked. ChangeDylibName and SetInstallName wrote the new name and only then asked whether it fits, so an edit that did not fit left the command renamed but not resized, describing a string longer than it has room for. AppendCommand marked the signature stale before finding out the command would not fit, so a failed edit left the image unable to be written until it was signed again. Each now works out what the change costs, checks there is room, and only then makes it, so a failure leaves the image exactly as it was. - src/LibObjectFile/MachO/MachOFile.Edit.cs: validate, then mutate - src/LibObjectFile.Tests/MachO/MachOEditingTests.cs: a failed rename leaves names, sizes and the written bytes untouched --- .../MachO/MachOEditingTests.cs | 30 +++++++++++++++++++ src/LibObjectFile/MachO/MachOFile.Edit.cs | 14 +++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs index 37a12ed..173f1a8 100644 --- a/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs @@ -105,6 +105,36 @@ public void AddingBeyondTheAvailableSpaceThrows() StringAssert.Contains(exception.Message, "free before the first section"); } + /// + /// An edit that does not fit has to leave the image exactly as it was. Assigning the new name + /// before checking there is room would leave the command renamed but not resized, describing + /// a string longer than it has space for. + /// + [TestMethod] + public void AnEditThatDoesNotFitChangesNothing() + { + var file = LoadMachO("unixthread_i386"); + + // Use up the room so that any growth fails. + while (file.AvailableLoadCommandSpace >= 64) + { + file.AddRPath("/" + new string('a', 40) + file.LoadCommands.Count); + } + + var before = WriteToArray(file); + var namesBefore = file.LinkedLibraries.Select(d => d.Name).ToArray(); + var sizesBefore = file.LinkedLibraries.Select(d => d.Size).ToArray(); + var space = file.AvailableLoadCommandSpace; + + var longName = "/" + new string('b', 200); + Assert.ThrowsExactly(() => file.ChangeDylibName("/usr/lib/libSystem.B.dylib", longName)); + + CollectionAssert.AreEqual(namesBefore, file.LinkedLibraries.Select(d => d.Name).ToArray(), "a name was changed by a failed edit"); + CollectionAssert.AreEqual(sizesBefore, file.LinkedLibraries.Select(d => d.Size).ToArray(), "a size was changed by a failed edit"); + Assert.AreEqual(space, file.AvailableLoadCommandSpace); + ByteArrayAssert.AreEqual(before, WriteToArray(file), "a failed edit changed the image"); + } + [TestMethod] public void ChangeDylibNameRepointsTheReference() { diff --git a/src/LibObjectFile/MachO/MachOFile.Edit.cs b/src/LibObjectFile/MachO/MachOFile.Edit.cs index 95804eb..7a75a37 100644 --- a/src/LibObjectFile/MachO/MachOFile.Edit.cs +++ b/src/LibObjectFile/MachO/MachOFile.Edit.cs @@ -128,16 +128,19 @@ public int ChangeDylibName(string oldName, string newName) var matches = LoadCommands.OfType().Where(c => c.Name == oldName).ToArray(); if (matches.Length == 0) return 0; + // Work out what the new name costs before assigning it, so an edit that does not fit + // leaves the image exactly as it was rather than half renamed. long extra = 0; foreach (var command in matches) { - command.Name = newName; - extra += Math.Max(0, (long)command.MinimumSize - (long)command.Size); + extra += Math.Max(0, (long)command.ComputeMinimumSize(newName) - (long)command.Size); } EnsureLoadCommandSpace(extra); + foreach (var command in matches) { + command.Name = newName; command.Size = Math.Max(command.Size, command.MinimumSize); } @@ -161,20 +164,21 @@ public void SetInstallName(string name) var command = IdDylib ?? throw new InvalidOperationException("This image has no LC_ID_DYLIB command, so it is not a dylib and has no install name."); + EnsureLoadCommandSpace(Math.Max(0, (long)command.ComputeMinimumSize(name) - (long)command.Size)); + command.Name = name; - EnsureLoadCommandSpace(Math.Max(0, (long)command.MinimumSize - (long)command.Size)); command.Size = Math.Max(command.Size, command.MinimumSize); MarkCodeSignatureStale(); } private void AppendCommand(MachOLoadCommand command) { - MarkCodeSignatureStale(); - command.Size = command is MachOPathLoadCommand path ? path.MinimumSize : command.Size; EnsureLoadCommandSpace((long)command.Size); + // Only once the command is known to fit, since a signature is not stale if nothing changed. LoadCommands.Add(command); + MarkCodeSignatureStale(); } /// From 69804c70e12ea7c55fe8bd71deee46759b0e57c1 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 15:15:38 -0400 Subject: [PATCH 16/32] Document why an ad-hoc signature keeps an empty CMS slot The empty CMS wrapper looked like it might not belong, since the linker-produced fixture here carries only a code directory. A linker writes a lone code directory flagged LINKER_SIGNED, which is a different thing from what signing a finished image produces: codesign writes a code directory, a twelve byte empty requirement set and an eight byte empty CMS wrapper, which is what a shipping ad-hoc signed dylib contains and what this writes. Says so where the wrapper is written, and checks the sizes of all three blobs rather than only their count. - src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs: why the slot is present and empty --- src/LibObjectFile.Tests/MachO/MachOSigningTests.cs | 9 +++++++++ .../MachO/CodeSign/MachOAdHocSignatureBuilder.cs | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs index 0938cc9..922d8fe 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs @@ -71,10 +71,19 @@ public void SignatureUsesTheAdHocCodeDirectoryFormat() var file = MachOFile.Read(new MemoryStream(image)); var command = file.CodeSignature!; + // Three blobs, which is what codesign produces for an ad-hoc signature: a code directory, + // an empty requirement set and an empty CMS wrapper. The wrapper is present and empty + // rather than omitted, matching a shipping ad-hoc signed dylib byte for byte in shape. var superBlob = image.AsSpan((int)command.DataOffset); Assert.AreEqual(EmbeddedSignatureMagic, BinaryPrimitives.ReadUInt32BigEndian(superBlob)); Assert.AreEqual(3u, BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8)), "code directory, requirements and CMS slots"); + var emptyRequirements = FindBlob(image, command.DataOffset, RequirementsSlot); + Assert.AreEqual(12, emptyRequirements.Length, "an empty requirement set"); + var cms = FindBlob(image, command.DataOffset, SignatureSlot); + Assert.AreEqual(BlobWrapperMagic, BinaryPrimitives.ReadUInt32BigEndian(cms)); + Assert.AreEqual(8, cms.Length, "an empty CMS wrapper, as codesign writes for ad-hoc"); + var codeDirectory = FindCodeDirectory(image, command.DataOffset); Assert.AreEqual(CodeDirectoryMagic, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory)); Assert.AreEqual(CodeDirectoryVersionExecSeg, BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(8))); diff --git a/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs index cb468d2..159c7a5 100644 --- a/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs +++ b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs @@ -161,9 +161,15 @@ private static void WriteRequirements(Span span) } /// - /// Writes the empty wrapper that would hold a CMS signature. It stays empty for an ad-hoc - /// signature, which is exactly what marks the signature as ad-hoc to a verifier. + /// Writes the empty wrapper that would hold a CMS signature. /// + /// + /// The slot is present and empty rather than omitted, which is what codesign produces + /// when it signs ad-hoc: a shipping ad-hoc signed dylib has exactly these three blobs, a code + /// directory, a twelve byte empty requirement set and this eight byte wrapper. A linker + /// writes something smaller still, a lone code directory flagged LINKER_SIGNED, but + /// that is a different thing from what signing a finished image produces. + /// private static void WriteBlobWrapper(Span span) { BinaryPrimitives.WriteUInt32BigEndian(span, BlobWrapperMagic); From c6123975e7fa950b0c3a98a345b94fcd8ce62c77 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 15:31:03 -0400 Subject: [PATCH 17/32] Bound the remaining reads sized by untrusted counts Reviewing the load command bounds turned up three more of the same kind elsewhere. A symbol, indirect symbol or relocation count was multiplied by an entry size in 32-bit arithmetic. A count large enough to wrap that product gave a small, plausible length that passed the coverage check, and the loop then read past what had been read. Those products are now computed in 64 bits, so the wrap cannot happen and an impossible length is reported instead. The symbol table count reached the caller as an ArgumentOutOfRangeException rather than a diagnostic. A universal binary header's slice count was used to size an allocation before anything checked the file was big enough to hold that many. It is now checked against the bytes actually there. Signing changed a good deal before it could fail: it removed the previous signature's content, moved the command, resized __LINKEDIT and cleared the stale flag, then wrote the image to take the digests, and that write can fail. What it changes is now put back if it does, so a failed signing leaves the image exactly as it was rather than half signed. - src/LibObjectFile/MachO/MachOFile.Symbols.cs: lengths computed in 64 bits - src/LibObjectFile/MachO/MachOFatFile.cs: the slice count checked before it sizes anything - src/LibObjectFile/MachO/MachOFile.Sign.cs: signing restores what it changed if it fails --- .../MachO/MachOSigningTests.cs | 17 ++ .../MachO/MachOSimpleTests.cs | 13 ++ src/LibObjectFile/MachO/MachOFatFile.cs | 9 +- .../MachO/MachOFile.Relocations.cs | 2 +- src/LibObjectFile/MachO/MachOFile.Sign.cs | 152 ++++++++++++------ src/LibObjectFile/MachO/MachOFile.Symbols.cs | 26 ++- 6 files changed, 161 insertions(+), 58 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs index 922d8fe..04a7f10 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs @@ -209,6 +209,23 @@ public void RejectsWhatCannotBeSigned() // An object file has no __LINKEDIT, so there is nowhere for a signature to live. Assert.ThrowsExactly(() => LoadMachO("helloworld_x86_64.o").AdHocSign("helloworld")); Assert.ThrowsExactly(() => LoadMachO("unixthread_i386").AdHocSign(string.Empty)); + + // Signing changes a good deal before it can fail, and a half-signed image is worse than + // an unsigned one, so a failure has to leave the image exactly as it was. + var file = LoadMachO("unixthread_i386"); + var before = WriteToArray(file); + var contents = file.Content.Count; + var commands = file.LoadCommands.Count; + + var section = file.FindSegment("__TEXT")!.Sections[0]; + section.Address += 4; + Assert.ThrowsExactly(() => file.AdHocSign("swkotor")); + section.Address -= 4; + + Assert.AreEqual(contents, file.Content.Count, "a failed signing left content behind"); + Assert.AreEqual(commands, file.LoadCommands.Count, "a failed signing left its load command behind"); + Assert.IsFalse(file.IsCodeSignatureStale, "a failed signing left the image marked stale"); + ByteArrayAssert.AreEqual(before, WriteToArray(file), "a failed signing changed the image"); } private static Span FindCodeDirectory(byte[] image, uint signatureOffset) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 9713a2b..0eb2c04 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -496,6 +496,19 @@ static byte[] Patch(Action corrupt) var tooManySections = Patch(b => Write(b, 28 + 56 + 48, 40)); Assert.IsFalse(MachOFile.TryRead(new MemoryStream(tooManySections), out _, out var d3)); Assert.IsTrue(d3.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d3.Messages)); + + // A count large enough that multiplying it by an entry size wraps a 32-bit product would + // otherwise pass a length check as a small number and then be read past. + var overflowing = LoadMachO("helloworld_x86_64"); + overflowing.LoadCommands.OfType().Single().SymbolCount = 0x10000001; + Assert.ThrowsExactly(() => overflowing.ReadSymbolTable()); + + // A universal binary header claiming more slices than the file holds must not be trusted + // to size anything before that is checked. + var fat = File.ReadAllBytes(GetFile("helloworld_fat")); + BitConverter.GetBytes(0x10000000u).Reverse().ToArray().CopyTo(fat, 4); + Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(fat), out _, out var d4)); + Assert.IsTrue(d4.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatHeader), string.Join("; ", d4.Messages)); } [TestMethod] diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs index ff2e189..96f16a8 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -133,7 +133,14 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? var count = BinaryPrimitives.ReadUInt32BigEndian(header.Slice(4)); var entrySize = GetSliceEntrySize(file.Is64BitOffsets); - var entries = new byte[count * (uint)entrySize]; + if ((ulong)count * (ulong)entrySize > (ulong)(stream.Length - stream.Position)) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatHeader, $"The universal binary header claims {count} architectures, which do not fit in the file"); + file = null; + return false; + } + + var entries = new byte[count * entrySize]; try { stream.ReadExactly(entries); diff --git a/src/LibObjectFile/MachO/MachOFile.Relocations.cs b/src/LibObjectFile/MachO/MachOFile.Relocations.cs index 06adc77..cc3d828 100644 --- a/src/LibObjectFile/MachO/MachOFile.Relocations.cs +++ b/src/LibObjectFile/MachO/MachOFile.Relocations.cs @@ -30,7 +30,7 @@ public IReadOnlyList ReadRelocations(MachOSection section) if (section.NumberOfRelocations == 0) return []; - var bytes = ReadFileBytes(section.RelocationOffset, section.NumberOfRelocations * MachORelocation.EntrySize, $"relocations of {section.SegmentName},{section.Name}"); + var bytes = ReadFileBytes(section.RelocationOffset, (ulong)section.NumberOfRelocations * MachORelocation.EntrySize, $"relocations of {section.SegmentName},{section.Name}"); var relocations = new MachORelocation[section.NumberOfRelocations]; for (var i = 0; i < relocations.Length; i++) { diff --git a/src/LibObjectFile/MachO/MachOFile.Sign.cs b/src/LibObjectFile/MachO/MachOFile.Sign.cs index 2664c83..a607df6 100644 --- a/src/LibObjectFile/MachO/MachOFile.Sign.cs +++ b/src/LibObjectFile/MachO/MachOFile.Sign.cs @@ -4,6 +4,7 @@ using System; using System.IO; +using System.Collections.Generic; using System.Linq; using LibObjectFile.MachO.CodeSign; using LibObjectFile.Utils; @@ -41,72 +42,121 @@ public void AdHocSign(string identifier) var text = FindSegment("__TEXT") ?? throw new InvalidOperationException("The image has no __TEXT segment, so the signature cannot describe its executable range."); + // Everything below can fail, and a half-signed image is worse than an unsigned one, so + // what changes is remembered first and put back if it does. The state is captured before + // the command is added, since adding one is itself a change to undo. + var previousFileSize = linkEdit.FileSize; + var previousVmSize = linkEdit.VmSize; + var previousStale = IsCodeSignatureStale; + var added = new List(); + MachOContent? removed = null; + var removedIndex = -1; + MachOLinkEditDataCommand? addedCommand = null; + var command = CodeSignature; - if (command is null) + var previousOffset = command?.DataOffset ?? 0; + var previousSize = command?.DataSize ?? 0; + + try { - command = new MachOLinkEditDataCommand + if (command is null) + { + command = new MachOLinkEditDataCommand + { + Type = MachOLoadCommandType.CodeSignature, + Size = MachOLinkEditDataCommand.CommandSize, + }; + AppendCommand(command); + addedCommand = command; + } + // A signature covers everything before it, so it has to be the last thing in the file. + // Whatever occupied that place before, including a previous signature, is replaced. + if (command.DataOffset != 0) { - Type = MachOLoadCommandType.CodeSignature, - Size = MachOLinkEditDataCommand.CommandSize, + for (var i = Content.Count - 1; i >= 0; i--) + { + if (Content[i].Position == command.DataOffset) + { + removed = Content[i]; + removedIndex = i; + Content.RemoveAt(i); + break; + } + } + } + + var contentEnd = ComputeFileSize(); + var signatureOffset = AlignHelper.AlignUp((uint)contentEnd, (uint)MachOCodeSignatureConstants.SignatureAlignment); + + var builder = new MachOAdHocSignatureBuilder(identifier) + { + CodeLimit = signatureOffset, + ExecSegmentBase = text.FileOffset, + ExecSegmentLimit = text.FileSize, + ExecSegmentFlags = FileType == MachOFileType.Execute ? MachOCodeSignatureConstants.ExecSegMainBinary : 0, }; - AppendCommand(command); - } + var signatureSize = builder.ComputeSize(); - // A signature covers everything before it, so it has to be the last thing in the file. - // Whatever occupied that place before, including a previous signature, is replaced. - if (command.DataOffset != 0) - { - for (var i = Content.Count - 1; i >= 0; i--) + command.DataOffset = signatureOffset; + command.DataSize = signatureSize; + + linkEdit.FileSize = signatureOffset + signatureSize - linkEdit.FileOffset; + linkEdit.VmSize = Math.Max(linkEdit.VmSize, linkEdit.FileSize); + + // Aligning the signature can leave a gap, and every byte of the file has to belong to + // some content, so the padding is added rather than left as a hole in the list. + if (signatureOffset > contentEnd) { - if (Content[i].Position == command.DataOffset) + var gap = new MachOStreamContent(new MemoryStream(new byte[signatureOffset - contentEnd])) { - Content.RemoveAt(i); - break; - } + Position = contentEnd, + }; + Content.Add(gap); + added.Add(gap); } - } - var contentEnd = ComputeFileSize(); - var signatureOffset = AlignHelper.AlignUp((uint)contentEnd, (uint)MachOCodeSignatureConstants.SignatureAlignment); + var placeholder = new MachOStreamContent(new MemoryStream(new byte[signatureSize])) { Position = signatureOffset }; + Content.Add(placeholder); + added.Add(placeholder); - var builder = new MachOAdHocSignatureBuilder(identifier) - { - CodeLimit = signatureOffset, - ExecSegmentBase = text.FileOffset, - ExecSegmentLimit = text.FileSize, - ExecSegmentFlags = FileType == MachOFileType.Execute ? MachOCodeSignatureConstants.ExecSegMainBinary : 0, - }; - var signatureSize = builder.ComputeSize(); - - command.DataOffset = signatureOffset; - command.DataSize = signatureSize; - - linkEdit.FileSize = signatureOffset + signatureSize - linkEdit.FileOffset; - linkEdit.VmSize = Math.Max(linkEdit.VmSize, linkEdit.FileSize); - - // Aligning the signature can leave a gap, and every byte of the file has to belong to - // some content, so the padding is added rather than left as a hole in the list. - if (signatureOffset > contentEnd) + // Signing is what makes the image match its signature again, so the edit is settled here + // rather than after the digests are taken. Writing checks this, and the digests are taken + // by writing the image out. + IsCodeSignatureStale = false; + + // The digests cover the image as it will finally be written, so the header, the command + // table and the signature's own offsets all have to be settled before they are taken. + var image = new MemoryStream(); + Write(image); + placeholder.Content = new MemoryStream(builder.Build(image.GetBuffer().AsSpan(0, (int)signatureOffset))); + } + catch { - Content.Add(new MachOStreamContent(new MemoryStream(new byte[signatureOffset - contentEnd])) + foreach (var content in added) { - Position = contentEnd, - }); - } + Content.Remove(content); + } - var placeholder = new MachOStreamContent(new MemoryStream(new byte[signatureSize])) { Position = signatureOffset }; - Content.Add(placeholder); + if (removed is not null) + { + Content.Insert(removedIndex, removed); + } - // Signing is what makes the image match its signature again, so the edit is settled here - // rather than after the digests are taken. Writing checks this, and the digests are taken - // by writing the image out. - IsCodeSignatureStale = false; + if (addedCommand is not null) + { + LoadCommands.Remove(addedCommand); + } + else if (command is not null) + { + command.DataOffset = previousOffset; + command.DataSize = previousSize; + } - // The digests cover the image as it will finally be written, so the header, the command - // table and the signature's own offsets all have to be settled before they are taken. - var image = new MemoryStream(); - Write(image); - placeholder.Content = new MemoryStream(builder.Build(image.GetBuffer().AsSpan(0, (int)signatureOffset))); + linkEdit.FileSize = previousFileSize; + linkEdit.VmSize = previousVmSize; + IsCodeSignatureStale = previousStale; + throw; + } } } diff --git a/src/LibObjectFile/MachO/MachOFile.Symbols.cs b/src/LibObjectFile/MachO/MachOFile.Symbols.cs index 1ec5d23..ad2d863 100644 --- a/src/LibObjectFile/MachO/MachOFile.Symbols.cs +++ b/src/LibObjectFile/MachO/MachOFile.Symbols.cs @@ -33,7 +33,7 @@ public IReadOnlyList ReadSymbolTable() if (command is null || command.SymbolCount == 0) return []; var entrySize = MachOSymbolTableCommand.GetSymbolSize(Is64Bit); - var entries = ReadFileBytes(command.SymbolOffset, command.SymbolCount * entrySize, "symbol table"); + var entries = ReadFileBytes(command.SymbolOffset, (ulong)command.SymbolCount * entrySize, "symbol table"); var strings = ReadFileBytes(command.StringOffset, command.StringSize, "string table"); var symbols = new List((int)command.SymbolCount); @@ -63,7 +63,7 @@ public IReadOnlyList ReadIndirectSymbolTable() var command = LoadCommands.OfType().FirstOrDefault(); if (command is null || command.IndirectSymbolCount == 0) return []; - var bytes = ReadFileBytes(command.IndirectSymbolOffset, command.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize, "indirect symbol table"); + var bytes = ReadFileBytes(command.IndirectSymbolOffset, (ulong)command.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize, "indirect symbol table"); var indices = new uint[command.IndirectSymbolCount]; for (var i = 0; i < indices.Length; i++) { @@ -118,10 +118,18 @@ private static string ReadString(byte[] strings, uint offset) /// /// Reads a run of bytes at a file offset out of whichever content covers it. /// - private byte[] ReadFileBytes(uint offset, uint length, string what) + private byte[] ReadFileBytes(uint offset, ulong length, string what) { if (length == 0) return []; + // The length is a count from the file multiplied by an entry size, so it is computed in + // 64 bits: a count large enough to wrap a 32-bit product would otherwise pass this check + // as a small length and then be read past. + if (length > int.MaxValue || offset + length > uint.MaxValue) + { + Throw(offset, length, what); + } + foreach (var content in Content) { if (content is not MachOStreamContent stream || offset < content.Position) continue; @@ -135,8 +143,16 @@ private byte[] ReadFileBytes(uint offset, uint length, string what) return buffer; } + Throw(offset, length, what); + return []; + } + + [System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void Throw(uint offset, ulong length, string what) + { + var message = $"The {what} at 0x{offset:X} for 0x{length:X} bytes is not covered by any content of this image."; var diagnostics = new DiagnosticBag(); - diagnostics.Error(DiagnosticId.MACHO_ERR_DataOutsideImage, $"The {what} at 0x{offset:X} for 0x{length:X} bytes is not covered by any content of this image."); - throw new ObjectFileException($"The {what} at 0x{offset:X} for 0x{length:X} bytes is not covered by any content of this image.", diagnostics); + diagnostics.Error(DiagnosticId.MACHO_ERR_DataOutsideImage, message); + throw new ObjectFileException(message, diagnostics); } } From 9f27c269aa3a1f0170ce86cd4cfd24b57eb7ef92 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Wed, 2 Sep 2026 16:08:53 -0400 Subject: [PATCH 18/32] Derive load command count bounds without multiplying The count checks added with the previous bounds could be walked around by the arithmetic in the checks themselves. A segment declaring 0x40000001 sections multiplies out to 124 in 32 bits, the size of the command being checked, so the check passed and the section headers were read out of the commands after it. A build version declaring 0x20000000 tools multiplies out to nothing at all. The largest count that fits is now derived by division, so there is no product to wrap. ComputeCommandSize computes wide and rejects a count that would not fit rather than returning a wrapped size, since it is public and a caller can reach it directly. The same widening was missing where the reader gathers the regions the commands point at: those sizes were counts times an entry size in 32-bit arithmetic, so a wrapped product would have named a small region and left the rest of the table looking like padding. - src/LibObjectFile/MachO/MachOSegment.cs: section count derived by division, command size computed wide - src/LibObjectFile/MachO/MachOBuildVersionCommand.cs: tool count derived by division - src/LibObjectFile/MachO/MachOFile.Read.cs: region sizes computed wide - src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs: the two overflow counts, which fail against the multiplying checks --- .../MachO/MachOSimpleTests.cs | 28 +++++++++++++ .../MachO/MachOBuildVersionCommand.cs | 4 +- src/LibObjectFile/MachO/MachOFile.Read.cs | 19 +++++---- src/LibObjectFile/MachO/MachOSegment.cs | 39 ++++++++++++++++--- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 0eb2c04..7c252a6 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -478,6 +478,21 @@ static byte[] Patch(Action corrupt) static uint Read(byte[] b, int offset) => BitConverter.ToUInt32(b, offset); static void Write(byte[] b, int offset, uint value) => BitConverter.GetBytes(value).CopyTo(b, offset); + // Finds where a load command starts, so the patches below do not depend on offsets that + // would silently point at the wrong field if a fixture were regenerated. + static int FindCommand(byte[] b, uint type, int skip) + { + var is64 = Read(b, 0) == 0xfeedfacf; + var offset = is64 ? 32 : 28; + for (var i = 0; i < Read(b, 16); i++) + { + if (Read(b, offset) == type && skip-- == 0) return offset; + offset += (int)Read(b, offset + 4); + } + + throw new InvalidOperationException($"No load command of type 0x{type:X} in the fixture"); + } + // The first command is LC_SEGMENT at offset 28. Shrinking its cmdsize below its fixed // part would have it read fields out of the command after it. var tooSmall = Patch(b => Write(b, 32, 8)); @@ -497,6 +512,19 @@ static byte[] Patch(Action corrupt) Assert.IsFalse(MachOFile.TryRead(new MemoryStream(tooManySections), out _, out var d3)); Assert.IsTrue(d3.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d3.Messages)); + // A count chosen so that multiplying it out wraps: 0x40000001 sections at 68 bytes each + // comes back to 124, the size of the command being checked, so a check that multiplies + // would let it through and the section headers would be read out of the commands after it. + var wrappingSections = Patch(b => Write(b, FindCommand(b, 0x1, skip: 1) + 48, 0x40000001)); + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(wrappingSections), out _, out var d5)); + Assert.IsTrue(d5.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d5.Messages)); + + // The same for a tool count: 0x20000000 tools at 8 bytes each wraps to nothing at all. + var arm64 = File.ReadAllBytes(GetFile("helloworld_arm64")); + Write(arm64, FindCommand(arm64, 0x32, skip: 0) + 20, 0x20000000); + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(arm64), out _, out var d6)); + Assert.IsTrue(d6.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidLoadCommandSize), string.Join("; ", d6.Messages)); + // A count large enough that multiplying it by an entry size wraps a 32-bit product would // otherwise pass a length check as a small number and then be read past. var overflowing = LoadMachO("helloworld_x86_64"); diff --git a/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs index 8c0b407..5da89b4 100644 --- a/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs +++ b/src/LibObjectFile/MachO/MachOBuildVersionCommand.cs @@ -81,7 +81,9 @@ public override unsafe void Read(MachOReader reader) Tools.Clear(); - if (HeaderSize + raw.ToolCount * ToolSize > Size) + // Derived by division for the same reason as the section count: multiplying the declared + // count out could wrap past the 32-bit field and pass a check it should not. + if (Size < HeaderSize || raw.ToolCount > (Size - HeaderSize) / ToolSize) { reader.Diagnostics.Error( DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs index 5b4a353..44fcd10 100644 --- a/src/LibObjectFile/MachO/MachOFile.Read.cs +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -339,6 +339,9 @@ private List CollectKnownRegions(MachOReader reader, ulong commands { var regions = new List(); + // Sizes are counts from the file times an entry size, computed wide: a count large enough + // to wrap a 32-bit product would otherwise name a small region and leave the rest of the + // table looking like padding. void Add(ulong offset, ulong size, MachOSection? section = null) { // An offset of zero means the data is absent rather than at the start of the file. @@ -364,7 +367,7 @@ void Add(ulong offset, ulong size, MachOSection? section = null) Add(section.FileOffset, section.Size, section); } - Add(section.RelocationOffset, section.NumberOfRelocations * (ulong)MachORelocation.EntrySize); + Add(section.RelocationOffset, (ulong)section.NumberOfRelocations * MachORelocation.EntrySize); } } @@ -377,12 +380,12 @@ void Add(ulong offset, ulong size, MachOSection? section = null) Add(symtab.StringOffset, symtab.StringSize); break; case MachODynamicSymbolTableCommand dysymtab: - Add(dysymtab.TableOfContentsOffset, dysymtab.TableOfContentsCount * MachODynamicSymbolTableCommand.TableOfContentsEntrySize); - Add(dysymtab.ModuleTableOffset, dysymtab.ModuleTableCount * MachODynamicSymbolTableCommand.GetModuleTableEntrySize(Is64Bit)); - Add(dysymtab.ExternalReferenceOffset, dysymtab.ExternalReferenceCount * MachODynamicSymbolTableCommand.ExternalReferenceEntrySize); - Add(dysymtab.IndirectSymbolOffset, dysymtab.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize); - Add(dysymtab.ExternalRelocationOffset, dysymtab.ExternalRelocationCount * MachORelocation.EntrySize); - Add(dysymtab.LocalRelocationOffset, dysymtab.LocalRelocationCount * MachORelocation.EntrySize); + Add(dysymtab.TableOfContentsOffset, (ulong)dysymtab.TableOfContentsCount * MachODynamicSymbolTableCommand.TableOfContentsEntrySize); + Add(dysymtab.ModuleTableOffset, (ulong)dysymtab.ModuleTableCount * MachODynamicSymbolTableCommand.GetModuleTableEntrySize(Is64Bit)); + Add(dysymtab.ExternalReferenceOffset, (ulong)dysymtab.ExternalReferenceCount * MachODynamicSymbolTableCommand.ExternalReferenceEntrySize); + Add(dysymtab.IndirectSymbolOffset, (ulong)dysymtab.IndirectSymbolCount * MachODynamicSymbolTableCommand.IndirectSymbolEntrySize); + Add(dysymtab.ExternalRelocationOffset, (ulong)dysymtab.ExternalRelocationCount * MachORelocation.EntrySize); + Add(dysymtab.LocalRelocationOffset, (ulong)dysymtab.LocalRelocationCount * MachORelocation.EntrySize); break; case MachODyldInfoCommand dyldInfo: Add(dyldInfo.RebaseOffset, dyldInfo.RebaseSize); @@ -395,7 +398,7 @@ void Add(ulong offset, ulong size, MachOSection? section = null) Add(data.DataOffset, data.DataSize); break; case MachOTwoLevelHintsCommand hints: - Add(hints.Offset, hints.HintCount * MachOTwoLevelHintsCommand.HintSize); + Add(hints.Offset, (ulong)hints.HintCount * MachOTwoLevelHintsCommand.HintSize); break; } } diff --git a/src/LibObjectFile/MachO/MachOSegment.cs b/src/LibObjectFile/MachO/MachOSegment.cs index 04422d7..ae0e26a 100644 --- a/src/LibObjectFile/MachO/MachOSegment.cs +++ b/src/LibObjectFile/MachO/MachOSegment.cs @@ -95,10 +95,37 @@ public MachOSegment() /// Whether the segment uses the 64-bit layout. /// The number of sections in the segment. /// The value to store in cmdsize. + /// + /// is negative, or so large that the command would not fit + /// in the 32-bit cmdsize field. + /// public static unsafe uint ComputeCommandSize(bool is64Bit, int sectionCount) - => is64Bit - ? (uint)(sizeof(RawSegmentCommand64) + sectionCount * sizeof(RawSection64)) - : (uint)(sizeof(RawSegmentCommand32) + sectionCount * sizeof(RawSection32)); + { + ArgumentOutOfRangeException.ThrowIfNegative(sectionCount); + + // Computed wide so that a count large enough to overflow is rejected rather than + // wrapping to a small, plausible-looking size. + var total = GetFixedCommandSize(is64Bit) + (ulong)sectionCount * GetSectionSize(is64Bit); + ArgumentOutOfRangeException.ThrowIfGreaterThan(total, uint.MaxValue, nameof(sectionCount)); + + return (uint)total; + } + + /// + /// Gets the size of a segment command excluding its section headers. + /// + /// Whether the segment uses the 64-bit layout. + /// The size of a segment_command_64 or a segment_command. + public static unsafe uint GetFixedCommandSize(bool is64Bit) + => is64Bit ? (uint)sizeof(RawSegmentCommand64) : (uint)sizeof(RawSegmentCommand32); + + /// + /// Gets the size of one section header. + /// + /// Whether the containing segment uses the 64-bit layout. + /// The size of a section_64 or a section. + public static unsafe uint GetSectionSize(bool is64Bit) + => is64Bit ? (uint)sizeof(RawSection64) : (uint)sizeof(RawSection32); /// public override unsafe uint MinimumCommandSize @@ -171,8 +198,10 @@ private unsafe void ReadSections(MachOReader reader, uint count) Sections.Clear(); // The section headers follow the fixed part inside this command, so a count that does not - // fit would read whatever comes after it. - if (ComputeCommandSize(Is64Bit, (int)count) > Size) + // fit would read whatever comes after it. The largest count that fits is derived by + // division: multiplying the declared count out could wrap and pass a check it should not. + var fixedSize = GetFixedCommandSize(Is64Bit); + if (Size < fixedSize || count > (Size - fixedSize) / GetSectionSize(Is64Bit)) { reader.Diagnostics.Error( DiagnosticId.MACHO_ERR_InvalidLoadCommandSize, From d9d630d380f84ece71159e0716929a3ec80684a2 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:15:45 -0400 Subject: [PATCH 19/32] Refactor Mach-O diagnostic ids to match their use sites MACHO_ERR_InvalidSegmentFileRange was minted with the rest of the block before the readers existed and was never raised. Give 5004 the condition that had been borrowing MACHO_ERR_ValueTooLargeFor32Bit: a segment whose width does not match the image it sits in is not a value that failed to fit a field. MACHO_ERR_InvalidSectionFileRange is raised from CollectKnownRegions for every content region, so name it after what it checks. --- src/LibObjectFile/Diagnostics/DiagnosticId.cs | 4 ++-- src/LibObjectFile/MachO/MachOFile.Read.cs | 2 +- src/LibObjectFile/MachO/MachOFile.Verify.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 8c69da2..6381892 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -219,8 +219,8 @@ public enum DiagnosticId MACHO_ERR_UnsupportedByteOrder = 5001, MACHO_ERR_InvalidLoadCommandSize = 5002, MACHO_ERR_TruncatedLoadCommand = 5003, - MACHO_ERR_InvalidSegmentFileRange = 5004, - MACHO_ERR_InvalidSectionFileRange = 5005, + MACHO_ERR_InvalidImageBitness = 5004, + MACHO_ERR_InvalidContentFileRange = 5005, MACHO_ERR_InvalidFatHeader = 5006, MACHO_ERR_InvalidFatArchRange = 5007, MACHO_ERR_NoRoomForLoadCommands = 5008, diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs index 44fcd10..9deb9d4 100644 --- a/src/LibObjectFile/MachO/MachOFile.Read.cs +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -350,7 +350,7 @@ void Add(ulong offset, ulong size, MachOSection? section = null) if (offset + size > fileLength) { reader.Diagnostics.Error( - DiagnosticId.MACHO_ERR_InvalidSectionFileRange, + DiagnosticId.MACHO_ERR_InvalidContentFileRange, $"Content at 0x{offset:X} for 0x{size:X} bytes extends past the end of the file"); return; } diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs index 7de63c5..91c095a 100644 --- a/src/LibObjectFile/MachO/MachOFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -113,7 +113,7 @@ private void VerifySegments(MachOVisitorContext context) if (segment.Is64Bit != Is64Bit) { context.Diagnostics.Error( - DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + DiagnosticId.MACHO_ERR_InvalidImageBitness, $"Segment {segment.Name} is {(segment.Is64Bit ? "64" : "32")}-bit in a {(Is64Bit ? "64" : "32")}-bit image"); } From 73e8bf6460f5701f1f13603d61cf9371bb4f15a0 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:19:06 -0400 Subject: [PATCH 20/32] Add verify and try-write to the Mach-O universal binary MachOFatFile was the one writable type in the library with no Verify and no TryWrite, so a malformed universal binary was written out rather than reported and a slice failure surfaced as an exception from the middle of a write. Verify covers what only the containing file can see: slices overlapping each other or the table they are listed in, an offset off the slice's own alignment, a duplicate architecture, and a slice a 32-bit table cannot record. That last one guarded the casts in the writer, which silently truncated. Slices are now laid out before the containing file is placed, since the size recorded for a slice has to be the size that slice goes on to write, and they write through a writer sharing one diagnostic bag. The reader also rejects an alignment exponent past 63. A shift count is masked to the width of what it shifts, so a larger one aliased to a different alignment instead of being caught. --- .../MachO/MachOSimpleTests.cs | 25 ++++ src/LibObjectFile/Diagnostics/DiagnosticId.cs | 4 + .../MachO/MachOFatFile.Verify.cs | 107 ++++++++++++++++++ src/LibObjectFile/MachO/MachOFatFile.cs | 69 +++++++++-- src/LibObjectFile/MachO/MachOFatSlice.cs | 15 +++ 5 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 src/LibObjectFile/MachO/MachOFatFile.Verify.cs diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 7c252a6..c3fca5d 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -436,9 +436,26 @@ public void ReadsAndWritesAUniversalBinary() Assert.AreEqual(0ul, slice.FileOffset % slice.Alignment, "a slice is mapped directly, so it has to be page aligned"); } + Assert.IsFalse(fat.Verify().HasErrors, "a real universal binary has to verify"); + var output = new MemoryStream(); fat.Write(output); ByteArrayAssert.AreEqual(original, output.ToArray(), "Invalid binary diff for helloworld_fat after read -> write"); + + // Two slices claiming the same architecture leave the loader picking the first and the + // other unreachable, so the write reports it rather than producing a file lipo rejects. + var duplicated = MachOFatFile.Read(new MemoryStream(original)); + duplicated.Slices[1].CpuType = duplicated.Slices[0].CpuType; + duplicated.Slices[1].CpuSubType = duplicated.Slices[0].CpuSubType; + Assert.IsFalse(duplicated.TryWrite(new MemoryStream(), out var duplicateDiagnostics)); + Assert.IsTrue(duplicateDiagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_DuplicateFatSlice), string.Join("; ", duplicateDiagnostics.Messages)); + + // A slice that no longer fits the 32-bit slice table has to be reported, since casting it + // out would record a different slice than the one written. + var tooFar = MachOFatFile.Read(new MemoryStream(original)); + tooFar.Slices[1].FileOffset = 0x1_0000_0000; + tooFar.Slices[1].Size = 0x10; + Assert.IsTrue(tooFar.Verify().Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit)); } /// @@ -537,6 +554,14 @@ static int FindCommand(byte[] b, uint type, int skip) BitConverter.GetBytes(0x10000000u).Reverse().ToArray().CopyTo(fat, 4); Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(fat), out _, out var d4)); Assert.IsTrue(d4.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatHeader), string.Join("; ", d4.Messages)); + + // A shift count is masked to the width of what it shifts, so an alignment exponent past + // 63 would alias to a different alignment rather than being rejected. The align field of + // the first slice sits at the end of its 20-byte entry, and the slice table is big-endian. + var badAlign = File.ReadAllBytes(GetFile("helloworld_fat")); + BitConverter.GetBytes(64u).Reverse().ToArray().CopyTo(badAlign, MachOFatFile.HeaderSize + 16); + Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(badAlign), out _, out var d7)); + Assert.IsTrue(d7.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment), string.Join("; ", d7.Messages)); } [TestMethod] diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 6381892..75cd121 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -235,4 +235,8 @@ public enum DiagnosticId MACHO_ERR_SectionContentMismatch = 5017, MACHO_ERR_LoadCommandTableSizeMismatch = 5018, MACHO_ERR_LoadCommandOverread = 5019, + MACHO_ERR_InvalidFatSliceAlignment = 5020, + MACHO_ERR_OverlappingFatSlices = 5021, + MACHO_ERR_DuplicateFatSlice = 5022, + MACHO_ERR_MissingFatSliceImage = 5023, } \ No newline at end of file diff --git a/src/LibObjectFile/MachO/MachOFatFile.Verify.cs b/src/LibObjectFile/MachO/MachOFatFile.Verify.cs new file mode 100644 index 0000000..d3805ad --- /dev/null +++ b/src/LibObjectFile/MachO/MachOFatFile.Verify.cs @@ -0,0 +1,107 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using System; +using System.Collections.Generic; +using LibObjectFile.Diagnostics; + +namespace LibObjectFile.MachO; + +partial class MachOFatFile +{ + /// + /// Checks this universal binary for inconsistencies. + /// + /// What was found. Empty if nothing was. + public DiagnosticBag Verify() + { + var diagnostics = new DiagnosticBag(); + Verify(diagnostics); + return diagnostics; + } + + /// + /// Checks this universal binary for inconsistencies, including each slice's own image. + /// + /// Receives what was found. + /// is null. + public void Verify(DiagnosticBag diagnostics) + { + ArgumentNullException.ThrowIfNull(diagnostics); + + var entrySize = (ulong)GetSliceEntrySize(Is64BitOffsets); + var tableEnd = (ulong)HeaderSize + (ulong)Slices.Count * entrySize; + + var architectures = new HashSet<(MachOCpuType, uint)>(); + var ranges = new List<(ulong Start, ulong End, int Index)>(); + + for (var i = 0; i < Slices.Count; i++) + { + var slice = Slices[i]; + + if (slice.File is null) + { + diagnostics.Error( + DiagnosticId.MACHO_ERR_MissingFatSliceImage, + $"Slice {i} ({slice.CpuType}) has no image to write"); + continue; + } + + if (slice.AlignLog2 > MachOFatSlice.MaxAlignLog2) + { + diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, + $"Slice {i} ({slice.CpuType}) has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a shift can express"); + } + else if ((slice.FileOffset & (slice.Alignment - 1)) != 0) + { + // A slice is mapped straight out of the containing file, so an offset off its own + // page boundary is one the loader cannot map. + diagnostics.Error( + DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, + $"Slice {i} ({slice.CpuType}) starts at 0x{slice.FileOffset:X}, which is not a multiple of its 0x{slice.Alignment:X} alignment"); + } + + if (slice.FileOffset < tableEnd) + { + diagnostics.Error( + DiagnosticId.MACHO_ERR_OverlappingFatSlices, + $"Slice {i} ({slice.CpuType}) starts at 0x{slice.FileOffset:X}, inside the slice table that ends at 0x{tableEnd:X}"); + } + + // The 32-bit table cannot express these, and casting on the way out would record a + // different slice rather than fail. + if (!Is64BitOffsets && (slice.FileOffset > uint.MaxValue || slice.Size > uint.MaxValue)) + { + diagnostics.Error( + DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + $"Slice {i} ({slice.CpuType}) spans [0x{slice.FileOffset:X}, 0x{slice.FileOffset + slice.Size:X}) which a 32-bit slice table cannot record. Set {nameof(Is64BitOffsets)}."); + } + + if (!architectures.Add((slice.CpuType, slice.CpuSubType))) + { + // The loader takes the first slice matching the host, so a duplicate is either + // dead weight or a different image than the one that will be run. + diagnostics.Error( + DiagnosticId.MACHO_ERR_DuplicateFatSlice, + $"Slice {i} repeats the architecture {slice.CpuType} (subtype 0x{slice.CpuSubType:X}) of an earlier slice"); + } + + ranges.Add((slice.FileOffset, slice.FileOffset + slice.Size, i)); + slice.File.Verify(diagnostics); + } + + // The header may list slices in any order, so overlap is checked in file order. + ranges.Sort((a, b) => a.Start.CompareTo(b.Start)); + for (var i = 1; i < ranges.Count; i++) + { + if (ranges[i].Start < ranges[i - 1].End) + { + diagnostics.Error( + DiagnosticId.MACHO_ERR_OverlappingFatSlices, + $"Slice {ranges[i].Index} starts at 0x{ranges[i].Start:X}, inside slice {ranges[i - 1].Index} which ends at 0x{ranges[i - 1].End:X}"); + } + } + } +} diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs index 96f16a8..e2cd4c5 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -27,7 +27,7 @@ namespace LibObjectFile.MachO; /// together. /// /// -public sealed class MachOFatFile +public sealed partial class MachOFatFile { /// /// The size of the header preceding the slice table (fat_header). @@ -174,6 +174,13 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? slice.AlignLog2 = BinaryPrimitives.ReadUInt32BigEndian(entry.Slice(16)); } + if (slice.AlignLog2 > MachOFatSlice.MaxAlignLog2) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, $"Slice {i} has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a shift can express"); + file = null; + return false; + } + if (slice.FileOffset + slice.Size > (ulong)stream.Length) { bag.Error(DiagnosticId.MACHO_ERR_InvalidFatArchRange, $"Slice {i} spans [0x{slice.FileOffset:X}, 0x{slice.FileOffset + slice.Size:X}) which extends past the end of the file"); @@ -207,18 +214,62 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? /// /// The stream to write to. /// is null. - /// A slice could not be written. + /// The universal binary or one of its slices is not writable. /// - /// The slices are laid out first, so a slice that changed size since it was read is placed - /// and recorded correctly rather than overrunning the one after it. The space between them - /// is left zeroed: every universal binary examined pads with zeros, and slices are aligned - /// rather than packed, so the gaps hold nothing worth preserving. + /// The stream is grown to hold the last slice if it is shorter, and is flushed before this + /// method returns but is not disposed. /// public void Write(Stream stream) + { + if (!TryWrite(stream, out var diagnostics)) + { + throw new ObjectFileException($"Unexpected error while writing the Mach-O universal binary", diagnostics); + } + } + + /// + /// Tries to write this universal binary to a stream. + /// + /// The stream to write to. + /// The diagnostics collected while writing. + /// true if the file was written without errors; otherwise false. + /// is null. + /// + /// Each slice is laid out before the slices are placed, so a slice that changed size since it + /// was read is recorded at the size it actually writes rather than overrunning the one after + /// it. The space between slices is left zeroed: every universal binary examined pads with + /// zeros, and slices are aligned rather than packed, so the gaps hold nothing worth + /// preserving. + /// + /// The stream is grown to hold the last slice if it is shorter, and is flushed before this + /// method returns but is not disposed. + /// + /// + public bool TryWrite(Stream stream, out DiagnosticBag diagnostics) { ArgumentNullException.ThrowIfNull(stream); + diagnostics = new DiagnosticBag(); + + // Each slice is verified and laid out before the containing file is, because the size + // recorded for a slice has to be the size that slice goes on to write. + foreach (var slice in Slices) + { + if (slice.File is null) continue; + + var context = new MachOVisitorContext(slice.File, diagnostics); + slice.File.Verify(context); + if (diagnostics.HasErrors) return false; + + slice.File.UpdateLayout(context); + if (diagnostics.HasErrors) return false; + } + UpdateLayout(); + + Verify(diagnostics); + if (diagnostics.HasErrors) return false; + var basePosition = stream.Position; var entrySize = GetSliceEntrySize(Is64BitOffsets); @@ -253,7 +304,9 @@ public void Write(Stream stream) { if (slice.File is null) continue; stream.Position = basePosition + (long)slice.FileOffset; - slice.File.Write(stream); + var writer = new MachOWriter(slice.File, stream, diagnostics); + slice.File.Write(writer); + if (diagnostics.HasErrors) return false; } var end = Slices.Count == 0 ? 0 : Slices.Max(s => (long)(s.FileOffset + s.Size)); @@ -263,6 +316,8 @@ public void Write(Stream stream) } stream.Flush(); + + return !diagnostics.HasErrors; } /// diff --git a/src/LibObjectFile/MachO/MachOFatSlice.cs b/src/LibObjectFile/MachO/MachOFatSlice.cs index 8152c3b..c50a08b 100644 --- a/src/LibObjectFile/MachO/MachOFatSlice.cs +++ b/src/LibObjectFile/MachO/MachOFatSlice.cs @@ -3,6 +3,7 @@ // See the license.txt file in the project root for more information. using System; +using LibObjectFile.Diagnostics; namespace LibObjectFile.MachO; @@ -22,6 +23,16 @@ public sealed class MachOFatSlice /// public uint CpuSubType { get; set; } + /// + /// The largest alignment exponent a slice can carry. + /// + /// + /// A shift count is masked to the width of the value it shifts, so an exponent past this + /// would quietly alias to a different alignment instead of being rejected. Real images use + /// 12 to 14, the page size of the architecture. + /// + public const uint MaxAlignLog2 = 63; + /// /// Gets or sets the alignment of this slice as a power of two. It is the page size of the /// architecture, because the slice is mapped directly out of the containing file. @@ -46,6 +57,10 @@ public sealed class MachOFatSlice /// /// Gets the alignment of this slice in bytes. /// + /// + /// Only meaningful while is within , which + /// the reader enforces and checks. + /// public ulong Alignment => 1ul << (int)AlignLog2; /// From 28e4c4d072897a00250f3b33e3df58f747e08339 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:21:55 -0400 Subject: [PATCH 21/32] Fix Mach-O TryRead throwing instead of reporting on short input The magic was the one read in the walk taken without a bound, so a stream of fewer than four bytes left TryRead as an EndOfStreamException rather than false plus diagnostics. MachOFatFile reached the same escape through a slice sized zero, which passed the range check and was then descended into. ELF and PE both report on the same input, so this was Mach-O being the outlier. The magic is now read through the bounded primitive the rest of the header already used, a slice too short to hold a header is rejected before it is read, and TryRead converts an EndOfStreamException into a diagnostic so that a bound missed at any of the sites that check one by hand cannot break the contract. Fuzzing truncations and bit flips over the fixtures escaped six times before this and none after. --- .../MachO/MachOSimpleTests.cs | 29 ++++++++++++++++++- src/LibObjectFile/Diagnostics/DiagnosticId.cs | 1 + src/LibObjectFile/MachO/MachOFatFile.cs | 7 +++++ src/LibObjectFile/MachO/MachOFile.Read.cs | 21 ++++++++++++-- src/LibObjectFile/MachO/MachOFile.cs | 6 ++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index c3fca5d..e5a2142 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -564,13 +564,40 @@ static int FindCommand(byte[] b, uint type, int skip) Assert.IsTrue(d7.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment), string.Join("; ", d7.Messages)); } + /// + /// A Try method owes the caller diagnostics rather than an exception, whatever it is handed. + /// The magic was read without a bound, so anything shorter than four bytes escaped as an + /// , as did a universal binary slice with no room for a + /// header. + /// [TestMethod] - public void RejectsANonMachOStream() + public void ReportsRatherThanThrowsOnUnreadableInput() { using var input = new MemoryStream("not a mach-o file at all"u8.ToArray()); Assert.IsFalse(MachOFile.IsMachO(input)); Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); + + foreach (var length in new[] { 0, 1, 3 }) + { + Assert.IsFalse(MachOFile.TryRead(new MemoryStream(new byte[length]), out _, out var tooShort), $"a {length}-byte stream cannot be an image"); + Assert.IsTrue(tooShort.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic), string.Join("; ", tooShort.Messages)); + } + + // Every truncation of a real image, which is what walks each bounded read up to its + // limit. Anything that throws here fails the test by escaping. + var image = File.ReadAllBytes(GetFile("unixthread_i386")); + for (var length = 0; length < image.Length; length += 7) + { + MachOFile.TryRead(new MemoryStream(image.AsSpan(0, length).ToArray()), out _, out _); + } + + // The size of the first slice sits 12 bytes into its entry, and the slice table is + // big-endian. A slice with no room for a header must be rejected, not descended into. + var fat = File.ReadAllBytes(GetFile("helloworld_fat")); + BitConverter.GetBytes(0u).Reverse().ToArray().CopyTo(fat, MachOFatFile.HeaderSize + 12); + Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(fat), out _, out var emptySlice)); + Assert.IsTrue(emptySlice.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatArchRange), string.Join("; ", emptySlice.Messages)); } } diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 75cd121..10168bf 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -239,4 +239,5 @@ public enum DiagnosticId MACHO_ERR_OverlappingFatSlices = 5021, MACHO_ERR_DuplicateFatSlice = 5022, MACHO_ERR_MissingFatSliceImage = 5023, + MACHO_ERR_UnexpectedEndOfStream = 5024, } \ No newline at end of file diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs index e2cd4c5..155e576 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -181,6 +181,13 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? return false; } + if (slice.Size < MachOFile.MinHeaderSize) + { + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatArchRange, $"Slice {i} is {slice.Size} bytes, too short to hold a Mach-O header"); + file = null; + return false; + } + if (slice.FileOffset + slice.Size > (ulong)stream.Length) { bag.Error(DiagnosticId.MACHO_ERR_InvalidFatArchRange, $"Slice {i} spans [0x{slice.FileOffset:X}, 0x{slice.FileOffset + slice.Size:X}) which extends past the end of the file"); diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs index 9deb9d4..27f7050 100644 --- a/src/LibObjectFile/MachO/MachOFile.Read.cs +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -47,7 +47,19 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFile? fil var reader = new MachOReader(file, stream, options ?? new MachOReaderOptions()); diagnostics = reader.Diagnostics; - file.Read(reader); + try + { + file.Read(reader); + } + catch (EndOfStreamException) + { + // Every read in the walk is bounded before it is taken, so arriving here means one of + // those bounds was missed rather than that the caller passed a short stream. Either + // way a Try method owes the caller a diagnostic rather than an exception. + reader.Diagnostics.Error( + DiagnosticId.MACHO_ERR_UnexpectedEndOfStream, + "The image ends inside a structure the file declares, so it is truncated or malformed"); + } if (reader.Diagnostics.HasErrors) { @@ -87,7 +99,12 @@ public override unsafe void Read(MachOReader reader) { Position = reader.Position; - var magic = reader.ReadU32(); + if (!reader.TryReadData(sizeof(uint), out uint magic)) + { + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidMagic, "The stream is too short to hold a Mach-O magic"); + return; + } + reader.Position -= 4; switch (magic) diff --git a/src/LibObjectFile/MachO/MachOFile.cs b/src/LibObjectFile/MachO/MachOFile.cs index 262f11a..4e52fc3 100644 --- a/src/LibObjectFile/MachO/MachOFile.cs +++ b/src/LibObjectFile/MachO/MachOFile.cs @@ -112,6 +112,12 @@ public MachODylibCommand? IdDylib /// public uint HeaderSize => Is64Bit ? 32u : 28u; + /// + /// The smallest a Mach-O header can be, which is the 32-bit one. Nothing shorter than this + /// can hold an image. + /// + internal const uint MinHeaderSize = 28; + /// /// Gets the total size of the load commands, the value stored in sizeofcmds. /// From ffd7f333861268421047893f40198df949ec5b17 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:25:17 -0400 Subject: [PATCH 22/32] Add Mach-O API parity with the other file types ElfFile and ArArchiveFile both offer a parameterless Verify returning the bag, which is the shape a caller wants when it has nothing to merge into. Mach-O only had the overload taking one. AddLoadDylib took the command type as an optional parameter. AGENTS.md prefers overloads for binary compatibility, so it is now two methods. --- src/LibObjectFile/MachO/MachOFile.Edit.cs | 17 +++++++++++------ src/LibObjectFile/MachO/MachOFile.Verify.cs | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/LibObjectFile/MachO/MachOFile.Edit.cs b/src/LibObjectFile/MachO/MachOFile.Edit.cs index 7a75a37..8ac6353 100644 --- a/src/LibObjectFile/MachO/MachOFile.Edit.cs +++ b/src/LibObjectFile/MachO/MachOFile.Edit.cs @@ -17,10 +17,6 @@ partial class MachOFile /// The install name of the library. It is resolved by dyld rather than opened as a path, so /// it may start with @rpath, @executable_path or @loader_path. /// - /// - /// Which dylib command to add. makes the - /// library optional at runtime. - /// /// The command that was added. /// /// The command is appended, never inserted, because dyld numbers libraries by their command @@ -28,11 +24,20 @@ partial class MachOFile /// leaves the existing numbering alone. /// /// is null. - /// is empty, or is not a dylib command. + /// is empty, or the type is not a dylib command. /// /// The image has no room left before its first section. The message states the shortfall. /// - public MachODylibCommand AddLoadDylib(string name, MachOLoadCommandType type = MachOLoadCommandType.LoadDylib) + public MachODylibCommand AddLoadDylib(string name) + => AddLoadDylib(name, MachOLoadCommandType.LoadDylib); + + /// + /// The install name to record, as it will appear to dyld. + /// + /// Which dylib command to add. makes the + /// library optional at runtime. + /// + public MachODylibCommand AddLoadDylib(string name, MachOLoadCommandType type) { ArgumentException.ThrowIfNullOrEmpty(name); diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs index 91c095a..e15adc8 100644 --- a/src/LibObjectFile/MachO/MachOFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -10,6 +10,17 @@ namespace LibObjectFile.MachO; partial class MachOFile { + /// + /// Checks this image for inconsistencies. + /// + /// What was found. Empty if nothing was. + public DiagnosticBag Verify() + { + var diagnostics = new DiagnosticBag(); + Verify(diagnostics); + return diagnostics; + } + /// /// Checks this image for inconsistencies. /// From e7cc1fd6fcf5b478d8fb210fc220103213a30c48 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:25:41 -0400 Subject: [PATCH 23/32] Fix unchecked size arithmetic on the Mach-O write path The earlier overflow work bounded the counts the reader trusts. The same class was still live on the layout side, where the values come from the model rather than the file. SizeOfCommands accumulated into a uint, so a command table past 4GB wrapped to a small number. AvailableLoadCommandSpace is derived from it and gates every edit, so the wrap offered room that did not exist. It now totals wide and saturates, which leaves the space negative rather than plausible, and Verify reports it. LoadCommandsEndOffset becomes a file offset like ContentStartOffset beside it, since adding the header size to a saturated total wrapped again. AdHocSign cast the image size down to the 32-bit offset LC_CODE_SIGNATURE records, which for an image past 4GB pointed the signature back into the image. It now refuses, which is already what its documented failures do. The header test asserted HeaderSize + SizeOfCommands == LoadCommandsEndOffset, which restates the definition of the property. It now checks the decoded table against the ncmds and sizeofcmds the file actually carries. --- .../MachO/MachOSimpleTests.cs | 15 ++++++- src/LibObjectFile/MachO/MachOFile.Sign.cs | 8 ++++ src/LibObjectFile/MachO/MachOFile.Verify.cs | 8 ++++ src/LibObjectFile/MachO/MachOFile.cs | 42 +++++++++++++++---- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index e5a2142..359b09c 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -50,7 +50,13 @@ public void ReadsHeader(string name, bool is64Bit, MachOCpuType cpuType, MachOFi Assert.AreEqual(is64Bit, file.Is64Bit); Assert.AreEqual(cpuType, file.CpuType); Assert.AreEqual(fileType, file.FileType); - Assert.AreEqual(file.HeaderSize + file.SizeOfCommands, file.LoadCommandsEndOffset); + + // ncmds and sizeofcmds sit at the same offsets in both header layouts. Comparing the + // decoded table against them catches a walk that read a different number of commands + // than the file declares. + var raw = File.ReadAllBytes(GetFile(name)); + Assert.AreEqual(BitConverter.ToUInt32(raw, 16), (uint)file.LoadCommands.Count, "ncmds"); + Assert.AreEqual(BitConverter.ToUInt32(raw, 20), file.SizeOfCommands, "sizeofcmds"); } /// @@ -73,6 +79,13 @@ public void ReportsSpaceLeftForNewLoadCommands() Assert.AreEqual(28ul, dylinker.Size); Assert.AreEqual(28u, dylinker.MinimumSize); Assert.IsTrue(file.LoadCommands.OfType().All(d => d.Size >= d.MinimumSize)); + + // A table too large for sizeofcmds must not wrap into a small value, which would offer + // room that does not exist and let an edit write over the content after it. + dylinker.Size = 0x1_0000_0000; + Assert.AreEqual(uint.MaxValue, file.SizeOfCommands, "sizeofcmds saturates rather than wrapping"); + Assert.IsTrue(file.AvailableLoadCommandSpace < 0, "a table that cannot be recorded leaves no room"); + Assert.IsTrue(file.Verify().Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit)); } /// diff --git a/src/LibObjectFile/MachO/MachOFile.Sign.cs b/src/LibObjectFile/MachO/MachOFile.Sign.cs index a607df6..325dc0e 100644 --- a/src/LibObjectFile/MachO/MachOFile.Sign.cs +++ b/src/LibObjectFile/MachO/MachOFile.Sign.cs @@ -86,6 +86,14 @@ public void AdHocSign(string identifier) } var contentEnd = ComputeFileSize(); + + // LC_CODE_SIGNATURE records where the signature is in a 32-bit dataoff, so an image + // this large cannot carry one. Casting would point the offset back into the image. + if (contentEnd > uint.MaxValue) + { + throw new InvalidOperationException($"The image is 0x{contentEnd:X} bytes, too large for the 32-bit offset LC_CODE_SIGNATURE records."); + } + var signatureOffset = AlignHelper.AlignUp((uint)contentEnd, (uint)MachOCodeSignatureConstants.SignatureAlignment); var builder = new MachOAdHocSignatureBuilder(identifier) diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs index e15adc8..eae564d 100644 --- a/src/LibObjectFile/MachO/MachOFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -166,6 +166,14 @@ private void VerifyLoadCommands(MachOVisitorContext context) } } + var sizeOfCommands = ComputeSizeOfCommands(); + if (sizeOfCommands > uint.MaxValue) + { + context.Diagnostics.Error( + DiagnosticId.MACHO_ERR_ValueTooLargeFor32Bit, + $"The load commands total 0x{sizeOfCommands:X} bytes, which the 32-bit sizeofcmds field cannot record"); + } + if (AvailableLoadCommandSpace < 0) { context.Diagnostics.Error( diff --git a/src/LibObjectFile/MachO/MachOFile.cs b/src/LibObjectFile/MachO/MachOFile.cs index 4e52fc3..dac4e0b 100644 --- a/src/LibObjectFile/MachO/MachOFile.cs +++ b/src/LibObjectFile/MachO/MachOFile.cs @@ -121,23 +121,39 @@ public MachODylibCommand? IdDylib /// /// Gets the total size of the load commands, the value stored in sizeofcmds. /// + /// + /// Saturates instead of wrapping when the commands do not fit the 32-bit field. A wrap here + /// would report a large table as a small one, and + /// would then offer room that does not exist. + /// reports the condition. + /// public uint SizeOfCommands { get { - uint total = 0; - foreach (var command in LoadCommands) - { - total += (uint)command.Size; - } - return total; + var total = ComputeSizeOfCommands(); + return total > uint.MaxValue ? uint.MaxValue : (uint)total; } } + /// + /// Totals the load commands without narrowing, so a table that overruns sizeofcmds can + /// be told from one that exactly fills it. + /// + internal ulong ComputeSizeOfCommands() + { + ulong total = 0; + foreach (var command in LoadCommands) + { + total += command.Size; + } + return total; + } + /// /// Gets the file offset one past the end of the load command table. /// - public uint LoadCommandsEndOffset => HeaderSize + SizeOfCommands; + public ulong LoadCommandsEndOffset => HeaderSize + ComputeSizeOfCommands(); /// /// Gets the command locating this image's code signature, or null if it is unsigned. @@ -181,7 +197,17 @@ public ulong ContentStartOffset /// Gets the number of bytes the load command table can still grow by without moving any /// content. A negative value means the current commands no longer fit. /// - public long AvailableLoadCommandSpace => (long)ContentStartOffset - LoadCommandsEndOffset; + public long AvailableLoadCommandSpace + { + get + { + // Both ends are clamped so that a model carrying nonsensical offsets reports no room + // rather than wrapping into a large positive number and letting an edit through. + var start = (long)Math.Min(ContentStartOffset, long.MaxValue); + var end = (long)Math.Min(LoadCommandsEndOffset, long.MaxValue); + return start - end; + } + } /// /// Rewrites the file offsets that point at relocatable data: the tables in __LINKEDIT From bea41837a20d8a30f84ad98d25bf0563dd447109 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:29:58 -0400 Subject: [PATCH 24/32] Fix test coverage that could pass without checking anything The i386 fixture declared two symbols but carried no bytes for them, so every field decoded as zero and any bug in the 32-bit nlist path would have produced the same result as a correct read. The fixture now carries a real text and data symbol, which llvm-nm agrees with, and the symbol test asserts against them. yaml2obj emits a two byte export trie ahead of the tables it generates, so LC_DYLD_INFO_ONLY now records that trie rather than leaving the bytes unexplained, and LC_SYMTAB points past it. The space exhaustion test filled until fewer than 120 bytes were left, then expected a command costing about 116 to fail. Whether it did depended on the padding the fill happened to stop on. It now asks for a path longer than the space that is actually left. arm64 reads symbols through the same 64-bit path as x86_64, so it is covered against the fixture rather than as a separate decode. --- .../MachO/MachOEditingTests.cs | 6 +++- .../MachO/MachOSimpleTests.cs | 14 ++++++++++ src/LibObjectFile.Tests/MachO/dyldinfo_i386 | Bin 8256 -> 8256 bytes .../MachO/dyldinfo_i386.yaml | 26 ++++++++++++++---- ...sts.Prints_name=dyldinfo_i386.verified.txt | 10 +++---- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs index 173f1a8..5764277 100644 --- a/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOEditingTests.cs @@ -101,7 +101,11 @@ public void AddingBeyondTheAvailableSpaceThrows() } Assert.IsTrue(file.AvailableLoadCommandSpace < space); - var exception = Assert.ThrowsExactly(() => file.AddRPath("/" + path)); + + // Sized against the space actually left rather than the loop's threshold. A fixed path + // could still fit whatever padding the fill happened to stop on. + var tooLong = new string('a', (int)file.AvailableLoadCommandSpace + 16); + var exception = Assert.ThrowsExactly(() => file.AddRPath("/" + tooLong)); StringAssert.Contains(exception.Message, "free before the first section"); } diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 359b09c..9418ec0 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -372,6 +372,20 @@ public void ReadsTheSymbolTable() var stubs = file.Segments.SelectMany(s => s.Sections).Single(s => s.Name == "__stubs"); Assert.AreEqual(MachOSectionType.SymbolStubs, stubs.SectionType); Assert.AreEqual("_printf", symbols[(int)indirect[(int)stubs.Reserved1]].Name); + + // A 32-bit nlist packs into 12 bytes rather than 16, so it is a separate decode. + var i386 = LoadMachO("dyldinfo_i386").ReadSymbolTable(); + CollectionAssert.AreEqual(new[] { "_main", "_dyldinfo_data" }, i386.Select(s => s.Name).ToArray()); + Assert.AreEqual(0x800ul, i386[0].Value); + Assert.AreEqual(1, i386[0].SectionIndex); + Assert.AreEqual(0x2000ul, i386[1].Value); + Assert.AreEqual(2, i386[1].SectionIndex); + + // arm64 shares the 64-bit decode with x86_64, so this covers the fixture rather than + // another path through the reader. + var arm64 = LoadMachO("helloworld_arm64").ReadSymbolTable(); + Assert.AreEqual(0x100003F24ul, arm64.Single(s => s.Name == "_main").Value); + Assert.AreEqual(MachOSymbolKind.Undefined, arm64.Single(s => s.Name == "_printf").Kind); } /// diff --git a/src/LibObjectFile.Tests/MachO/dyldinfo_i386 b/src/LibObjectFile.Tests/MachO/dyldinfo_i386 index 6f613c087bc3b3813b68a231a8a319c1ec16f1f4..296cc643bcf632243044a3a41af32606ec8a3503 100644 GIT binary patch delta 104 zcmX@$aKK^1T1FWK1_mYu2$lfRP`(t9me_of(Nb>m14b4rentTX1`aS{;0LJ(N-HqL b=O$+6F~p}-=A>lirRB$`B$gxs>o delta 41 tcmX@$aKK^1TE@u>7}+O3U}O=LU;qLI1_mY|B>|)rHlJj)l$*qC2LSI030wdG diff --git a/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml b/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml index 4dbd9fe..3ca795f 100644 --- a/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml +++ b/src/LibObjectFile.Tests/MachO/dyldinfo_i386.yaml @@ -92,14 +92,14 @@ LoadCommands: weak_bind_size: 0 lazy_bind_off: 0 lazy_bind_size: 0 - export_off: 0 - export_size: 0 + export_off: 8192 + export_size: 2 - cmd: LC_SYMTAB cmdsize: 24 - symoff: 8192 + symoff: 8194 nsyms: 2 - stroff: 8216 - strsize: 32 + stroff: 8218 + strsize: 24 - cmd: LC_DYSYMTAB cmdsize: 80 ilocalsym: 0 @@ -160,3 +160,19 @@ LoadCommands: compatibility_version: 0x00010000 Content: '/usr/lib/libSystem.B.dylib' ZeroPadBytes: 2 +LinkEditData: + NameList: + - n_strx: 2 + n_type: 0x0F + n_sect: 1 + n_desc: 0x0010 + n_value: 2048 + - n_strx: 8 + n_type: 0x0F + n_sect: 2 + n_desc: 0 + n_value: 8192 + StringTable: + - ' ' + - _main + - _dyldinfo_data diff --git a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt index b06ac77..4de3fc9 100644 --- a/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt +++ b/src/LibObjectFile.Tests/Verified/MachOSimpleTests.Prints_name=dyldinfo_i386.verified.txt @@ -90,15 +90,15 @@ Load command 4 bind_size 0 lazy_bind_off 0 lazy_bind_size 0 - export_off 0 - export_size 0 + export_off 8192 + export_size 2 Load command 5 cmd LC_SYMTAB cmdsize 24 - symoff 8192 + symoff 8194 nsyms 2 - stroff 8216 - strsize 32 + stroff 8218 + strsize 24 Load command 6 cmd LC_DYSYMTAB cmdsize 80 From 1018e08f358267ce0656e550f3230d540a642632 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:31:33 -0400 Subject: [PATCH 25/32] Update doc for the Mach-O editing and write surface SetInstallName and RemoveRPath were implemented but absent from the editing section, and the feature list claimed all load commands are decoded in the same breath as saying the unmodelled ones round-trip verbatim. The write section now covers TryWrite and the universal binary, and records why the reader insists the commands end exactly at sizeofcmds where dyld tolerates slack after them. generate_files.sh discovers the SDK instead of naming a version, and records the toolchain the committed fixtures came out of, since a different one will move offsets and show up as a diff worth reading rather than accepting. --- doc/readme.md | 12 ++++++++++-- readme.md | 4 ++-- src/LibObjectFile.Tests/MachO/generate_files.sh | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/doc/readme.md b/doc/readme.md index ceac047..4c5dd07 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -621,7 +621,11 @@ A `MachOFile` is written back with `MachOFile.Write`: machO.Write(outputStream); ``` -Content is written at the offset recorded for it, so an image that has not been edited comes back byte for byte. Use `MachOFile.Verify` to check an image before writing it. +Content is written at the offset recorded for it, so an image that has not been edited comes back byte for byte. `Write` verifies first and throws if anything is wrong; `TryWrite` reports through a `DiagnosticBag` instead. `MachOFile.Verify` checks an image on its own. + +A universal binary is written the same way through `MachOFatFile.Write` or `MachOFatFile.TryWrite`, which lay out each slice before placing it, so a slice that changed size is recorded at the size it actually writes. + +The reader requires the load commands to end exactly where the header's `sizeofcmds` says. dyld is looser, stopping once it has walked `ncmds` commands and ignoring any slack after them. The stricter reading is deliberate: writing an image back means reproducing that slack, and an image whose two counts disagree is one where it is not clear which the loader will believe. ### Editing load commands @@ -630,10 +634,14 @@ Content is written at the offset recorded for it, so an image that has not been ```csharp machO.AddLoadDylib("@executable_path/../Frameworks/mylib.dylib"); machO.AddRPath("@executable_path/../Frameworks"); +machO.RemoveRPath("@loader_path/../Frameworks"); machO.ChangeDylibName("/usr/lib/libfoo.dylib", "@rpath/libfoo.dylib"); +machO.SetInstallName("@rpath/libmylib.dylib"); ``` -A dependency is appended rather than inserted, because dyld identifies a library by the position of its command among the others and the symbol table binds against that number. Removing a dependency is not offered for the same reason. +`SetInstallName` writes the `LC_ID_DYLIB` name a library reports for itself, which is what the images linking against it record. It applies to a dylib rather than an executable. + +A dependency is appended rather than inserted, because dyld identifies a library by the position of its command among the others and the symbol table binds against that number. Removing a dependency is not offered for the same reason. A run path carries no such numbering, so `RemoveRPath` is available. When the padding runs out these throw, naming the shortfall, rather than moving content and invalidating the image. There is no way around that short of relinking with `-headerpad_max_install_names`, which is also why `install_name_tool` fails in the same situation. diff --git a/readme.md b/readme.md index 9d9887f..5efcfd2 100644 --- a/readme.md +++ b/readme.md @@ -60,9 +60,9 @@ elf.Write(outStream); - Read and write from/to a `System.IO.Stream` - 32 and 64 bit, `i386`, `x86_64` and `arm64` - Universal (fat) binaries via `MachOFatFile` - - All load commands are decoded, and commands this library does not model round-trip verbatim + - The load commands a linked image is built from are decoded; the rest round-trip verbatim - Symbol table, indirect symbol table and relocations - - `MachOFile.AddLoadDylib`, `AddRPath` and `ChangeDylibName` edit an existing image without moving any section (address-preserving, `install_name_tool`-style) + - `MachOFile.AddLoadDylib`, `AddRPath`, `RemoveRPath`, `ChangeDylibName` and `SetInstallName` edit an existing image without moving any section (address-preserving, `install_name_tool`-style) - `MachOFile.AdHocSign` writes an ad-hoc code signature, which Apple Silicon requires in order to execute an image - `MachOFile.Print` to print the content of a Mach-O file with `otool` similar output - Support for **DWARF debugging format**: diff --git a/src/LibObjectFile.Tests/MachO/generate_files.sh b/src/LibObjectFile.Tests/MachO/generate_files.sh index 0a792c2..9396bcd 100755 --- a/src/LibObjectFile.Tests/MachO/generate_files.sh +++ b/src/LibObjectFile.Tests/MachO/generate_files.sh @@ -15,8 +15,23 @@ # models a pre-10.8 executable, which uses LC_UNIXTHREAD for the entry point; # dyldinfo_i386 models a 10.7-era one, which pairs 32-bit segments with LC_MAIN # and LC_DYLD_INFO_ONLY. No linker still emits either combination. +# +# The committed fixtures were produced with LLVM 22.1.8 and the OSXCross +# MacOSX14 SDK. Another version will not necessarily lay them out the same way, +# so regenerating with one is expected to move offsets and will show up as a +# diff in the byte-exact and snapshot tests. Check such a diff rather than +# accepting it: the fixtures exist to pin what a real toolchain emits. set -e +# Locate the SDK rather than assuming a version, so a toolchain built against a +# different one still works. +SDK="$(dirname "$(command -v oa64-clang)")/../SDK" +SDK="$(ls -d "$SDK"/MacOSX*.sdk 2>/dev/null | sort -V | tail -1)" +if [ -z "$SDK" ]; then + echo "No macOS SDK found next to oa64-clang; see the OSXCross README." >&2 + exit 1 +fi + o64-clang helloworld.c -o helloworld_x86_64 oa64-clang helloworld.c -o helloworld_arm64 @@ -32,7 +47,6 @@ lipo -create helloworld_x86_64 helloworld_arm64 -output helloworld_fat # cctools ld64 predates chained fixups, so this one fixture comes from lld, # which emits LC_DYLD_CHAINED_FIXUPS when the deployment target is macOS 12+. # That is a separate read path from the LC_DYLD_INFO_ONLY opcode streams above. -SDK="$(dirname "$(command -v oa64-clang)")/../SDK/MacOSX14.sdk" oa64-clang -mmacosx-version-min=12.0 -c helloworld.c -o chained.o ld64.lld -arch arm64 -platform_version macos 12.0 12.0 -syslibroot "$SDK" \ -lSystem -e _main -fixup_chains -o chainedfixups_arm64 chained.o From 50cf240fd22cba9fdd389f8d7f389fee287e40ab Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:32:06 -0400 Subject: [PATCH 26/32] Fix test fixtures being copied to output by a wildcard The glob excluded MachO\*.c, which does not match MachO\*.cs, so the four test sources were copied to the output directory alongside the fixtures. Listing the fixtures the way the ELF and PE ones are listed states which files are inputs instead of inferring it from an exclusion that has to stay in step. --- .../LibObjectFile.Tests.csproj | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj b/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj index 1035286..02ef36c 100644 --- a/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj +++ b/src/LibObjectFile.Tests/LibObjectFile.Tests.csproj @@ -25,6 +25,15 @@ + + + + + + + + + @@ -85,8 +94,33 @@ - - + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + From eed6fabbffefc62b2c4855f32a5f5c884cc76a4c Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:36:51 -0400 Subject: [PATCH 27/32] Fix the SuperBlob length covering its alignment padding The signature is rounded up to a 16 byte boundary, and the SuperBlob header was given that rounded size as its own length. The length field describes the blob, not the room reserved for it: Apple's codesign writes the offset one past the last blob and leaves the slack to the load command's datasize, which is how it reports the value back. Verified against macOS 13.7.6. codesign -s - on the same input produces a SuperBlob of 322 bytes over three slots, and after this change so does AdHocSign, with matching slot offsets and lengths. --- .../MachO/MachOSigningTests.cs | 11 ++++++++++- .../MachO/CodeSign/MachOAdHocSignatureBuilder.cs | 15 ++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs index 04a7f10..6d3a5cd 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs @@ -78,6 +78,15 @@ public void SignatureUsesTheAdHocCodeDirectoryFormat() Assert.AreEqual(EmbeddedSignatureMagic, BinaryPrimitives.ReadUInt32BigEndian(superBlob)); Assert.AreEqual(3u, BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(8)), "code directory, requirements and CMS slots"); + // The length field is the SuperBlob's own, so it ends at the last blob rather than + // covering the padding the signature is rounded up to. codesign reports it that way and + // so does Apple's own ad-hoc output; the slack belongs to the load command's datasize. + var declaredLength = BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(4)); + var lastBlobEnd = FindBlob(image, command.DataOffset, SignatureSlot).Length + + (int)BinaryPrimitives.ReadUInt32BigEndian(superBlob.Slice(12 + 2 * 8 + 4)); + Assert.AreEqual((uint)lastBlobEnd, declaredLength, "the SuperBlob length excludes the alignment padding"); + Assert.IsTrue(declaredLength <= command.DataSize, "the reserved region covers the blobs"); + var emptyRequirements = FindBlob(image, command.DataOffset, RequirementsSlot); Assert.AreEqual(12, emptyRequirements.Length, "an empty requirement set"); var cms = FindBlob(image, command.DataOffset, SignatureSlot); @@ -103,7 +112,7 @@ public void SignatureUsesTheAdHocCodeDirectoryFormat() Assert.AreEqual(text.FileSize, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(72))); Assert.AreEqual(ExecSegMainBinary, BinaryPrimitives.ReadUInt64BigEndian(codeDirectory.Slice(80))); - var hashOffset = (int)BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16)); + var hashOffset = (int)BinaryPrimitives.ReadUInt32BigEndian(codeDirectory.Slice(16)); var info = codeDirectory.Slice(hashOffset - Sha256Size, Sha256Size); Assert.IsTrue(info.ToArray().All(b => b == 0), "the Info.plist slot should be zero"); diff --git a/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs index 159c7a5..d35dac1 100644 --- a/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs +++ b/src/LibObjectFile/MachO/CodeSign/MachOAdHocSignatureBuilder.cs @@ -83,10 +83,15 @@ public MachOAdHocSignatureBuilder(string identifier) /// lay out the image before any hash has been computed. /// public uint ComputeSize() - { - var total = SuperBlobSize + CodeDirectorySize + RequirementsSize + BlobWrapperSize; - return AlignHelper.AlignUp((uint)total, (uint)SignatureAlignment); - } + => AlignHelper.AlignUp((uint)ContentSize, (uint)SignatureAlignment); + + /// + /// The size of the blobs themselves, which is what the SuperBlob header records. The room + /// reserved for the signature is rounded up from this, and the padding is not part of the + /// SuperBlob: codesign reports the length field as the blob's own, and Apple's ad-hoc output + /// leaves the slack to the load command's datasize. + /// + private int ContentSize => SuperBlobSize + CodeDirectorySize + RequirementsSize + BlobWrapperSize; // Header plus one index entry for each of the code directory, requirements and CMS slots. private const int SuperBlobSize = 12 + 3 * 8; @@ -127,7 +132,7 @@ public byte[] Build(ReadOnlySpan image) WriteRequirements(span.Slice(requirementsOffset, RequirementsSize)); WriteBlobWrapper(span.Slice(blobWrapperOffset, BlobWrapperSize)); WriteCodeDirectory(span.Slice(codeDirectoryOffset, CodeDirectorySize), image, span.Slice(requirementsOffset, RequirementsSize)); - WriteSuperBlob(span, codeDirectoryOffset, requirementsOffset, blobWrapperOffset, result.Length); + WriteSuperBlob(span, codeDirectoryOffset, requirementsOffset, blobWrapperOffset, ContentSize); return result; } From 14786b74dc18ca756e3126a21e51af799505c8fe Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:40:28 -0400 Subject: [PATCH 28/32] Fix Verify rejecting relocatable object files The section address invariant holds for a linked image, where a section's address is its segment's address plus its distance from the segment's file offset. An object file keeps its sections in one unnamed segment with addresses the linker has still to assign, so they do not track file offsets at all. Apple's own crt1.o and lazydylib1.o from the MacOSX14 SDK were rejected by it despite reading and round-tripping byte for byte. The committed object fixture satisfies the invariant by chance, its sections being packed in the same order as their offsets, so the test makes the case rather than relying on finding it. --- src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs | 14 ++++++++++++++ src/LibObjectFile/MachO/MachOFile.Verify.cs | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 9418ec0..6f6524e 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -280,6 +280,20 @@ public void VerifyAcceptsRealImagesAndCatchesABrokenOne() moved.Verify(broken); Assert.IsTrue(broken.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch)); + // An object file's sections carry addresses the linker has still to assign, so they do + // not track file offsets and the check above does not apply to them. Apple's own crt1.o + // breaks it. The committed object fixture happens to satisfy it, so the case is made + // rather than found. + var relocatable = LoadMachO("helloworld_x86_64.o"); + relocatable.Segments.Single().Sections[1].Address += 0x1000; + Assert.IsFalse(relocatable.Verify().HasErrors, "an object file is not laid out at its final addresses"); + + var linked = LoadMachO("helloworld_x86_64"); + Assert.AreEqual(MachOFileType.Execute, linked.FileType); + linked.FindSegment("__TEXT")!.Sections[1].Address += 0x1000; + Assert.IsTrue(linked.Verify().Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_SectionAddressMismatch), + "the same edit in a linked image is what the check is for"); + var overlong = LoadMachO("unixthread_i386"); overlong.LoadCommands[0].Size += 1; var misaligned = new DiagnosticBag(); diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs index eae564d..3dc2401 100644 --- a/src/LibObjectFile/MachO/MachOFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -119,6 +119,11 @@ private void VerifyContentCoversTheFile(MachOVisitorContext context) private void VerifySegments(MachOVisitorContext context) { + // An object file holds its sections in one unnamed segment and gives them addresses the + // linker has yet to assign for real, so they do not track file offsets the way a linked + // image's do. Apple's own crt1.o breaks the invariant below. + var isLinkedImage = FileType != MachOFileType.Object; + foreach (var segment in Segments) { if (segment.Is64Bit != Is64Bit) @@ -135,7 +140,7 @@ private void VerifySegments(MachOVisitorContext context) // This is the invariant the whole format rests on: a section's address is its // segment's address plus its distance from the segment's file offset. Break it // and the loader maps the section somewhere other than where the code expects. - if (section.Address - segment.VmAddress != section.FileOffset - segment.FileOffset) + if (isLinkedImage && section.Address - segment.VmAddress != section.FileOffset - segment.FileOffset) { context.Diagnostics.Error( DiagnosticId.MACHO_ERR_SectionAddressMismatch, From c48296bcec11339ad9507a7d7885abeb55b71c11 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 06:41:51 -0400 Subject: [PATCH 29/32] Improve the diagnostic for an ar archive read as a Mach-O image A universal static library is a fat file whose slices are ar archives rather than images, so reading one arrived at "Invalid Mach-O magic 0x72613C21". That is the ar magic, and the library can read it through ArArchiveFile, so say so. Found running the SDK's static libraries through the reader. --- src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs | 5 +++++ src/LibObjectFile/Diagnostics/DiagnosticId.cs | 1 + src/LibObjectFile/MachO/MachOFile.Read.cs | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 6f6524e..04a7551 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -620,6 +620,11 @@ public void ReportsRatherThanThrowsOnUnreadableInput() Assert.IsFalse(MachOFile.TryRead(input, out _, out var diagnostics)); Assert.IsTrue(diagnostics.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidMagic)); + // A universal static library is a fat file of ar archives rather than images, which is + // a real thing to be handed and worth naming instead of reporting a stray magic. + Assert.IsFalse(MachOFile.TryRead(new MemoryStream("!\n"u8.ToArray()), out _, out var archive)); + Assert.IsTrue(archive.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_UnexpectedArchive), string.Join("; ", archive.Messages)); + foreach (var length in new[] { 0, 1, 3 }) { Assert.IsFalse(MachOFile.TryRead(new MemoryStream(new byte[length]), out _, out var tooShort), $"a {length}-byte stream cannot be an image"); diff --git a/src/LibObjectFile/Diagnostics/DiagnosticId.cs b/src/LibObjectFile/Diagnostics/DiagnosticId.cs index 10168bf..8563691 100644 --- a/src/LibObjectFile/Diagnostics/DiagnosticId.cs +++ b/src/LibObjectFile/Diagnostics/DiagnosticId.cs @@ -240,4 +240,5 @@ public enum DiagnosticId MACHO_ERR_DuplicateFatSlice = 5022, MACHO_ERR_MissingFatSliceImage = 5023, MACHO_ERR_UnexpectedEndOfStream = 5024, + MACHO_ERR_UnexpectedArchive = 5025, } \ No newline at end of file diff --git a/src/LibObjectFile/MachO/MachOFile.Read.cs b/src/LibObjectFile/MachO/MachOFile.Read.cs index 27f7050..bb95885 100644 --- a/src/LibObjectFile/MachO/MachOFile.Read.cs +++ b/src/LibObjectFile/MachO/MachOFile.Read.cs @@ -128,6 +128,11 @@ public override unsafe void Read(MachOReader reader) // universal binary sees the swapped spelling. reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_UnexpectedFatFile, "This is a universal binary rather than a single Mach-O image. Read it with MachOFatFile and pick a slice."); return; + case ArchiveMagic: + // A universal static library is a fat file whose slices are ar archives rather + // than images, so this is what reading one slice by slice arrives at. + reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_UnexpectedArchive, "This is an ar archive rather than a Mach-O image. Read it with ArArchiveFile."); + return; default: reader.Diagnostics.Error(DiagnosticId.MACHO_ERR_InvalidMagic, $"Invalid Mach-O magic 0x{magic:X8}"); return; @@ -171,6 +176,9 @@ public override unsafe void Read(MachOReader reader) ReadContent(reader); } + // The first four bytes of "!\n", read the way the magic above is. + private const uint ArchiveMagic = 0x72613C21; + /// /// Walks the load command table. The table is bounded by the sizeofcmds the header /// declares, each command by its own cmdsize, so a command cannot reach into the one From 1462ff0abd24d762d87eabdd50f8075c1b80f526 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 07:06:17 -0400 Subject: [PATCH 30/32] Fix the fat slice alignment bound being looser than the format allows The bound was 63, which is where a shift count stops being well defined rather than where a universal binary stops being valid. lipo refuses anything past 2^15, reporting that -segalign "must be equal to or less than 8000 (hex)", which matches MAXSECTALIGN in cctools. At 63 we would have accepted and written fat files Apple's own tools reject. Checked against lipo on macOS 13.7.6. --- src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs | 9 +++++---- src/LibObjectFile/MachO/MachOFatFile.Verify.cs | 2 +- src/LibObjectFile/MachO/MachOFatFile.cs | 2 +- src/LibObjectFile/MachO/MachOFatSlice.cs | 12 ++++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs index 04a7551..198314b 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSimpleTests.cs @@ -596,11 +596,12 @@ static int FindCommand(byte[] b, uint type, int skip) Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(fat), out _, out var d4)); Assert.IsTrue(d4.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatHeader), string.Join("; ", d4.Messages)); - // A shift count is masked to the width of what it shifts, so an alignment exponent past - // 63 would alias to a different alignment rather than being rejected. The align field of - // the first slice sits at the end of its 20-byte entry, and the slice table is big-endian. + // lipo refuses an alignment past 2^15, and a shift count is masked to the width of what + // it shifts, so an unchecked exponent would alias to a different alignment rather than + // being rejected. The align field of the first slice sits at the end of its 20-byte + // entry, and the slice table is big-endian. var badAlign = File.ReadAllBytes(GetFile("helloworld_fat")); - BitConverter.GetBytes(64u).Reverse().ToArray().CopyTo(badAlign, MachOFatFile.HeaderSize + 16); + BitConverter.GetBytes(MachOFatSlice.MaxAlignLog2 + 1).Reverse().ToArray().CopyTo(badAlign, MachOFatFile.HeaderSize + 16); Assert.IsFalse(MachOFatFile.TryRead(new MemoryStream(badAlign), out _, out var d7)); Assert.IsTrue(d7.Messages.Any(m => m.Id == DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment), string.Join("; ", d7.Messages)); } diff --git a/src/LibObjectFile/MachO/MachOFatFile.Verify.cs b/src/LibObjectFile/MachO/MachOFatFile.Verify.cs index d3805ad..1eb1c1a 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.Verify.cs @@ -52,7 +52,7 @@ public void Verify(DiagnosticBag diagnostics) { diagnostics.Error( DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, - $"Slice {i} ({slice.CpuType}) has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a shift can express"); + $"Slice {i} ({slice.CpuType}) has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a universal binary allows"); } else if ((slice.FileOffset & (slice.Alignment - 1)) != 0) { diff --git a/src/LibObjectFile/MachO/MachOFatFile.cs b/src/LibObjectFile/MachO/MachOFatFile.cs index 155e576..e039c0d 100644 --- a/src/LibObjectFile/MachO/MachOFatFile.cs +++ b/src/LibObjectFile/MachO/MachOFatFile.cs @@ -176,7 +176,7 @@ public static bool TryRead(Stream stream, [NotNullWhen(true)] out MachOFatFile? if (slice.AlignLog2 > MachOFatSlice.MaxAlignLog2) { - bag.Error(DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, $"Slice {i} has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a shift can express"); + bag.Error(DiagnosticId.MACHO_ERR_InvalidFatSliceAlignment, $"Slice {i} has an alignment exponent of {slice.AlignLog2}, past the {MachOFatSlice.MaxAlignLog2} a universal binary allows"); file = null; return false; } diff --git a/src/LibObjectFile/MachO/MachOFatSlice.cs b/src/LibObjectFile/MachO/MachOFatSlice.cs index c50a08b..0e53ebe 100644 --- a/src/LibObjectFile/MachO/MachOFatSlice.cs +++ b/src/LibObjectFile/MachO/MachOFatSlice.cs @@ -27,11 +27,15 @@ public sealed class MachOFatSlice /// The largest alignment exponent a slice can carry. /// /// - /// A shift count is masked to the width of the value it shifts, so an exponent past this - /// would quietly alias to a different alignment instead of being rejected. Real images use - /// 12 to 14, the page size of the architecture. + /// This is the limit lipo enforces, which reports that -segalign "must be equal to or + /// less than 8000 (hex)". Real images use 12 to 14, the page size of the architecture. + /// + /// The bound matters beyond matching the tooling: a shift count is masked to the width of + /// the value it shifts, so an unchecked exponent would quietly alias to a different + /// alignment rather than being rejected. + /// /// - public const uint MaxAlignLog2 = 63; + public const uint MaxAlignLog2 = 15; /// /// Gets or sets the alignment of this slice as a power of two. It is the page size of the From 5662812e6e0cc051ef73871f29022155a48d69c8 Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 07:11:50 -0400 Subject: [PATCH 31/32] Update doc for why object files skip the section address check The exemption cited the fixture that exposed it rather than the rule. loader.h states it directly: non-MH_OBJECT files have their sections padded to the segment alignment, while MH_OBJECT keeps all sections in one segment for compactness with no padding to a segment boundary. --- src/LibObjectFile/MachO/MachOFile.Verify.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/LibObjectFile/MachO/MachOFile.Verify.cs b/src/LibObjectFile/MachO/MachOFile.Verify.cs index 3dc2401..bdb172b 100644 --- a/src/LibObjectFile/MachO/MachOFile.Verify.cs +++ b/src/LibObjectFile/MachO/MachOFile.Verify.cs @@ -119,9 +119,12 @@ private void VerifyContentCoversTheFile(MachOVisitorContext context) private void VerifySegments(MachOVisitorContext context) { - // An object file holds its sections in one unnamed segment and gives them addresses the - // linker has yet to assign for real, so they do not track file offsets the way a linked - // image's do. Apple's own crt1.o breaks the invariant below. + // loader.h draws the line here: non-MH_OBJECT files have "all of their segments with the + // proper sections in each, and padded to the specified segment alignment", while + // "the MH_OBJECT format has all of its sections in one segment for compactness. There is + // no padding to a specified segment boundary". Packed for compactness means a section's + // address does not track its file offset, so the invariant below is for linked images + // only. Apple's own crt1.o breaks it. var isLinkedImage = FileType != MachOFileType.Object; foreach (var segment in Segments) From e1d53d77adddc563acd18bd5fed9c8ceaa52a63f Mon Sep 17 00:00:00 2001 From: Jack Greiner Date: Thu, 3 Sep 2026 07:20:32 -0400 Subject: [PATCH 32/32] Fix __LINKEDIT ending mid-page after signing Signing grew __LINKEDIT to cover the signature but only to exactly that size. A segment is sized in whole units of the segment alignment, and codesign rounds it up: signing the 8MB i386 KOTOR binary with Apple's tool takes __LINKEDIT from 0x30D000 to 0x32C000, where we produced 0x31CB00. It was not visible in the fixtures because their signature fits in the slack the linker already left. A real image does not: that binary has 0x7CC spare and the signature is about 65KB. loader.h calls the value "the specified segment alignment" and leaves it to the link editor, so it comes from what the toolchain does. Signing an arm64 image rounds 0x4A50 to 0x8000, which only 16KB explains, while the linker's own x86 and x86_64 images carry sizes such as 0x30D000 that are not 16KB multiples. Checked on macOS 13.7.6: both arm64 images pass codesign --verify --strict. --- .../MachO/MachOSigningTests.cs | 17 +++++++++++++++++ src/LibObjectFile/MachO/MachOFile.Sign.cs | 6 +++++- src/LibObjectFile/MachO/MachOFile.cs | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs index 6d3a5cd..9c80e05 100644 --- a/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs +++ b/src/LibObjectFile.Tests/MachO/MachOSigningTests.cs @@ -185,6 +185,23 @@ public void SigningLeavesEverySectionWhereItWas() Assert.IsNull(before.CodeSignature); Assert.AreEqual(before.LoadCommands.Count + 1, after.LoadCommands.Count); Assert.AreEqual(0u, after.CodeSignature.DataOffset % SignatureAlignment, "the signature must be 16-byte aligned"); + + // A segment is sized in whole units of the architecture's segment alignment, and codesign + // rounds __LINKEDIT up when it grows. The fixtures leave enough slack that the signature + // fits inside the existing size, so the slack is removed to make the segment grow. A real + // image reaches this on its own: signing the 8MB i386 one adds about 65KB to a segment + // with 0x7CC to spare. + foreach (var name in new[] { "unixthread_i386", "helloworld_x86_64", "helloworld_arm64" }) + { + var file = LoadMachO(name); + var segment = file.FindSegment("__LINKEDIT")!; + segment.VmSize = segment.FileSize; + file.AdHocSign("swkotor"); + + Assert.IsTrue(segment.VmSize >= segment.FileSize, $"{name} __LINKEDIT does not cover its content"); + Assert.AreEqual(0ul, segment.VmSize % file.SegmentAlignment, + $"{name} __LINKEDIT ends mid-page at 0x{segment.VmSize:X} for a 0x{file.SegmentAlignment:X} alignment"); + } } /// diff --git a/src/LibObjectFile/MachO/MachOFile.Sign.cs b/src/LibObjectFile/MachO/MachOFile.Sign.cs index 325dc0e..2c68833 100644 --- a/src/LibObjectFile/MachO/MachOFile.Sign.cs +++ b/src/LibObjectFile/MachO/MachOFile.Sign.cs @@ -109,7 +109,11 @@ public void AdHocSign(string identifier) command.DataSize = signatureSize; linkEdit.FileSize = signatureOffset + signatureSize - linkEdit.FileOffset; - linkEdit.VmSize = Math.Max(linkEdit.VmSize, linkEdit.FileSize); + + // The signature is far larger than the slack the linker left, so __LINKEDIT grows and + // has to be rounded to a segment boundary the way codesign rounds it. Without this it + // ends mid-page, which is a shape no linker and no signing tool produces. + linkEdit.VmSize = AlignHelper.AlignUp(Math.Max(linkEdit.VmSize, linkEdit.FileSize), SegmentAlignment); // Aligning the signature can leave a gap, and every byte of the file has to belong to // some content, so the padding is added rather than left as a hole in the list. diff --git a/src/LibObjectFile/MachO/MachOFile.cs b/src/LibObjectFile/MachO/MachOFile.cs index dac4e0b..6d4b290 100644 --- a/src/LibObjectFile/MachO/MachOFile.cs +++ b/src/LibObjectFile/MachO/MachOFile.cs @@ -118,6 +118,23 @@ public MachODylibCommand? IdDylib /// internal const uint MinHeaderSize = 28; + /// + /// Gets the segment alignment of this image's architecture, which segment sizes are rounded + /// up to. + /// + /// + /// loader.h calls this "the specified segment alignment", a link time choice rather than + /// anything the header records, so it is taken from what the toolchain does per + /// architecture: signing an arm64 image rounds __LINKEDIT from 0x4A50 to 0x8000, which only + /// 16KB explains, while the linker's own x86 and x86_64 images carry sizes such as 0x30D000 + /// that are not 16KB multiples at all. + /// + /// This is not the page size a code signature hashes in, which is 4KB on every architecture + /// including arm64. + /// + /// + public ulong SegmentAlignment => CpuType == MachOCpuType.Arm64 ? 0x4000ul : 0x1000ul; + /// /// Gets the total size of the load commands, the value stored in sizeofcmds. ///