Live coding notes on dynamic instrumentation with Frida
This post accompanies a talk on Frida me and my colleague Thomas Wimmer are giving at the FH Linux User Group in Hagenberg.
Getting Started
We start out by writing a simple test program and saving in in a file called hello.c
.
#include <stdio.h>
#include <unistd.h>
void f (int n)
{
printf ("Number: %d\n", n);
}
int main (int argc, char * argv[])
{
int i = 0;
printf ("f() is at %p\n", f);
while (1)
{
f (i++);
sleep (1);
}
}
We compile this program using any C compiler and run it.
gcc -Wall hello.c -o hello
./hello
We use Frida to attach to the test program.
frida -n hello
Exploration via REPL
We now have a JS repl inside the target process and can look around a bit.
We can find the beginning of where our hello
module is mapped in memory.
[Local::hello]-> hello = Module.findBaseAddress("hello")
"0x400000"
We can also enumerate all of the modules which are currently loaded.
[Local::hello]-> Process.enumerateModulesSync()
[
{
"base": "0x400000",
"name": "hello",
"path": "/home/mschwaig/Documents/frida/hello",
"size": 2105344
},
{
"base": "0x7ff66c68c000",
"name": "libc-2.23.so",
"path": "/lib/x86_64-linux-gnu/libc-2.23.so",
"size": 3956736
},
{
"base": "0x7ff66ca56000",
"name": "ld-2.23.so",
"path": "/lib/x86_64-linux-gnu/ld-2.23.so",
"size": 2256896
},
{
"base": "0x7ff66c46f000",
"name": "libpthread-2.23.so",
"path": "/lib/x86_64-linux-gnu/libpthread-2.23.so",
"size": 2199552
},
{
"base": "0x7ff66a9b3000",
"name": "frida-agent-64.so",
"path": "/tmp/frida-6eaab3c624da904002f46fc37334f873/frida-agent-64.so",
"size": 19435520
},
{
"base": "0x7ff66a798000",
"name": "libresolv-2.23.so",
"path": "/lib/x86_64-linux-gnu/libresolv-2.23.so",
"size": 2199552
},
{
"base": "0x7ff66a594000",
"name": "libdl-2.23.so",
"path": "/lib/x86_64-linux-gnu/libdl-2.23.so",
"size": 2113536
},
{
"base": "0x7ff66a38c000",
"name": "librt-2.23.so",
"path": "/lib/x86_64-linux-gnu/librt-2.23.so",
"size": 2129920
},
{
"base": "0x7ff66a083000",
"name": "libm-2.23.so",
"path": "/lib/x86_64-linux-gnu/libm-2.23.so",
"size": 3182592
}
]
Additionally we can also find the exact memory ranges which are mapped for specific modules.
[Local::hello]-> Module.enumerateRangesSync("hello", "---")
[
{
"base": "0x400000",
"file": {
"offset": 0,
"path": "/home/mschwaig/Documents/frida/hello",
"size": 0
},
"protection": "r-x",
"size": 4096
},
{
"base": "0x600000",
"file": {
"offset": 0,
"path": "/home/mschwaig/Documents/frida/hello",
"size": 0
},
"protection": "r--",
"size": 4096
},
{
"base": "0x601000",
"file": {
"offset": 4096,
"path": "/home/mschwaig/Documents/frida/hello",
"size": 0
},
"protection": "rw-",
"size": 4096
}
]
We can print chunks of memory.
[Local::hello]-> hellomem = Memory.readByteArray(hello, 64)
0000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 .ELF............
0010 02 00 3e 00 01 00 00 00 70 04 40 00 00 00 00 00 ..>.....p.@.....
0020 40 00 00 00 00 00 00 00 28 1a 00 00 00 00 00 00 @.......(.......
0030 00 00 00 00 40 00 38 00 09 00 40 00 1f 00 1c 00 ....@.8...@.....
There is a tool available called fridump
which does this for the whole process I beleive. We would not want to do that via the REPL.
Writing memory is also safe as you can see. Instead of crashing on something like this frida tends to crash when you run into some unpolished corner case with the API.
[Local::hello]-> Memory.writeU64(ptr(hello), 0)
Error: access violation accessing 0x400000
I tried and I cannot see the stack this way. I think this depends on the calling convention of the architecture. The first argument for x86
and x86_64
simply sits in a register.
We can now close the REPL using the exit
command.
Changing f()
via script
Now we crate a .js
file and open it in the editor of our choice.
touch explore.js
We then restart Frida and attach to our test program while also loading the file we created.
frida -n hello -l explore.js
We can now print whenever f()
is called.
Interceptor.attach(ptr(0x400566), {
onEnter: function(args) {
console.log("Calling f()");
}
});
We can also print the arguments of method calls.
Interceptor.attach(ptr(0x400566), {
onEnter: function(args) {
console.log("Calling f() with argument " + args[0].toInt32());
}
});
We can switch out the argument for some other value.
Interceptor.attach(ptr(0x400566), {
onEnter: function(args) {
console.log("Calling f() with argument " + args[0].toInt32());
args[0] = ptr(1337)
}
});
Or we can compute a new argument.
Interceptor.attach(ptr(0x400566), {
onEnter: function(args) {
console.log("Calling f() with argument " + args[0].toInt32());
args[0] = ptr(args[0].toInt32()-1337)
}
});
We can also just directly call f()
if we want to.
var f = new NativeFunction(ptr(0x400566), 'void', ['int'])
f(666)
f(667)
f(668)
Changing sleep()
via script
We can also modify functions in other modules. For example sleep
. Take a look at the docs at man 3 sleep
to find out the types info you need.
Then you can find and replace the whole function.
var sleepPtr = Module.findExportByName(null, "sleep");
Interceptor.replace(sleepPtr, new NativeCallback(function (seconds) {
Thread.sleep(1)
return 0;
}, 'int', ['uint']));
Or warp the original function.
var sleepPtr = Module.findExportByName(null, "sleep");
var sleep = new NativeFunction(sleepPtr, 'int', ['uint']);
Interceptor.replace(sleepPtr, new NativeCallback(function (seconds) {
var ret = sleep(seconds);
return ret;
}, 'int', ['uint']));
Or redirect to another function.
var sleepPtr = Module.findExportByName(null, "sleep");
var usleep = new NativeFunction(usleepPtr, 'int', ['uint']);
Interceptor.replace(sleepPtr, new NativeCallback(function (seconds) {
var ret = usleep(seconds*1000000);
return ret;
}, 'int', ['uint']));
Changing printf()
via script
We can also replace the format string argument for prinftf
.
var string = Memory.allocUtf8String("frida: %d\n")
var printfPtr = Module.findExportByName(null, "printf");
Interceptor.attach(printfPtr, {
onEnter: function(args) {
args[0] = string;
}
});
Trust me that we could also allocate memory with arbitrary data for structs and such and pass it around. I will not show this.
Update: Inspecting network requests by an Android app
This update consists of the code snippets from Thomas’s half of the talk where he analyzed the network stack of an Android app, read unencrypted network traffic and bypassed certificate pinning.
Analyze network stack
Let’s see what we can find out about the network stack by logging all classes that contain http
or ssl
in their names. We find out the app most likely uses OkHttp for HTTP and Conscrypt as the JSSE Provider for TLS.
Java.perform(function() {
analyzeNetworkStack()
/**
* Lists all classes containing HTTP or SSL.
*/
function analyzeNetworkStack() {
enumerateClasses("http")
enumerateClasses("ssl")
function enumerateClasses(pattern){
console.log("\n----------"+pattern+"----------")
Java.enumerateLoadedClasses({
"onMatch": function(className){
if(className.toLowerCase().indexOf(pattern) != -1){
console.log(className)
}
},
"onComplete":function(){}
})
}
}
});
Intercept and log request execution
We look at the OkHttp docs. We find it offers both a sync and and async API, so we hook the implementations of both to find out which one our app uses. We find it uses the async one.
Java.perform(function() {
inteceptAndLogRequestExecution();
/**
* Intercepts and logs all execute and enqueue calls of okhttp3.RealCall.
*/
function inteceptAndLogRequestExecution(){
var OkHttp3RealCall = Java.use("okhttp3.RealCall")
OkHttp3RealCall.execute.implementation = function(){
console.log("okhttp3.RealCall.execute called")
return this.execute();
}
OkHttp3RealCall.enqueue.overload('okhttp3.Callback').implementation = function(callbackParam){
console.log("okhttp3.RealCall.enqueue#1 called")
return this.enqueue(callbackParam);
}
OkHttp3RealCall.enqueue.overload('okhttp3.Callback', 'boolean').implementation = function(callbackParam, booleanParam){
console.log("okhttp3.RealCall.enqueue#2 called")
return this.enqueue(callbackParam, booleanParam);
}
}
});
Intercept async request and response
We can now log requests and responses for the async version of the OkHttp API that our target App uses.
Java.perform(function() {
inteceptAsyncRequestAndResponse();
/**
* Intercepts and logs all async request and responses executed of okhttp3.RealCall.
*/
function inteceptAsyncRequestAndResponse(){
var OkHttp3RealCall = Java.use("okhttp3.RealCall")
var OkioBuffer = Java.use("okio.Buffer");
OkHttp3RealCall.enqueue.overload('okhttp3.Callback', 'boolean').implementation = function(callbackParam, booleanParam){
Java.perform(function(){
//Get the implementation class of okhttp3.Callback
var callbackClass = Java.use(callbackParam.$className);
//Intercept the callbacks onResponse() method
callbackClass.onResponse.implementation = function(call, response){
logRequest(response.request())
logResponse(response)
this.onResponse(call, response);
}
})
this.enqueue(callbackParam, booleanParam);
}
function logRequest(request){
console.log('\n--------------------Request---------------------')
logUrl(request);
console.log('---Headers---')
logHeaders(request.headers());
console.log('---Body---')
logBodyAsUtf8(request);
function logUrl(request){
console.log(request.method() + ' ' + request.url().toString());
}
function logBodyAsUtf8(request){
var bufferInstance = OkioBuffer.$new();
request.body().writeTo(bufferInstance);
console.log(bufferInstance.readUtf8());
}
}
function logResponse(response){
console.log('\n--------------------Response---------------------')
console.log('---HTTP Status Code: ' + response.code() + '---')
console.log('---Protocol: ' + response.protocol().name() + '---')
console.log('---Headers---')
logHeaders(response.headers());
console.log('---Body---')
logBodyAsUtf8(response);
function logBodyAsUtf8(response){
var bodyCopy = response.peekBody(922337203685477580)
console.log(bodyCopy.string());
}
}
function logHeaders(headers){
for(var i = 0; i < headers.size(); i++){
var headerName = headers.name(i);
var headerValue = headers.value(i);
console.log(headerName + ': ' + headerValue);
}
}
}
});
Intercept and disable Conscrypt certificate pinning
We are now already able to read plain requests and responses without bypassing the certificate pinning per se. We still want to bypass certificate pinning anyways so that we can capture traffic directly using Wireshark.
From our analysis in the beginning we already know that the target App uses Conscrypt for TLS. By looking at the Conscypt code we can find a good place to disable the certificate pinning.
Java.perform(function() {
interceptAndDisableConscryptCertificatePinning();
/**
* Intercepts and disables conscrypt's certificate chain validation.
*/
function interceptAndDisableConscryptCertificatePinning(){
var OpenSSLSocketImpl = Java.use("com.android.org.conscrypt.OpenSSLSocketImpl")
OpenSSLSocketImpl.verifyCertificateChain.implementation = function(certRefs, authMethod){
console.log("verifyCertificateChain() hooked")
//do nothing
}
}
});