/** * @file Architecture.cpp * @brief Per-architecture jump-patch backends for x86, x86-64, ARM64, and ARM32 (both the ARM * and Thumb-2 instruction sets). */ #include "Architecture.hpp" #include #include #include /* WHY THERE CAN BE A "LANDING PAD" IN FRONT OF A FUNCTION * ----------------------------------------------------------- * Modern toolchains can emit a marker instruction as the very first * instruction of a function, required by a CPU-level indirect-branch * protection feature: x86 CET ("Control-flow Enforcement Technology", * ENDBR64/ENDBR32) and ARM64 BTI ("Branch Target Identification"). * When the CPU enforces either, an indirect call/jump (which is * exactly how a call through a function pointer, or a call across a * shared-library boundary via the PLT/GOT, actually reaches a * function) is only allowed to land on one of these marker * instructions -- landing anywhere else raises a fault. * * This matters here because it is genuinely on by default in * mainstream environments, not a rare opt-in: this exact toolchain * (Ubuntu 24.04's system GCC 13, x86-64) emits ENDBR64 at every * function's entry with no extra flags beyond -fpatchable-function-entry, * confirmed empirically while building this library -- * `gcc -O2 -fpatchable-function-entry=16,0` alone was enough to produce * an `endbr64` as the very first instruction, ahead of the requested * NOP padding. Overwriting it unconditionally would silently work on * a CPU/kernel combination that does not enforce IBT and silently * crash on one that does -- exactly the kind of environment-dependent * failure this library exists to avoid. * * So every backend below first checks the live bytes at the function's * entry for its architecture's landing-pad encoding and, if present, * treats it as a fixed, immovable prefix: the jump is written * immediately after it rather than over it. Since a landing pad is * defined to otherwise behave as a NOP, falling through it into our * jump is always correct whether or not the CPU actually enforces the * protection it marks. * * WRITE ORDERING (AND WHAT IT DOES / DOES NOT PROTECT AGAINST) * ----------------------------------------------------------- * Every backend below writes the target-address bytes first and the * leading opcode/instruction word that "arms" the jump last, with a * release fence in between. This protects the realistic case this * library is designed for: hot_reload_reload() runs at a quiescent * point (e.g. your main thread between frames) where nothing is * actively calling INTO the function while it is being patched, and * this ordering guarantees that any call arriving right at that * boundary sees either the fully-old or the fully-new jump, never a * target address that is half-old-half-new. It does NOT make the * write safe against a thread that is concurrently, actively * executing through the exact bytes being patched -- doing that in * general on a variable-length ISA needs a signal-handler-based retry * protocol (a much bigger mechanism, well beyond a dev-loop tool's * requirements). See the "Limitations" section of the top-level README * for this precondition stated plainly. */ namespace hot_reload::detail { namespace { #if defined(__x86_64__) || defined(_M_X64) /// Size in bytes of the CET landing pad (`endbr64`) a function may start with. constexpr std::size_t kLandingPadBytes = 4; /// Encoding of `endbr64`, the x86-64 CET indirect-branch landing pad. constexpr unsigned char kEndbr64[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFA}; /// Size in bytes of the jump written over a function's entry: /// `jmp qword ptr [rip+0]` followed by an 8-byte absolute address. That reaches any /// 64-bit address regardless of how far apart the old and new modules were loaded, /// unlike a 5-byte relative jump (`E9 rel32`) which only reaches +/-2GB. Verified /// byte-for-byte against `as`/objdump output while building this library. constexpr std::size_t kJumpBytes = 14; /// Whether the function at @p entry starts with an `endbr64` landing pad. /// @param entry The function's entry address (at least kLandingPadBytes readable bytes). /// @return True if the first bytes are `endbr64`. bool hasLandingPad(const unsigned char* entry) { return std::memcmp(entry, kEndbr64, kLandingPadBytes) == 0; } /// Writes the x86-64 absolute jump at @p site, payload first and the opcode last. /// @param site Where the jump goes (just past the landing pad, if there is one). /// @param target Address the jump transfers control to. void writeJumpAt(unsigned char* site, const void* target) { std::uint64_t targetValue = 0; std::memcpy(&targetValue, &target, sizeof(target)); // Payload first: the absolute address (site+6) and the // constant zero rip-displacement (site+2) are only ever // consumed as DATA once the opcode below is in place, so // writing them early cannot be misinterpreted as a // different, fresh instruction stream by anything that // might fall through this region in its old, all-NOP state. std::memcpy(site + 6, &targetValue, sizeof(targetValue)); std::memcpy(site + 2, "\x00\x00\x00\x00", 4); std::atomic_thread_fence(std::memory_order_release); // Arm last: until these 2 bytes land, site+0 still reads as // whatever it was (a NOP, on first patch; a previous FF 25 // on a later reload -- either way harmless/inert). unsigned char opcode[2] = {0xFF, 0x25}; std::memcpy(site, opcode, sizeof(opcode)); std::atomic_thread_fence(std::memory_order_release); } #elif defined(__i386__) || defined(_M_IX86) /// Size in bytes of the CET landing pad (`endbr32`) a function may start with. constexpr std::size_t kLandingPadBytes = 4; /// Encoding of `endbr32`, the 32-bit x86 CET indirect-branch landing pad. constexpr unsigned char kEndbr32[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFB}; /// Size in bytes of the jump written over a function's entry: `e9 rel32`, a plain /// relative near jump. On 32-bit x86 the entire address space fits in the signed 32-bit /// displacement (mod 2^32 wraparound gives the right answer regardless of "distance"), /// so unlike x86-64 there is no need for an absolute/indirect form here. constexpr std::size_t kJumpBytes = 5; /// Whether the function at @p entry starts with an `endbr32` landing pad. /// @param entry The function's entry address (at least kLandingPadBytes readable bytes). /// @return True if the first bytes are `endbr32`. bool hasLandingPad(const unsigned char* entry) { return std::memcmp(entry, kEndbr32, kLandingPadBytes) == 0; } /// Writes the x86 relative jump at @p site, displacement first and the opcode last. /// @param site Where the jump goes (just past the landing pad, if there is one). /// @param target Address the jump transfers control to. void writeJumpAt(unsigned char* site, const void* target) { std::int32_t rel32 = static_cast( reinterpret_cast(target) - reinterpret_cast(site) - 5); // Payload (the displacement) first, opcode byte last -- // same reasoning as the x86-64 backend above. std::memcpy(site + 1, &rel32, sizeof(rel32)); std::atomic_thread_fence(std::memory_order_release); site[0] = 0xE9; std::atomic_thread_fence(std::memory_order_release); } #elif defined(__aarch64__) || defined(_M_ARM64) /// Size in bytes of the BTI landing pad a function may start with (one instruction word). constexpr std::size_t kLandingPadBytes = 4; /// Whether the instruction word at @p entry is any BTI variant (plain/C/J/JC): bits /// [31:6] are fixed at 0b010101000000001001001, differing only in bits [6:5] (the /// qualifier) -- equivalently, the low byte is one of 0x1F/0x5F/0x9F/0xDF and the next 3 /// bytes are always 24 03 D5. Verified with `as`/objdump for all 4 variants while /// building this library. /// @param entry The function's entry address (at least 4 readable bytes). /// @return True if the first instruction is a BTI landing pad. bool isBti(const unsigned char* entry) { return (entry[0] & 0x3Fu) == 0x1Fu && entry[1] == 0x24 && entry[2] == 0x03 && entry[3] == 0xD5; } /// Whether the function at @p entry starts with a BTI landing pad. /// @param entry The function's entry address (at least kLandingPadBytes readable bytes). /// @return True if the first instruction is a BTI landing pad. bool hasLandingPad(const unsigned char* entry) { return isBti(entry); } /// Size in bytes of the jump written over a function's entry: /// `ldr x17, #8 ; br x17 ; <8-byte absolute address>`. x16/x17 are AArch64's designated /// "intra-procedure-call" scratch registers (IP0/IP1) -- the AAPCS64 explicitly reserves /// them for exactly this kind of linker/veneer-style long branch, so clobbering them at /// a function's entry point, before any of its real prologue has executed, is always /// safe. Byte values verified with `as`/objdump while building this library. constexpr std::size_t kJumpBytes = 16; /// Encoding of `ldr x17, #8` (loads the literal address stored 8 bytes further on). constexpr unsigned char kLdrX17Pc8[4] = {0x51, 0x00, 0x00, 0x58}; /// Encoding of `br x17` (branches to the address `ldr x17, #8` loaded). constexpr unsigned char kBrX17[4] = {0x20, 0x02, 0x1F, 0xD6}; /// Writes the ARM64 `ldr`/`br` pair plus literal address at @p site, payload first and /// the leading `ldr` last. /// @param site Where the jump goes (just past the landing pad, if there is one). /// @param target Address the jump transfers control to. void writeJumpAt(unsigned char* site, const void* target) { std::uint64_t targetValue = 0; std::memcpy(&targetValue, &target, sizeof(target)); // Payload (the literal address) first. ARM64 instructions // are fixed-width and naturally aligned, so -- unlike the // variable-length x86 case -- each 4-byte slot here is // unambiguous: it is either the old NOP, the old // instruction, or the new one, never a byte-misaligned // mix. Writing high-to-low keeps the two real instructions // (which is what any in-flight fall-through NOP execution // could actually reach) inert for as long as possible. std::memcpy(site + 8, &targetValue, sizeof(targetValue)); std::atomic_thread_fence(std::memory_order_release); std::memcpy(site + 4, kBrX17, sizeof(kBrX17)); std::atomic_thread_fence(std::memory_order_release); // Arm last: until this word lands, site+0 still reads as a // NOP (or a previous ldr, on a later reload) and execution // never reaches the br at site+4 via fall-through. std::memcpy(site, kLdrX17Pc8, sizeof(kLdrX17Pc8)); std::atomic_thread_fence(std::memory_order_release); } #elif defined(__arm__) || defined(_M_ARM) /* 32-bit ARM is a genuinely different shape from the three backends above: every * other architecture here picks one instruction encoding and a single fixed * kJumpBytes. ARM32 cannot, for a reason specific to this architecture: a CPU in * this family is always in one of two incompatible instruction-decoding states, ARM * (fixed 4-byte instructions) or Thumb (2-byte and 4-byte Thumb-2 instructions), and * a function's own compiled state -- never a free choice this library gets to make, * only something to detect and match -- decides which encoding is even legal to * write at its address. The ARM EABI records this on every function symbol: bit 0 of * its value is set if and only if the function is Thumb code (so that loading the * symbol's own value into pc, exactly what this backend's jump does, switches the * CPU to the right state automatically) -- SymbolTable.cpp's `loadedBase + st_value` * already preserves that bit unmodified for every architecture, so detecting the * mode here is as simple as reading it back off the address this backend is handed. * * A second, smaller wrinkle is specific to Thumb state alone: its `ldr pc, [...]` * literal-load form can only read from a 4-byte-aligned address, computed from the * instruction's OWN address rounded down to 4 bytes -- but a Thumb function is only * ever guaranteed 2-byte alignment, not 4 (confirmed directly: building a plugin * with -falign-functions=1 reliably produces functions starting 2 bytes off a * 4-byte boundary). So Thumb state alone needs a one-instruction fork: a 2-byte NOP * realigns first when the entry isn't already on a 4-byte boundary, nothing extra * when it already is. ARM state needs no such fork -- that state requires 4-byte * alignment architecture-wide, so every ARM-mode function already has it. * * No landing pad exists on this architecture in any mainstream toolchain or CPU * extension in common use (unlike x86 CET or ARM64 BTI) -- confirmed directly: * -fpatchable-function-entry's reserved padding here is plain NOPs in both states, * nothing else, so there is never anything to detect or preserve in front of a * function's real body. * * Every byte sequence below was derived the same way as the other three backends: * assembled with `as` from the exact mnemonics named in each comment, and the * resulting bytes read back with objdump -- including, for the Thumb realignment * case specifically, assembling it at both a 4-byte-aligned and a 2-mod-4 address * and confirming objdump's own computed literal-load target lands exactly on the * embedded address both times. */ /// No landing pad exists on this architecture (see the file comment above), so unlike /// the three backends above, there is no hasLandingPad()/kLandingPadBytes pair here: /// the outer planPatch() below sets PatchLayout::patchOffset to 0 directly instead. /// @param address A function's address as this library carries it: `loadedBase + /// st_value`, Thumb's mode bit (bit 0) included unmodified. /// @return True if @p address names Thumb code, per the ARM EABI convention. bool isThumb(const void* address) { return (reinterpret_cast(address) & std::uintptr_t{1}) != 0; } /// @param address A function's address, Thumb mode bit possibly included. /// @return The real, even byte address of its first instruction -- bit 0 is a mode /// marker on this architecture, never part of the actual memory location. unsigned char* realAddress(const void* address) { return reinterpret_cast(reinterpret_cast(address) & ~std::uintptr_t{1}); } /// `ldr pc, [pc, #-4]`: ARM state's literal-load jump. ARM state is always 4-byte /// aligned architecture-wide, so this one fixed form always applies; the embedded /// address follows immediately, 8 bytes total. constexpr unsigned char kArmJump[4] = {0x04, 0xF0, 0x1F, 0xE5}; /// `ldr.w pc, [pc, #0]`: Thumb-2's literal-load jump, valid only when this /// instruction's own address is 4-byte aligned (see the file comment above); the /// embedded address follows immediately, 8 bytes total from wherever this is placed. constexpr unsigned char kThumbJump[4] = {0xDF, 0xF8, 0x00, 0xF0}; /// The Thumb-2 `nop` hint instruction -- matches the padding GCC's own /// -fpatchable-function-entry emits on this target -- used only to realign kThumbJump /// onto a 4-byte boundary when the function's own entry isn't already on one. constexpr unsigned char kThumbNop[2] = {0x00, 0xBF}; /// @param entryAddress The function's entry address, Thumb bit included. /// @return 8 for ARM state or an already-4-byte-aligned Thumb entry; 10 for a Thumb /// entry starting 2 bytes off a 4-byte boundary (room for the one extra /// realigning NOP in front of kThumbJump -- see the file comment above). /// Unlike every other backend in this file, this one genuinely depends on /// the entry address itself, not just the architecture. std::size_t jumpBytes(const void* entryAddress) { if (!isThumb(entryAddress)) { return 8; } bool misaligned = (reinterpret_cast(realAddress(entryAddress)) & std::uintptr_t{2}) != 0; return misaligned ? 10 : 8; } /// Writes the ARM32 jump at @p site: the ARM-state form if @p site is ARM code, the /// Thumb-2 form (with a realigning NOP first, if needed) if it is Thumb -- @p site's /// own bit 0 says which, exactly as isThumb() reads it, and there is no separate /// landing pad to skip past on this architecture (see hasLandingPad()), so @p site is /// always the function's own entry address, unmodified. /// @param site Where the jump goes. /// @param target Address the jump transfers control to, Thumb bit preserved so the /// `ldr` that loads it into pc correctly switches state at the destination, /// too, exactly as calling this address normally would. void writeJumpAt(const void* site, const void* target) { std::uint32_t targetValue = static_cast(reinterpret_cast(target)); unsigned char* bytes = realAddress(site); if (!isThumb(site)) { // Payload first, opcode last -- same reasoning as every other backend above. std::memcpy(bytes + 4, &targetValue, sizeof(targetValue)); std::atomic_thread_fence(std::memory_order_release); std::memcpy(bytes, kArmJump, sizeof(kArmJump)); std::atomic_thread_fence(std::memory_order_release); return; } bool misaligned = (reinterpret_cast(bytes) & std::uintptr_t{2}) != 0; unsigned char* jumpAt = misaligned ? bytes + 2 : bytes; // The realigning NOP, if there is one, is pure filler -- inert whether a reader // sees it before or after this write, and byte-for-byte identical on every // reload of this same (fixed) address, so it is written first, unconditionally, // with no fence of its own: it never participates in the old-jump/new-jump // transition the fences below protect. if (misaligned) { std::memcpy(bytes, kThumbNop, sizeof(kThumbNop)); } // Payload next, opcode last -- same reasoning as every other backend above: until // kThumbJump lands, jumpAt still reads as whatever it was (old NOP padding, or a // previous ldr.w, on a later reload), and the fully-written literal sitting // ahead of it is inert until something actually loads it. std::memcpy(jumpAt + 4, &targetValue, sizeof(targetValue)); std::atomic_thread_fence(std::memory_order_release); std::memcpy(jumpAt, kThumbJump, sizeof(kThumbJump)); std::atomic_thread_fence(std::memory_order_release); } #endif } bool isArchitectureSupported() { #if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \ defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) return true; #else return false; #endif } PatchLayout planPatch(const void* entryAddress) { #if defined(__arm__) || defined(_M_ARM) // ARM32's own kJumpBytes genuinely depends on entryAddress (ARM state vs. Thumb state, // and for Thumb, alignment) -- see Architecture.cpp's ARM32 file comment above -- so it // cannot share the single-constant shape the other three architectures below do; there // is never a landing pad to skip past on this architecture either (hasLandingPad() is // always false), so patchOffset is always 0. return PatchLayout{jumpBytes(entryAddress), 0}; #elif defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \ defined(_M_ARM64) const unsigned char* entry = static_cast(entryAddress); PatchLayout layout; layout.patchOffset = hasLandingPad(entry) ? kLandingPadBytes : 0; layout.totalBytes = layout.patchOffset + kJumpBytes; return layout; #else (void) entryAddress; return PatchLayout{}; #endif } bool writeJump(void* entryAddress, const void* targetAddress) { #if defined(__arm__) || defined(_M_ARM) // entryAddress is passed through unmodified (not cast to unsigned char* the way the // generic path below does): its own bit 0, the Thumb/ARM mode marker, is exactly what // writeJumpAt() needs to pick the right encoding -- see Architecture.cpp's ARM32 file // comment above. There is no landing pad on this architecture to offset past first. writeJumpAt(entryAddress, targetAddress); return true; #elif defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \ defined(_M_ARM64) PatchLayout layout = planPatch(entryAddress); unsigned char* site = static_cast(entryAddress) + layout.patchOffset; writeJumpAt(site, targetAddress); return true; #else (void) entryAddress; (void) targetAddress; return false; #endif } }