Monkey Patching
What is monkey patching?
Say you have a library that does something useful, but it has a bug, or a missing feature, or you just want to change its behavior. Replacing parts of it at runtime is called monkey patching.
The same idea looks very different across languages. Some languages let you do it in one line. Others need a runtime hooking library and pages of unsafe code.
Both demos do the same thing.
A file on disk says Hello, world!.
The program prints Hello, Filip!.
The file is never touched.
JavaScript: just reassign
In JavaScript a module’s exports are an object whose properties you can overwrite. The library function is just a reference somewhere. Reassign it and every caller sees the new behavior.
import fs from 'node:fs';
const original = fs.readFileSync;
fs.readFileSync = (...args) =>
original(...args).replace('Hello, world!', 'Hello, Filip!');
process.stdout.write(fs.readFileSync('hello.txt', 'utf8')); Hello, world!
Hello, Filip!
The original fs.readFileSync is stashed in a local variable.
A new function takes its place, calls the original, and rewrites the returned string before handing it back.
Every later call to fs.readFileSync anywhere in the process gets the patched version.
Rust: native hooking via frida-gum
Rust does not let you reassign a function.
Once compiled, the call to read is wired to a fixed place.
The way around it is to hook the call itself.
read is a system call wrapper in libc, and frida-gum can redirect any libc function to your own code, so your hook sits in the middle of every call.
This program does that with read.
use frida_gum::{Gum, Module, NativePointer, interceptor::Interceptor};
use libc::{c_int, c_long, c_ulong, c_void};
use std::mem::transmute;
use std::sync::{Mutex, OnceLock};
type ReadFn = unsafe extern "C" fn(c_int, *mut c_void, c_ulong) -> c_long;
static ORIGINAL: Mutex<Option<ReadFn>> = Mutex::new(None);
unsafe extern "C" fn detour(
fd: c_int,
buf: *mut c_void,
count: c_ulong,
) -> c_long {
let read = ORIGINAL.lock().unwrap().unwrap();
let n = unsafe { read(fd, buf, count) };
if n > 0 {
let bytes = unsafe {
std::slice::from_raw_parts_mut(buf as *mut u8, n as usize)
};
if let Some(p) = bytes.windows(13).position(|w| w == b"Hello, world!") {
bytes[p..p + 13].copy_from_slice(b"Hello, Filip!");
}
}
n
}
fn install_hook() {
static GUM: OnceLock<Gum> = OnceLock::new();
let gum = GUM.get_or_init(Gum::obtain);
let libc = Module::load(gum, "libc.so.6");
let read = libc.find_export_by_name("read").unwrap();
let mut interceptor = Interceptor::obtain(gum);
unsafe {
let orig = interceptor.replace(
read,
NativePointer(detour as *mut c_void),
NativePointer(std::ptr::null_mut()),
).unwrap();
*ORIGINAL.lock().unwrap() = Some(transmute(orig.0));
}
}
fn main() {
install_hook();
let path = std::env::args().nth(1).expect("path arg");
print!("{}", std::fs::read_to_string(&path).unwrap());
} [package]
name = "monkey-demo"
version = "0.0.1"
edition = "2024"
[dependencies]
frida-gum = { version = "0.17.0", features = ["auto-download"] }
libc = "0.2"
[profile.release]
lto = true
Hello, world!
Hello, Filip!
Every byte that crosses the read boundary now passes through detour first.
The original read still does the syscall and fills the buffer.
Then the detour finds Hello, world! in those bytes and overwrites it with Hello, Filip! before returning to the caller.
Nothing above libc sees the original.
How this is verified
Each snippet above lives as a real file in this post’s folder. Astro runs the program on every build and embeds the captured output into the page you are reading.
Inside this post’s folder:
src/content/posts/monkey-patching/
├── index.mdx
└── verify/
├── js/
│ ├── hello.txt
│ ├── monkey-patch.mjs
│ └── run.sh
└── rs/
├── Cargo.toml
├── hello.txt
├── run.sh
└── src/main.rs
When Astro builds the site, a pre-step walks each verify/<lang>/ folder and runs its run.sh.
Whatever the script prints to stdout becomes the captured output, stored in a manifest file.
The MDX call that produced the JavaScript snippet:
<RunnableSnippet
source="src/content/posts/monkey-patching/verify/js/monkey-patch.mjs"
outputKey="monkey-patching/js"
lang="javascript"
command="node monkey-patch.mjs"
inputs={["src/content/posts/monkey-patching/verify/js/hello.txt"]}
/>
The result:
- The code in the post is the code that ran.
- The output in the post is what that code actually produced.
- The post will not build at all if either drifts from the other.
Source lives in scripts/verify-snippets.mjs and src/components/RunnableSnippet.astro.