Pattern-Based Hooking¶
Pattern-based hooking is one of friTap's most powerful features. It lets you analyze applications whose SSL/TLS library is stripped of symbols or statically linked, where traditional name-based resolution fails.
What it is and why it exists¶
friTap normally finds a function such as SSL_read by looking up its symbol in the target module's export/symbol table. That works for shared libraries that ship symbols. It does not work when:
- the library is stripped (no symbol table), or
- the TLS stack is statically linked into another binary (BoringSSL inside Chrome, Cronet, or
libflutter.so), so there is no separatelibssl.soto resolve against.
Explain the why
A stripped or statically linked BoringSSL exposes no symbol you can hook by name. The function still exists in memory — it just has no label. A byte signature of the function's machine-code prologue becomes the only available hook target. That is exactly what pattern-based hooking provides: friTap scans the module's executable memory for the signature and installs the hook at the matching address.
friTap ships a default pattern database (friTap/patterns/default_patterns.json) for several well-known libraries. Because compilers and library versions change, the shipped patterns can drift over time, so you can supply your own with --patterns.
Two hooking engines, two pattern schemas¶
friTap has two pattern-based hooking engines, and each expects its own JSON schema. A --patterns file is valid as long as it matches one of them — write the schema for the engine you are targeting:
| Engine | Active when | Schema | Shape | Example file |
|---|---|---|---|---|
Legacy (PatternBasedHooking) | default | Schema B | modules → module → platform → arch → function → {primary, fallback, second_fallback?} | repo-root pattern.json |
Modern (PatternStrategy) | opt-in via --modern | Schema A | library → os → arch → function → [hex, …] | friTap/patterns/default_patterns.json |
Which schema should I write?
The legacy engine is the default (the modern engine only runs with --modern), and it is what the Cronet / Flutter / Android key-extraction hooks use today. So for most real targets you write Schema B — the object form, exactly like the repo-root pattern.json. Write Schema A (the nested list form) only when you run with --modern. The loader accepts both, and they can even coexist in one file: Schema B lives under the top-level modules key while Schema A uses top-level library keys.
Defaults and merging¶
- The shipped default patterns load automatically — you do not need
--patternsto benefit from them. (default_patterns.jsonis Schema A.) - When you pass
--patterns <file.json>, friTap deep-merges your file on top of the defaults. The merge is granular: only the specific leaf entries present in your file override the defaults; everything else stays intact. A Schema-Bmodulessubtree merges in alongside the Schema-A defaults untouched, so the legacy engine sees your patterns and the modern defaults remain available. - If your file is structurally invalid for both schemas (wrong nesting or leaf types), friTap logs a warning and falls back to the defaults only — your file is ignored, but the run continues.
When to use pattern-based hooking¶
- Stripped libraries — no symbol information available.
- Statically linked SSL — BoringSSL embedded in Chrome, Cronet,
libflutter.so. - Obfuscated binaries — anti-analysis protections strip or rename symbols.
- Custom / proprietary SSL — modified builds with no recognizable exports.
Decision tree¶
flowchart TD
A[Need to hook a TLS function] --> B{Are symbols present<br/>in the module?}
B -- Yes --> C[Symbol-based hook<br/>no extra config]
B -- No --> D{Do you know a fixed<br/>address / offset for<br/>this exact build?}
D -- Yes --> E["Offsets: --offsets offsets.json<br/>(module → function → address)"]
D -- No --> F["Pattern scan: --patterns patterns.json<br/>(byte signature of the prologue)"]
F --> G{Pattern matches<br/>exactly one site?}
G -- Yes --> H[Hook installed]
G -- No / many --> I[Refine the signature<br/>add context bytes] Pattern file format¶
Write the schema for the engine you are targeting (see the table above). The loader accepts both.
Schema B — legacy engine (the default)¶
Nested as modules → module → platform → arch → function → pattern, where a pattern is an object { "primary": "…", "fallback": "…", "second_fallback": "…" } (only primary is typically needed; the others are optional). This is the form the repo-root pattern.json uses and the one the default Cronet / Flutter / Android hooks read. The legacy engine scans primary, then fallback, then second_fallback, stopping at the first match.
{
"modules": {
"libcronet.so": {
"android": {
"arm64": {
"Dump-Keys": {
"primary": "FF 83 02 D1 FD 7B 05 A9 F9 33 00 F9 F8 5F 07 A9",
"fallback": "3F 23 03 D5 FF ?3 02 D1 FD 7B 0? A9 F? ?? 0? ?9"
}
}
}
}
}
}
The platform key is one of android / ios / linux / macos / windows. Empty strings are allowed as placeholders (the engine skips them and falls back to symbol hooking).
Schema A — modern engine (--modern)¶
A nested map library → os → arch → function → list of hex strings. Each function maps to a list of candidate patterns, tried in order. This is the form default_patterns.json uses. The list itself is the primary/fallback chain.
{
"openssl": {
"android": {
"x64": {
"ssl_log_secret": [
"55 48 89 E5 48 83 EC 20 48 89 7D F8",
"55 41 57 41 56 41 54 53 48 83 EC 30 48 8B 47 68"
]
}
}
}
}
Both examples validate True against friTap.patterns.loader.PatternLoader.validate. In Schema A the top-level library key is matched against the module name first, then the library type; then the os key is matched against the current platform (android / ios / linux / macos / windows); then the arch key is compared against Process.arch, falling back to "default" if present (see pattern_strategy.ts).
The <os> key uses the same enum as Schema B (android / ios / linux / macos / windows) and is matched against the OS friTap is running on. If the current OS has no entry under a library, the modern engine finds no pattern for that library and falls through to symbol hooking.
Structure hierarchy at a glance¶
Schema B (legacy, default):
modules/<module>/<platform>/<arch>/<function> → { "primary": "<hex>", "fallback": "<hex>" }
Schema A (modern, --modern):
<library>/<os>/<arch>/<function> → [ "<hex>", "<hex>", … ]
In both, <arch> is x64 / arm64 / arm / x86 (or default), and <function> is a category label such as ssl_log_secret, Dump-Keys, SSL_Read, SSL_Write, Install-Key-Log-Callback, KeyLogCallback-Function.
Architecture keys¶
Use exactly these architecture keys (compared against Process.arch):
| Key | Use for |
|---|---|
x64 | 64-bit x86 (Intel/AMD) |
arm64 | 64-bit ARM (most modern phones) |
arm | 32-bit ARM |
x86 | 32-bit x86 |
default | catch-all if no arch-specific entry |
Do not use x86_64 or armv7
Frida's Process.arch reports x64 / arm64 / arm / x86. Keys like x86_64 or armv7 will never match and your patterns will be silently skipped.
Reserved keys¶
Any top-level key (or arch/function key) that starts with an underscore is reserved and ignored by the validator (loader.py:92,99,106). The shipped defaults use this for documentation:
_meta— version / format metadata._docs— category descriptions, architecture list, contributor notes.
You can keep your own _comment entries inside a pattern file without breaking validation.
Hex and wildcard rules¶
Each pattern string must match the regex ^([0-9A-Fa-f?]{2}\s)*[0-9A-Fa-f?]{2}$ (loader.py:84):
- Space-separated 2-character tokens, e.g.
55 48 89 E5. - Each token is either two hex digits (
0-9A-Fa-f) or wildcards. ?and??wildcards are allowed mid-pattern for variable bytes (registers, immediates, padding, compiler-specific differences). A nibble wildcard likeE?is also valid (the token stays 2 characters).
Avoid leading/trailing ??
Do not begin or end a pattern with ??. Frida's scanner rejects patterns whose edges are fully wildcarded. Always anchor the signature with concrete bytes at both ends.
--patterns vs --offsets — two separate mechanisms¶
These are different features and must not be mixed in one file:
| Feature | Flag | File shape | Use when |
|---|---|---|---|
| Byte scanning | --patterns | Schema B modules → … → {primary, fallback} (default) or Schema A library → os → arch → function → [hex] (--modern) | You have a signature but no fixed address |
| Offsets | --offsets | module → function → {address, absolute} | You know the exact address/offset already |
Offsets file (offsets_example.json):
{
"openssl": {
"SSL_read": { "address": "0x572115b4", "absolute": true },
"SSL_write": { "address": "0x144c", "absolute": false }
}
}
absolute: true means a runtime virtual address; absolute: false means an offset from the module base. Offsets skip scanning entirely — friTap hooks the computed address directly.
Create a pattern (step-by-step)¶
This tutorial is tool-agnostic for the prologue extraction, then shows how a BoringSecretHunter line drops straight into one list entry.
Step 1 — Locate the target function¶
Use any disassembler (radare2, Ghidra, IDA, objdump). The most valuable target for TLS key extraction is ssl_log_secret (BoringSSL/OpenSSL), which is always called during the handshake, even when key logging is disabled.
# radare2 example
r2 -A libflutter.so
[0x00000000]> afl | grep -i ssl
[0x00000000]> pdf @ sym.ssl_log_secret
Step 2 — Read the prologue bytes¶
Dump the first ~32-48 bytes of the function. A typical x64 prologue:
This becomes the pattern token sequence: 55 48 89 E5 48 83 EC 20 48 89 7D F8.
Step 3 — Wildcard the volatile bytes¶
Replace bytes that vary across builds (relative offsets, immediates, some registers) with ?/??, but keep concrete bytes at the start and end:
Step 4 — Drop it into the schema for your engine¶
For the default (legacy) engine, use Schema B — put the signature in primary under modules → module → platform → arch → function:
{
"modules": {
"libflutter.so": {
"android": {
"arm64": {
"Dump-Keys": { "primary": "55 48 89 E5 ?? ?? ?? ?? 48 83 EC ?? 48 89 7D F8" }
}
}
}
}
}
If you run with --modern, use Schema A instead — the same signature as one element of a list: { "libflutter": { "android": { "arm64": { "ssl_log_secret": ["55 48 89 E5 ?? ?? ?? ?? 48 83 EC ?? 48 89 7D F8"] } } } }.
Function label naming
Use the label friTap expects for the strategy you are extending. The shipped defaults use ssl_log_secret for OpenSSL/BoringSSL key dumping; the _docs block lists the category labels (Dump-Keys, SSL_Read, SSL_Write, Install-Key-Log-Callback, KeyLogCallback-Function).
Automating with BoringSecretHunter¶
BoringSecretHunter is a Ghidra-based tool that locates ssl_log_secret() in stripped BoringSSL/rustls libraries and emits a ready-to-use Frida byte pattern.
git clone https://github.com/monkeywave/BoringSecretHunter.git
cd BoringSecretHunter
docker build -t boringsecrethunter .
mkdir -p binary results
cp /path/to/libsignal_jni.so binary/
docker run --rm \
-v "$(pwd)/binary":/usr/local/src/binaries \
-v "$(pwd)/results":/host_output \
-e DEBUG_RUN=true \
boringsecrethunter
Example output:
[*] Start analyzing binary libsignal_jni.so (CPU Architecture: AARCH64)...
[*] Target function identified (ssl_log_secret):
Function label: FUN_00493BB0
Function offset: 00493BB0 (0X493BB0)
Byte pattern for frida (friTap): 3F 23 03 D5 FF C3 01 D1 FD 7B 04 A9 F6 57 05 A9 F4 4F 06 A9 FD 03 01 91 08 34 40 F9 08 11 41 F9 C8 07 00 B4
The "Byte pattern for frida (friTap)" line drops in with no reformatting. For the default (legacy) engine, place it in primary (Schema B):
{
"modules": {
"libsignal_jni.so": {
"android": {
"arm64": {
"Dump-Keys": { "primary": "3F 23 03 D5 FF C3 01 D1 FD 7B 04 A9 F6 57 05 A9 F4 4F 06 A9 FD 03 01 91 08 34 40 F9 08 11 41 F9 C8 07 00 B4" }
}
}
}
}
}
Under --modern, drop the same string into a Schema-A list instead: { "libsignal_jni": { "android": { "arm64": { "ssl_log_secret": ["3F 23 03 D5 …"] } } } }.
Why Docker?
The Docker image bundles a pre-configured Ghidra, eliminating setup and giving consistent extraction across platforms.
Validate before you deploy¶
Catch format mistakes early without a device:
# Quick JSON sanity check
python -m json.tool signal_pattern.json
# Validate against the actual loader (prints True on success)
python -c "import json, logging; from friTap.patterns.loader import PatternLoader; \
print(PatternLoader.validate(json.load(open('signal_pattern.json')), logging.getLogger('t')))"
Gotchas¶
Scans executable (r-x) ranges, with a whole-module fallback
The scanner enumerates the module's r-x ranges and scans those, because function prologues always live in executable memory and a single scan over the whole [base, base+size] span can hit unreadable pages and throw an access violation on huge modules (e.g. Chrome's ~193 MB libmonochrome_64.so). If range enumeration yields nothing, it falls back to a whole-module scan (pattern_strategy.ts:139-158).
Ambiguous match → first hit + warning
If a pattern matches more than one site, friTap installs the hook at the first match and emits a warning (pattern_strategy.ts:45-53). A wrong first hit can land on a hot path and slow or hang the process. Make your signature longer/more specific if you see this warning.
No-match falls through silently
If no pattern matches, the pattern strategy simply reports failure for that function and the pipeline continues — there is no hard error. Run with -do (debug output) to see per-pattern scan diagnostics, otherwise a missing hook can look like "nothing happened".
Using pattern files on the CLI¶
# Use a pattern file for key extraction (deep-merged on top of defaults)
fritap --patterns patterns.json -k keys.log target_app
# Mobile target
fritap -m --patterns android_patterns.json -k keys.log com.example.app
# See what matched / why it did not (debug output)
fritap -do -v --patterns patterns.json target_app
# Use fixed addresses instead of scanning
fritap --offsets offsets.json -k keys.log target_app
# Force a full library scan (do not trust cached/known module lists)
fritap --force-scan --patterns patterns.json -k keys.log target_app
| Flag | Purpose |
|---|---|
--patterns | Supply byte signatures (deep-merged on top of shipped defaults). |
--offsets | Supply fixed addresses/offsets (separate from --patterns). |
--force-scan | Force a fresh library scan instead of relying on known module lists. |
-do | Emit debug output, including per-pattern scan diagnostics. |
Real-world examples¶
Flutter applications¶
Flutter statically links BoringSSL into libflutter.so. Flutter key extraction runs on the default (legacy) engine, so use Schema B (primary/fallback under modules):
{
"modules": {
"libflutter.so": {
"android": {
"arm64": {
"Dump-Keys": {
"primary": "FF 83 00 D1 FD 7B 01 A9 ?? ?? ?? ?? F4 4F 03 A9",
"fallback": "FF 83 00 D1 FD 7B 01 A9 F4 4F 03 A9"
}
}
}
}
}
}
Cronet applications¶
Chrome's Cronet (Google QUICHE / BoringSSL) is the canonical pattern target. Two cases:
- BoringSSL key dump (default/legacy engine) — use Schema B, exactly like the
libcronet.soexample earlier in this page (modules → libcronet.so → android → arch → Dump-Keys → {primary, fallback}). - QUICHE HTTP/3 stream patterns — the shipped defaults already carry runtime-verified arm64 QUICHE patterns under the
google_quichelibrary key in Schema A (the formdefault_patterns.jsonuses). Override per build with your own Schema-A file:
{
"google_quiche": {
"android": {
"arm64": {
"QuicSpdyStream_OnDataFramePayload": [
"3F 23 03 D5 FD 7B BF A9 FD 03 00 91 08 04 40 F9 00 ?? ?? 91 ?? ?? ?? ?? 20 00 80 52 FD 7B C1 A8 BF 23 03 D5 C0 03 5F D6"
]
}
}
}
}
Troubleshooting¶
Pattern not found
# Verify JSON + schema first
python -m json.tool patterns.json
# See per-pattern scan diagnostics
fritap -do --patterns patterns.json -v target_app
# Confirm the library is actually loaded / named as you expect
fritap --list-libraries target_app
If the run is silent, the most common causes are: wrong architecture key (x86_64/armv7 instead of x64/arm64), using the wrong schema for the active engine (a Schema-A nested list while on the default legacy engine, or a Schema-B modules object while on --modern), or a leading/trailing ??.
False positives / ambiguous-match warning
Add more concrete context bytes to make the signature unique — the scanner warns when a pattern hits multiple sites and installs at the first one.
Pattern worked on one build, fails on the next
Re-extract with BoringSecretHunter for the new build, or widen the volatile mid-pattern bytes with wildcards while keeping the edges concrete.
Next steps¶
- Add a new library / hook — how the hooking pipeline and strategies fit together.
- CLI reference — full documentation for
--patterns,--offsets,--force-scan,-do, and related flags.