From 54c4bc9374bf49a1ad708c9b25bdb4687ba3ac02 Mon Sep 17 00:00:00 2001 From: "Peter A." Date: Mon, 12 Oct 2026 14:08:37 +0200 Subject: [PATCH] fix(script): isolate RPC replies and retire cancelled calls Prevent late replies to cancelled RPC calls from being delivered to subsequent calls. Preserve ordinary messages and RPC dispatch when handlers change, without changing ExportsCall APIs or reply values. Add unit and native regression tests for cancellation, reply ownership and message routing, including concurrent replies and handler replacement. --- Makefile | 28 ++ README.md | 16 ++ frida/frida.go | 2 +- frida/rpc.go | 120 +++++++++ frida/rpc_native_test.go | 291 ++++++++++++++++++++ frida/rpc_test.go | 352 +++++++++++++++++++++++++ frida/script.go | 180 ++++--------- frida/testdata/.gitignore | 1 + frida/testdata/rpc-target.c | 7 + frida/testdata/rpc-target.entitlements | 3 + 10 files changed, 864 insertions(+), 136 deletions(-) create mode 100644 Makefile create mode 100644 frida/rpc.go create mode 100644 frida/rpc_native_test.go create mode 100644 frida/rpc_test.go create mode 100644 frida/testdata/.gitignore create mode 100644 frida/testdata/rpc-target.c create mode 100644 frida/testdata/rpc-target.entitlements diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..966e934 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +GO ?= go +TESTFLAGS ?= -race -count=1 -timeout=120s +GOOS := $(shell $(GO) env GOOS) +RPC_TARGET := frida/testdata/bin/rpc-target + +.PHONY: test-integration clean +.DELETE_ON_ERROR: + +ifeq ($(filter darwin linux,$(GOOS)),) +test-integration: + @echo "Native RPC tests require macOS or Linux" >&2 + @exit 1 +else +test-integration: $(RPC_TARGET) + $(GO) test -tags=integration $(TESTFLAGS) ./frida + +$(RPC_TARGET): frida/testdata/rpc-target.c + mkdir -p "$(@D)" + $(CC) $(CPPFLAGS) $(CFLAGS) "$<" -o "$@" $(LDFLAGS) $(LDLIBS) +ifeq ($(GOOS),darwin) + /usr/bin/codesign --force --sign - --entitlements frida/testdata/rpc-target.entitlements "$@" + +$(RPC_TARGET): frida/testdata/rpc-target.entitlements +endif +endif + +clean: + rm -f "$(RPC_TARGET)" diff --git a/README.md b/README.md index f80e22c..e4a36cf 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,22 @@ Supported OS: - [x] Android - [x] Windows +# Tests + +With the Frida devkit configured, run `go test ./frida` for unit tests. + +Native RPC integration tests run on macOS and Linux: + +```sh +make test-integration +``` + +This builds the C fixture, signs it on macOS, and runs the tests with the race +detector. Fixture compilation uses `CC`, `CPPFLAGS`, `CFLAGS`, `LDFLAGS`, and +`LDLIBS`; `GO` selects the Go executable. Override `TESTFLAGS` to select tests or +change test options. Each test starts and cleans up its own fixture process. +Run `make clean` to remove the built fixture. + # Small example ```golang package main diff --git a/frida/frida.go b/frida/frida.go index 24ed19b..9aef6a7 100644 --- a/frida/frida.go +++ b/frida/frida.go @@ -12,7 +12,7 @@ package frida #cgo windows LDFLAGS: -lws2_32 -lgdi32 -lole32 -liphlpapi -lsetupapi -lpsapi -lshell32 -lshlwapi -ldnsapi -lcrypt32 -luuid -lshfolder #cgo CFLAGS: -I/usr/local/include/ -w #cgo windows CFLAGS: -Wno-error=incompatible-pointer-types -#cgo darwin LDFLAGS: -lbsm -framework IOKit -framework Foundation -framework AppKit -framework Security -lpthread +#cgo darwin LDFLAGS: -lbsm -lresolv -framework IOKit -framework Foundation -framework AppKit -framework Security -lpthread #cgo darwin CFLAGS: -Wno-error=incompatible-function-pointer-types #cgo android LDFLAGS: -llog #cgo android CFLAGS: -DANDROID -Wno-error=incompatible-function-pointer-types diff --git a/frida/rpc.go b/frida/rpc.go new file mode 100644 index 0000000..1625b81 --- /dev/null +++ b/frida/rpc.go @@ -0,0 +1,120 @@ +package frida + +import ( + "context" + "encoding/json" + "sync" + + "github.com/google/uuid" +) + +type scriptMessageEnvelope struct { + Type string `json:"type"` + Payload []json.RawMessage `json:"payload"` +} + +type scriptRPCRequest struct { + ID string + Method string + Args []any +} + +type scriptRPCReply struct { + ID string + Value any +} + +type pendingScriptRPCCalls struct { + mu sync.Mutex + callsByID map[string]chan any +} + +func (p *pendingScriptRPCCalls) register(id string) <-chan any { + p.mu.Lock() + defer p.mu.Unlock() + + if p.callsByID == nil { + p.callsByID = make(map[string]chan any) + } + // Each call owns its channel. A reply can finish delivery after cancellation. + reply := make(chan any, 1) + p.callsByID[id] = reply + return reply +} + +func (p *pendingScriptRPCCalls) remove(id string) chan any { + p.mu.Lock() + defer p.mu.Unlock() + + reply := p.callsByID[id] + delete(p.callsByID, id) + return reply +} + +func (p *pendingScriptRPCCalls) complete(reply scriptRPCReply) { + // Removing before delivery gives at most one reply access to the channel. + if channel := p.remove(reply.ID); channel != nil { + channel <- reply.Value + } +} + +func (r scriptRPCRequest) MarshalJSON() ([]byte, error) { + args := r.Args + if args == nil { + args = []any{} + } + // Frida's RPC wire format is a positional array, not a JSON object. + return json.Marshal([]any{"frida:rpc", r.ID, "call", r.Method, args}) +} + +func (s *Script) callExport(ctx context.Context, method string, args []any, post func(context.Context, scriptRPCRequest) error) any { + if ctx.Err() != nil { + return ErrContextCancelled + } + request := scriptRPCRequest{ID: uuid.NewString(), Method: method, Args: args} + reply := s.pendingCalls.register(request.ID) + defer s.pendingCalls.remove(request.ID) + if ctx.Err() != nil { + return ErrContextCancelled + } + if err := post(ctx, request); err != nil { + return err + } + select { + case <-ctx.Done(): + return ErrContextCancelled + case value := <-reply: + return value + } +} + +// decodeScriptRPCReply classifies a native message before decoding its reply. +// isRPC remains true for malformed reserved messages; a nil reply discards them. +func decodeScriptRPCReply(message string) (reply *scriptRPCReply, isRPC bool) { + var envelope scriptMessageEnvelope + if json.Unmarshal([]byte(message), &envelope) != nil || envelope.Type != "send" || len(envelope.Payload) == 0 { + return nil, false + } + parts := envelope.Payload + var marker string + if json.Unmarshal(parts[0], &marker) != nil || marker != "frida:rpc" { + return nil, false + } + return decodeScriptRPCPayload(parts[1:]), true +} + +func decodeScriptRPCPayload(parts []json.RawMessage) *scriptRPCReply { + var reply scriptRPCReply + if len(parts) < 3 || json.Unmarshal(parts[0], &reply.ID) != nil || reply.ID == "" { + return nil + } + var status string + if json.Unmarshal(parts[1], &status) != nil || (status != "ok" && status != "error") { + return nil + } + // ExportsCall returns the value for both success and error replies. + if json.Unmarshal(parts[2], &reply.Value) != nil { + return nil + } + return &reply +} diff --git a/frida/rpc_native_test.go b/frida/rpc_native_test.go new file mode 100644 index 0000000..1127b93 --- /dev/null +++ b/frida/rpc_native_test.go @@ -0,0 +1,291 @@ +//go:build integration && (darwin || linux) && !android && !ios + +package frida + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" +) + +func rpcNativeTarget(t *testing.T) string { + t.Helper() + target, err := filepath.Abs("testdata/bin/rpc-target") + if err != nil { + t.Fatalf("resolve native RPC target: %v", err) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("native RPC target: %v; run make test-integration from the repository root", err) + } + return target +} + +func rpcNativeSession(t *testing.T) (*Session, *exec.Cmd) { + t.Helper() + target := rpcNativeTarget(t) + manager := NewDeviceManager() + t.Cleanup(func() { + if err := manager.Close(); err != nil { + t.Error(err) + } + manager.Clean() + }) + device, err := manager.DeviceByType(DeviceTypeLocal) + if err != nil { + t.Fatal(err) + } + t.Cleanup(device.Clean) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + process := exec.CommandContext(ctx, target) + if err := process.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = process.Process.Kill(); _ = process.Wait() }) + session, err := device.Attach(process.Process.Pid, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if !session.IsDetached() { + if err := session.Detach(); err != nil { + t.Error(err) + } + } + session.Clean() + }) + return session, process +} + +func rpcNativeScript(t *testing.T, session *Session, source string) *Script { + t.Helper() + script, err := session.CreateScript(source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if !script.IsDestroyed() { + if err := script.Unload(); err != nil { + t.Error(err) + } + } + script.Clean() + }) + return script +} + +func rpcNativeLoad(t *testing.T, script *Script) { + t.Helper() + if err := script.Load(); err != nil { + t.Fatal(err) + } +} + +func TestNativeRPCLegacyValues(t *testing.T) { + session, _ := rpcNativeSession(t) + script := rpcNativeScript(t, session, ` + rpc.exports.text = () => 'boom'; + rpc.exports.fail = () => { throw new TypeError('boom'); }; + rpc.exports.primitive = () => { throw 'primitive'; }; + rpc.exports.number = () => 42; + rpc.exports.object = () => ({ answer: 42 }); + rpc.exports.array = () => ['text', 1]; + rpc.exports.nil = () => null; + rpc.exports.sum = (a, b) => a + b; + rpc.exports.bytes = () => new Uint8Array([1, 2]).buffer; + rpc.exports.emptyBytes = () => new ArrayBuffer(0); + rpc.exports.tuple = () => ['metadata', new Uint8Array([3]).buffer]; + `) + rpcNativeLoad(t, script) + for _, tc := range []struct { + method string + args []any + want any + }{ + {"text", nil, "boom"}, + {"fail", nil, "boom"}, + {"primitive", nil, nil}, + {"missing", nil, "unable to find method 'missing'"}, + {"number", nil, float64(42)}, + {"object", nil, map[string]any{"answer": float64(42)}}, + {"array", nil, []any{"text", float64(1)}}, + {"nil", nil, nil}, + {"sum", []any{1, 2}, float64(3)}, + {"bytes", nil, nil}, + {"emptyBytes", nil, nil}, + {"tuple", nil, nil}, + } { + t.Run(tc.method, func(t *testing.T) { + if value := script.ExportsCallWithContext(rpcTestContext(t), tc.method, tc.args...); !reflect.DeepEqual(value, tc.want) { + t.Fatalf("reply = %#v, want %#v", value, tc.want) + } + }) + } + result := make(chan any, 1) + go func() { result <- script.ExportsCall("fail") }() + if value := rpcTestReceive(t, rpcTestContext(t), result); value != "boom" { + t.Fatalf("ExportsCall exception = %#v", value) + } +} + +func TestNativeRPCMessageHookRegistration(t *testing.T) { + session, _ := rpcNativeSession(t) + first := rpcNativeScript(t, session, `rpc.exports.echo = () => 'ok';`) + first.On("destroyed", func() {}) + rpcNativeLoad(t, first) + if value := first.ExportsCallWithContext(rpcTestContext(t), "echo"); value != "ok" { + t.Fatalf("RPC after registering destroyed first = %#v", value) + } + second := rpcNativeScript(t, session, `rpc.exports.notify = () => { send('user'); return 'ok'; };`) + var oldCalls, calls int64 + second.On("message", func(string) { atomic.AddInt64(&oldCalls, 1) }) + second.On("message", func(string) { atomic.AddInt64(&calls, 1) }) + rpcNativeLoad(t, second) + if value := second.ExportsCallWithContext(rpcTestContext(t), "notify"); value != "ok" { + t.Fatalf("RPC with repeated message handlers = %#v", value) + } + if atomic.LoadInt64(&oldCalls) != 0 || atomic.LoadInt64(&calls) != 1 { + t.Fatalf("message delivered %d times to old and %d to current handler", oldCalls, calls) + } + var workers sync.WaitGroup + workers.Add(1) + go func() { + defer workers.Done() + for i := 0; i < 100; i++ { + second.On("message", func(string) { atomic.AddInt64(&calls, 1) }) + } + }() + for i := 0; i < 100; i++ { + if value := second.ExportsCallWithContext(rpcTestContext(t), "notify"); value != "ok" { + t.Fatalf("RPC while replacing handler = %#v", value) + } + } + workers.Wait() + if count := atomic.LoadInt64(&calls); count != 101 { + t.Fatalf("message handler called %d times, want 101", count) + } +} + +func TestNativeRPCUnexpectedMessages(t *testing.T) { + session, _ := rpcNativeSession(t) + script := rpcNativeScript(t, session, ` + rpc.exports.noise = () => { + console.log('frida:rpc'); + send('frida:rpc'); + send(['application', 'frida:rpc']); + send(['frida:rpc']); + send(['frida:rpc', 42, 'ok', 'bad id']); + send(['frida:rpc', 'unknown', 'unexpected', 'bad status']); + send(['frida:rpc', 'unknown', 'ok', 'unknown reply']); + send(['frida:rpc', 'unknown', 'ok', 'duplicate reply']); + return 'current'; + }; + `) + var calls int64 + script.On("message", func(string) { atomic.AddInt64(&calls, 1) }) + rpcNativeLoad(t, script) + if value := script.ExportsCallWithContext(rpcTestContext(t), "noise"); value != "current" { + t.Fatalf("RPC after unexpected messages = %#v", value) + } + if count := atomic.LoadInt64(&calls); count != 3 { + t.Fatalf("forwarded %d ordinary messages, want 3", count) + } +} + +const rpcNativePending = ` + let release; + rpc.exports.wait = () => { + send('waiting'); + return new Promise(resolve => { release = resolve; }); + }; + rpc.exports.release = () => { release('stale'); return 'released'; }; + rpc.exports.echo = value => value; +` + +func rpcNativeStartPending(t *testing.T, script *Script, ctx context.Context) <-chan any { + t.Helper() + started := make(chan struct{}, 1) + script.On("message", func(string) { + select { + case started <- struct{}{}: + default: + } + }) + result := make(chan any, 1) + go func() { result <- script.ExportsCallWithContext(ctx, "wait") }() + rpcTestReceive(t, rpcTestContext(t), started) + return result +} + +func TestNativeRPCCancellationAndLateReply(t *testing.T) { + session, _ := rpcNativeSession(t) + script := rpcNativeScript(t, session, rpcNativePending) + rpcNativeLoad(t, script) + ctx, cancel := context.WithCancel(rpcTestContext(t)) + result := rpcNativeStartPending(t, script, ctx) + cancel() + if value := rpcTestReceive(t, rpcTestContext(t), result); value != ErrContextCancelled { + t.Fatalf("cancellation = %#v", value) + } + if value := script.ExportsCallWithContext(rpcTestContext(t), "release"); value != "released" { + t.Fatalf("release after cancelled call = %#v", value) + } + if value := script.ExportsCallWithContext(rpcTestContext(t), "echo", "current"); value != "current" { + t.Fatalf("reply after late completion = %#v", value) + } + deadline, cancel := context.WithTimeout(rpcTestContext(t), 100*time.Millisecond) + defer cancel() + if value := script.ExportsCallWithContext(deadline, "wait"); value != ErrContextCancelled { + t.Fatalf("deadline = %#v", value) + } + if value := script.ExportsCallWithContext(rpcTestContext(t), "release"); value != "released" { + t.Fatalf("release after deadline = %#v", value) + } +} + +func TestNativeRPCIndependentScripts(t *testing.T) { + session, _ := rpcNativeSession(t) + first := rpcNativeScript(t, session, `rpc.exports.echo = value => ['first', value];`) + second := rpcNativeScript(t, session, `rpc.exports.echo = value => ['second', value];`) + rpcNativeLoad(t, first) + rpcNativeLoad(t, second) + ctx := rpcTestContext(t) + results := make(chan any, 2) + go func() { results <- first.ExportsCallWithContext(ctx, "echo", "value") }() + go func() { results <- second.ExportsCallWithContext(ctx, "echo", "value") }() + seen := make(map[string]bool) + for i := 0; i < 2; i++ { + value := rpcTestReceive(t, ctx, results) + tuple, ok := value.([]any) + if !ok || len(tuple) != 2 || tuple[1] != "value" { + t.Fatalf("independent reply = %#v", value) + } + name, ok := tuple[0].(string) + if !ok || (name != "first" && name != "second") || seen[name] { + t.Fatalf("unexpected script reply = %#v", value) + } + seen[name] = true + } +} + +func TestNativeRPCDetachCancelsContext(t *testing.T) { + session, process := rpcNativeSession(t) + script := rpcNativeScript(t, session, rpcNativePending) + ctx, cancel := context.WithCancel(rpcTestContext(t)) + defer cancel() + session.On("detached", func(SessionDetachReason) { cancel() }) + rpcNativeLoad(t, script) + result := rpcNativeStartPending(t, script, ctx) + if err := process.Process.Kill(); err != nil { + t.Fatal(err) + } + if value := rpcTestReceive(t, rpcTestContext(t), result); value != ErrContextCancelled { + t.Fatalf("detached context = %#v", value) + } +} diff --git a/frida/rpc_test.go b/frida/rpc_test.go new file mode 100644 index 0000000..8e2b82f --- /dev/null +++ b/frida/rpc_test.go @@ -0,0 +1,352 @@ +package frida + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" +) + +type rpcTestCancellingArgument struct { + cancel context.CancelFunc +} + +func rpcTestContext(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} + +func rpcTestReply(t *testing.T, id, status string, value any) string { + t.Helper() + message, err := json.Marshal(struct { + Type string `json:"type"` + Payload []any `json:"payload"` + }{Type: "send", Payload: []any{"frida:rpc", id, status, value}}) + if err != nil { + t.Fatal(err) + } + return string(message) +} + +func rpcTestPending(p *pendingScriptRPCCalls) int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.callsByID) +} + +func rpcTestReceive[T any](t *testing.T, ctx context.Context, results <-chan T) T { + t.Helper() + select { + case value := <-results: + return value + case <-ctx.Done(): + t.Fatal("timed out waiting for RPC") + var zero T + return zero + } +} + +func TestRPCLegacyReplyValues(t *testing.T) { + for _, tc := range []struct { + name string + status string + value any + data []byte + }{ + {"string", "ok", "boom", nil}, + {"number", "ok", float64(42), nil}, + {"null", "ok", nil, nil}, + {"object", "ok", map[string]any{"answer": float64(42)}, nil}, + {"array", "ok", []any{"text", float64(1)}, nil}, + {"exception string", "error", "boom", nil}, + {"missing export string", "error", "unable to find method 'missing'", nil}, + {"primitive exception", "error", nil, nil}, + {"binary remains null", "ok", nil, []byte{1, 2}}, + {"empty binary remains null", "ok", nil, []byte{}}, + } { + t.Run(tc.name, func(t *testing.T) { + var script Script + value := script.callExport(rpcTestContext(t), "method", nil, func(_ context.Context, request scriptRPCRequest) error { + if request.ID == "" || request.Method != "method" || len(request.Args) != 0 { + t.Fatalf("request = %#v", request) + } + script.dispatchMessage(rpcTestReply(t, request.ID, tc.status, tc.value), tc.data) + return nil + }) + if !reflect.DeepEqual(value, tc.value) || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("reply = %#v, want %#v; %d pending", value, tc.value, rpcTestPending(&script.pendingCalls)) + } + }) + } +} + +func TestRPCArgumentForwarding(t *testing.T) { + var script Script + value := script.callExport(rpcTestContext(t), "sum", []any{1, 2}, func(_ context.Context, request scriptRPCRequest) error { + if request.Method != "sum" || !reflect.DeepEqual(request.Args, []any{1, 2}) { + t.Fatalf("request = %#v", request) + } + script.pendingCalls.complete(scriptRPCReply{ID: request.ID, Value: nil}) + return nil + }) + if value != nil { + t.Fatalf("reply = %#v, want nil", value) + } +} + +func TestRPCLegacyBinaryReplyMetadata(t *testing.T) { + var script Script + reply := script.pendingCalls.register("id") + script.dispatchMessage(`{"type":"send","payload":["frida:rpc","id","ok",null,"metadata"]}`, []byte{3}) + if value := rpcTestReceive(t, rpcTestContext(t), reply); value != nil { + t.Fatalf("binary tuple = %#v, want nil", value) + } +} + +func TestRPCMessageRouting(t *testing.T) { + for _, tc := range []struct { + name string + message string + forward bool + }{ + {"marker log", `{"type":"log","payload":"frida:rpc"}`, true}, + {"marker error", `{"type":"error","description":"frida:rpc"}`, true}, + {"string payload", `{"type":"send","payload":"frida:rpc"}`, true}, + {"application array", `{"type":"send","payload":["application","frida:rpc"]}`, true}, + {"null payload", `{"type":"send","payload":null}`, true}, + {"object payload", `{"type":"send","payload":{"marker":"frida:rpc"}}`, true}, + {"invalid JSON", `{"type":"send","payload":"frida:rpc"`, true}, + {"missing type", `{"payload":["frida:rpc","id","ok",1]}`, true}, + {"short reserved", `{"type":"send","payload":["frida:rpc"]}`, false}, + {"missing value", `{"type":"send","payload":["frida:rpc","id","ok"]}`, false}, + {"numeric ID", `{"type":"send","payload":["frida:rpc",42,"ok",1]}`, false}, + {"null ID", `{"type":"send","payload":["frida:rpc",null,"ok",1]}`, false}, + {"empty ID", `{"type":"send","payload":["frida:rpc","","ok",1]}`, false}, + {"bad status type", `{"type":"send","payload":["frida:rpc","id",42,1]}`, false}, + {"unknown status", `{"type":"send","payload":["frida:rpc","id","unexpected",1]}`, false}, + {"unknown ID", `{"type":"send","payload":["frida:rpc","unknown","ok",1]}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var script Script + reply := script.pendingCalls.register("id") + calls := 0 + script.setMessageHandler(func(message string, data []byte) { + calls++ + if message != tc.message || !reflect.DeepEqual(data, []byte{1}) { + t.Errorf("forwarded message = %q, %v", message, data) + } + }) + script.dispatchMessage(tc.message, []byte{1}) + if (calls == 1) != tc.forward || rpcTestPending(&script.pendingCalls) != 1 { + t.Fatalf("forwarded %d times, %d pending", calls, rpcTestPending(&script.pendingCalls)) + } + select { + case value := <-reply: + t.Fatalf("unexpected message completed the call: %#v", value) + default: + } + script.dispatchMessage(rpcTestReply(t, "id", "ok", "current"), nil) + if value := rpcTestReceive(t, rpcTestContext(t), reply); value != "current" { + t.Fatal(value) + } + }) + } + var script Script + script.dispatchMessage(`{"type":"send","payload":"no handler"}`, nil) +} + +func TestRPCCancellationIgnoresLateReplies(t *testing.T) { + var script Script + posted := make(chan string, 1) + post := func(_ context.Context, request scriptRPCRequest) error { + posted <- request.ID + return nil + } + ctx := rpcTestContext(t) + cancelled, cancel := context.WithCancel(ctx) + first := make(chan any, 1) + go func() { first <- script.callExport(cancelled, "first", nil, post) }() + oldID := rpcTestReceive(t, ctx, posted) + cancel() + if value := rpcTestReceive(t, ctx, first); value != ErrContextCancelled || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("cancelled reply = %#v, %d pending", value, rpcTestPending(&script.pendingCalls)) + } + second := make(chan any, 1) + go func() { second <- script.callExport(ctx, "second", nil, post) }() + newID := rpcTestReceive(t, ctx, posted) + script.dispatchMessage(rpcTestReply(t, oldID, "ok", "stale"), nil) + script.dispatchMessage(rpcTestReply(t, newID, "ok", "current"), nil) + if value := rpcTestReceive(t, ctx, second); value != "current" || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("next reply = %#v, %d pending", value, rpcTestPending(&script.pendingCalls)) + } + script.dispatchMessage(rpcTestReply(t, oldID, "ok", "stale again"), nil) + script.dispatchMessage(rpcTestReply(t, newID, "ok", "duplicate"), nil) +} + +func TestRPCRepliesStayWithTheirScript(t *testing.T) { + var first, second Script + firstReply := first.pendingCalls.register("id") + secondReply := second.pendingCalls.register("id") + first.dispatchMessage(rpcTestReply(t, "id", "ok", "first"), nil) + if value := rpcTestReceive(t, rpcTestContext(t), firstReply); value != "first" { + t.Fatal(value) + } + if rpcTestPending(&second.pendingCalls) != 1 { + t.Fatal("another script's call was removed") + } + second.dispatchMessage(rpcTestReply(t, "id", "ok", "second"), nil) + if value := rpcTestReceive(t, rpcTestContext(t), secondReply); value != "second" { + t.Fatal(value) + } +} + +func TestRPCConcurrentDuplicateReplies(t *testing.T) { + var script Script + reply := script.pendingCalls.register("id") + message := rpcTestReply(t, "id", "ok", "once") + var workers sync.WaitGroup + for i := 0; i < 32; i++ { + workers.Add(1) + go func() { defer workers.Done(); script.dispatchMessage(message, nil) }() + } + done := make(chan struct{}) + go func() { workers.Wait(); close(done) }() + rpcTestReceive(t, rpcTestContext(t), done) + if value := <-reply; value != "once" || len(reply) != 0 || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("duplicate reply = %#v", value) + } +} + +func TestRPCReplyCancellationRace(t *testing.T) { + ctx := rpcTestContext(t) + for i := 0; i < 100; i++ { + var script Script + cancelled, cancel := context.WithCancel(ctx) + posted := make(chan string, 1) + result := make(chan any, 1) + go func() { + result <- script.callExport(cancelled, "race", nil, func(_ context.Context, request scriptRPCRequest) error { + posted <- request.ID + return nil + }) + }() + id := rpcTestReceive(t, ctx, posted) + message := rpcTestReply(t, id, "ok", "reply") + var workers sync.WaitGroup + workers.Add(2) + go func() { defer workers.Done(); cancel() }() + go func() { defer workers.Done(); script.dispatchMessage(message, nil) }() + value := rpcTestReceive(t, ctx, result) + workers.Wait() + if value != "reply" && value != ErrContextCancelled { + t.Fatalf("race reply = %#v", value) + } + if rpcTestPending(&script.pendingCalls) != 0 { + t.Fatal("call retained after completion/cancellation") + } + } +} + +func TestRPCCancelledCallIsNotPosted(t *testing.T) { + var script Script + post := func(context.Context, scriptRPCRequest) error { + t.Error("posted a cancelled call") + return nil + } + ctx, cancel := context.WithCancel(rpcTestContext(t)) + cancel() + if value := script.callExport(ctx, "cancelled", nil, post); value != ErrContextCancelled { + t.Fatal(value) + } + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if value := script.callExport(expired, "expired", nil, post); value != ErrContextCancelled { + t.Fatal(value) + } + if rpcTestPending(&script.pendingCalls) != 0 { + t.Fatal("cancelled call retained") + } +} + +func TestRPCRequestEncoding(t *testing.T) { + for _, tc := range []struct { + name string + args []any + want string + }{ + {"no arguments", nil, `["frida:rpc","id","call","method",[]]`}, + {"arguments", []any{"text", 2}, `["frida:rpc","id","call","method",["text",2]]`}, + } { + t.Run(tc.name, func(t *testing.T) { + encoded, err := json.Marshal(scriptRPCRequest{ID: "id", Method: "method", Args: tc.args}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != tc.want { + t.Fatalf("request = %s, want %s", encoded, tc.want) + } + }) + } +} + +func TestRPCEncodingFailureRetiresCall(t *testing.T) { + // Encoding must fail before reaching the native post on this empty Script. + var script Script + value := script.ExportsCallWithContext(rpcTestContext(t), "invalid", func() {}) + var encodingError *json.UnsupportedTypeError + err, ok := value.(error) + if !ok || !errors.As(err, &encodingError) || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("encoding failure = %#v, %d pending", value, rpcTestPending(&script.pendingCalls)) + } +} + +func (a rpcTestCancellingArgument) MarshalJSON() ([]byte, error) { + a.cancel() + return []byte("null"), nil +} + +func TestRPCCancellationDuringEncodingPreventsPost(t *testing.T) { + var script Script + ctx, cancel := context.WithCancel(rpcTestContext(t)) + defer cancel() + value := script.ExportsCallWithContext(ctx, "cancelled", rpcTestCancellingArgument{cancel: cancel}) + if value != ErrContextCancelled || rpcTestPending(&script.pendingCalls) != 0 { + t.Fatalf("cancelled encoding = %#v, %d pending", value, rpcTestPending(&script.pendingCalls)) + } +} + +func TestRPCMessageHandlerPublication(t *testing.T) { + var script Script + var calls int64 + fn := func(message string, data []byte) { + if message != `{"type":"send","payload":"user"}` || len(data) != 1 || data[0] != 42 { + t.Errorf("message changed: %q, %v", message, data) + } + atomic.AddInt64(&calls, 1) + } + script.setMessageHandler(fn) + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + for i := 0; i < 1000; i++ { + script.setMessageHandler(func(message string, data []byte) { fn(message, data) }) + } + }() + go func() { + defer workers.Done() + for i := 0; i < 1000; i++ { + script.dispatchMessage(`{"type":"send","payload":"user"}`, []byte{42}) + } + }() + workers.Wait() + if count := atomic.LoadInt64(&calls); count != 1000 { + t.Fatalf("handler called %d times", count) + } +} diff --git a/frida/script.go b/frida/script.go index 2e6b5c7..075a901 100644 --- a/frida/script.go +++ b/frida/script.go @@ -7,20 +7,17 @@ import ( "encoding/json" "reflect" "runtime" - "strings" "sync" + "sync/atomic" "unsafe" - - "github.com/google/uuid" ) -var rpcCalls = sync.Map{} - // Script represents loaded string in the memory. type Script struct { - hasHandler bool - sc *C.FridaScript - fn reflect.Value + sc *C.FridaScript + messageHandlerOnce sync.Once + messageHandler atomic.Pointer[reflect.Value] + pendingCalls pendingScriptRPCCalls } // IsDestroyed function returns whether the script previously loaded is destroyed (could be caused by unload) @@ -31,9 +28,7 @@ func (s *Script) IsDestroyed() bool { // Load function loads the script into the process. func (s *Script) Load() error { - if !s.hasHandler { - s.On("message", func() {}) - } + s.ensureMessageHandler() var err *C.GError C.frida_script_load_sync(s.sc, nil, &err) return handleGError(err) @@ -90,22 +85,24 @@ func (s *Script) DisableDebugger() error { // ExportsCall will try to call fn from the rpc.exports with args provided func (s *Script) ExportsCall(fn string, args ...any) any { - ch := s.makeExportsCall(fn, args...) - defer releaseChannel(ch) - ret := <-ch - return ret + return s.callExport(context.Background(), fn, args, s.postRPCRequest) } // ExportsCallWithContext will try to call fn from the rpc.exports with args provided using context provided. func (s *Script) ExportsCallWithContext(ctx context.Context, fn string, args ...any) any { - ch := s.makeExportsCall(fn, args...) - defer releaseChannel(ch) - select { - case <-ctx.Done(): + return s.callExport(ctx, fn, args, s.postRPCRequest) +} + +func (s *Script) postRPCRequest(ctx context.Context, request scriptRPCRequest) error { + message, err := json.Marshal(request) + if err != nil { + return err + } + if ctx.Err() != nil { return ErrContextCancelled - case ret := <-ch: - return ret } + s.Post(string(message), nil) + return nil } // Clean will clean the resources held by the script. @@ -120,132 +117,45 @@ func (s *Script) Clean() { // - "destroyed" with callback as func() {} // - "message" with callback as func(message string, data []byte) {} func (s *Script) On(sigName string, fn any) { - s.hasHandler = true - // hijack message to handle rpc calls if sigName == "message" { - s.fn = reflect.ValueOf(fn) - connectClosure(unsafe.Pointer(s.sc), sigName, s.hijackFn) + s.setMessageHandler(fn) + s.ensureMessageHandler() } else { connectClosure(unsafe.Pointer(s.sc), sigName, fn) } } -func getRPCIDFromMessage(message string) (string, any, error) { - unmarshalled := make(map[string]any) - if err := json.Unmarshal([]byte(message), &unmarshalled); err != nil { - return "", nil, err - } - - var rpcID string - var ret any - - loopMap := func(mp map[string]any) { - for _, v := range mp { - if reflect.ValueOf(v).Kind() == reflect.Slice { - slc := v.([]any) - rpcID = slc[1].(string) - ret = slc[3] - } - } - } - loopMap(unmarshalled) - - return rpcID, ret, nil -} - -func (s *Script) hijackFn(message string, data []byte) { - if strings.Contains(message, "frida:rpc") { - rpcID, ret, err := getRPCIDFromMessage(message) - if err != nil { - panic(err) - } - callerCh, ok := rpcCalls.Load(rpcID) - if !ok { - panic("rpc-id not found") - } - ch := callerCh.(chan any) - ch <- ret - rpcCalls.Delete(rpcID) - } else { - var args []reflect.Value - switch s.fn.Type().NumIn() { - case 1: - args = append(args, reflect.ValueOf(message)) - case 2: - args = append(args, reflect.ValueOf(message)) - args = append(args, reflect.ValueOf(data)) - } - s.fn.Call(args) - } +func (s *Script) ensureMessageHandler() { + s.messageHandlerOnce.Do(func() { + connectClosure(unsafe.Pointer(s.sc), "message", s.dispatchMessage) + }) } -func newRPCCall(fnName string) []any { - id := uuid.New() - rpcID := string(append([]byte(nil), id.String()[:16]...)) - dt := []any{ - "frida:rpc", - rpcID, - "call", - fnName, +func (s *Script) setMessageHandler(fn any) { + v := reflect.ValueOf(fn) + if v.Kind() != reflect.Func { + panic("got no function") } - - return dt + s.messageHandler.Store(&v) } -func (s *Script) makeExportsCall(fn string, args ...any) chan any { - rpcData := newRPCCall(fn) - - aIface := make([]any, len(args)) - copy(aIface, args) - - ct := 0 - var rpc []any - if len(aIface) > 0 { - rpc = make([]any, len(rpcData)+len(aIface)) - } else { - rpc = make([]any, len(rpcData)+1) - } - - for i := 0; i < len(rpcData); i++ { - rpc[ct] = rpcData[i] - ct++ +func (s *Script) dispatchMessage(message string, data []byte) { + if reply, isRPC := decodeScriptRPCReply(message); isRPC { + if reply != nil { + s.pendingCalls.complete(*reply) + } + return } - - if len(aIface) > 0 { - rpc[ct] = aIface - } else { - rpc[ct] = []struct{}{} + fn := s.messageHandler.Load() + if fn == nil { + return } - - ch := getChannel() - rpcCalls.Store(rpcData[1], ch) - - bt, _ := json.Marshal(rpc) - s.Post(string(bt), nil) - - return ch -} - -var channelPool = sync.Pool{ - New: func() interface{} { - return make(chan any, 1) - }, -} - -func getChannel() chan any { - ch := channelPool.Get().(chan any) - select { - case <-ch: - default: - } - - return ch -} - -func releaseChannel(ch chan any) { - select { - case <-ch: - default: + var args []reflect.Value + switch fn.Type().NumIn() { + case 1: + args = append(args, reflect.ValueOf(message)) + case 2: + args = append(args, reflect.ValueOf(message), reflect.ValueOf(data)) } - channelPool.Put(ch) + fn.Call(args) } diff --git a/frida/testdata/.gitignore b/frida/testdata/.gitignore new file mode 100644 index 0000000..ae3c172 --- /dev/null +++ b/frida/testdata/.gitignore @@ -0,0 +1 @@ +/bin/ diff --git a/frida/testdata/rpc-target.c b/frida/testdata/rpc-target.c new file mode 100644 index 0000000..844d341 --- /dev/null +++ b/frida/testdata/rpc-target.c @@ -0,0 +1,7 @@ +#include + +int main(void) { + for (;;) { + pause(); + } +} diff --git a/frida/testdata/rpc-target.entitlements b/frida/testdata/rpc-target.entitlements new file mode 100644 index 0000000..167ca54 --- /dev/null +++ b/frida/testdata/rpc-target.entitlements @@ -0,0 +1,3 @@ + + +com.apple.security.get-task-allow