Disassembly Workflow: How We Reverse-Engineered SolFi / HumidiFi
SECURITY RESEARCH NOTE · REVERSE ENGINEERING
Tooling, artifacts, exact commands, and the analysis method that confirm SolFi V2 verifies an Ethereum-style secp256k1 signature inside a Solana program.
| Target | solfi-v2.so |
| Finding | swap_with_okx_sig |
| Method | Static sBPF analysis |
| Domain | Smart Contract Audits · Web3 Security |
| Platform | Solana · sBPF |
| Author | Rafael Escrich · rafael@unblockthechain.com |
Overview — How the disassembly in this directory works
This document explains the tooling, the artifacts, the exact commands, and the analysis method that let us confirm that SolFi V2 verifies an Ethereum-style secp256k1 signature ("OKX signature") inside a Solana program. It is written so another engineer can reproduce every step.
TL;DR — THE FINDING
solfi-v2.socontains an instructionswap_with_okx_sigthat does keccak256 + secp256k1_recover (Solana'secrecover) — i.e. Ethereum's exact signing primitives — to authorize a swap.solfi-v1.soandhumidifi-swap.sodo not contain these primitives.
Section 01 — The tool: solx
Everything here is driven by a small custom RE tool, solx (in ../solx), plus a couple of hand-written Python passes for ELF relocation parsing. solx is a lite static analyzer for Solana sBPF programs:
| Command | What it does |
|---|---|
solx fetch | Download a deployed program's ELF (.so) from any cluster |
solx audit | Report what a built .so leaks: source paths, panic strings, serde vocab, symbols |
solx info | ELF section + size overview |
solx disasm | Lite sBPF disassembly of the .text section |
solx syscalls | Heuristic list of Solana syscalls the program references |
solx graph | Emit a Graphviz DOT call graph |
solx harden | Print a build recipe that strips the leaks audit reports |
solx trace | Step through sBPF with mock memory (RE aid; not consensus-exact) |
The Solana runtime executes sBPF (a Solana dialect of eBPF). A deployed program is a stripped ELF shared object whose .text is sBPF bytecode. Nothing is symbolicated, so the workflow is: recover structure from what the compiler couldn't strip (relocations, dynamic symbols, panic strings), then read the bytecode.
Section 02 — The artifacts in this directory
| File | What it is | How it was produced |
|---|---|---|
solfi-v1.so, solfi-v2.so | The two SolFi program binaries | solx fetch from mainnet |
humidifi-swap.so, humidifi-route.so | HumidiFi program binaries | solx fetch from mainnet |
*.disasm | Full .text disassembly | solx disasm <file> > file.disasm |
*.dot / *.svg | Call graphs | solx graph → dot -Tsvg |
*-labeled.dot | Call graphs with our human labels added | manual annotation |
solfi-v2-router-labels.txt | Confirmed/inferred names for each router case | manual, from evidence below |
solfi-v2-jupiter-*.json | Live Jupiter quotes/sims/route accounts | Jupiter API capture |
humidifi-trace/ | A Rust harness to step the bytecode | solx trace scaffolding |
The .disasm files are the raw material; the .dot/.svg graphs are how we navigate them; the *-labels* files are our accumulated understanding.
Section 03 · Method — Step-by-step
Step 0 — Section overview
solx info solfi-v2.so
This prints the ELF layout we rely on for every later step:
entry: 0xe410 is_64: true little_endian: true
section size offset
.text 215744 120
.rodata 5323 34be0
.data.rel.ro 3552 360b0
.dynsym 384 36f40
.dynstr 228 370c0
.rel.dyn 9936 371a8
Key idea: the addresses of .text, .rodata, .data.rel.ro, .dynsym, .dynstr, and .rel.dyn are the coordinates for the relocation-parsing pass in Step 3.
Step 1 — Audit the leaks (free intelligence)
solx audit solfi-v2.so
# or, quick and dirty:
strings -n 6 solfi-v2.so | rg "src/instructions/"
Rust programs leak #[track_caller] panic location strings — the source file path of every panic!, unwrap, bounds check, etc. For solfi-v2.so these include:
programs/solfi-v2-program/src/instructions/swap_with_okx_sig.rs
programs/solfi-v2-program/src/instructions/update_market_config.rs
programs/solfi-v2-program/src/instructions/update_big_blacklist.rs
programs/solfi-v2-program/src/instructions/update_fast_blacklist.rs
That single string — swap_with_okx_sig.rs — is the whole lead. The rest of the work is confirming what that instruction does.
Step 2 — Fingerprint the syscalls
solx syscalls solfi-v2.so
For solfi-v2.so this lists, among others:
sol_keccak256
sol_secp256k1_recover
sol_memcmp_
sol_invoke_signed_c
sol_get_clock_sysvar
...
sol_keccak256 + sol_secp256k1_recover is the tell. Those two together are Ethereum's ecrecover: keccak256 is Ethereum's hash, and secp256k1_recover recovers the signer's public key from an ECDSA signature. Solana's native signature scheme is Ed25519; a program only reaches for secp256k1 + keccak256 when it must verify a signature produced by an Ethereum-style (secp256k1) key.
Cross-check across the other binaries (a one-liner, since these are just string/symbol presence):
| Binary | swap_with_okx_sig | sol_secp256k1_recover | sol_keccak256 |
|---|---|---|---|
solfi-v2.so | yes | yes | yes |
solfi-v1.so | no | no | no |
humidifi-swap.so | no | no | no |
So this is a capability added in SolFi V2 and absent from HumidiFi.
Step 3 — Find the exact call sites (relocation parsing)
solx disasm is a lite disassembler: it prints call 0xffffffff for syscalls (the immediate is a -1 placeholder patched at load time) and its internal-call operands are synthetic, not byte addresses. So you cannot grep the .disasm for a syscall by name. Instead we resolve call sites straight from the ELF relocation table.
In sBPF, each syscall call has a relocation in .rel.dyn (Elf64_Rel, 16 bytes: r_offset, r_info) whose symbol index points into .dynsym, whose name lives in .dynstr. Parsing those three tables gives the precise .text address of every syscall call site:
import struct
d = open('solfi-v2.so', 'rb').read()
dynsym_off, dynsym_size = 0x36f40, 0x180
dynstr_off = 0x370c0
rel_off, rel_size = 0x371a8, 0x26d0
dynstr = d[dynstr_off:]
name = lambda o: dynstr[o:dynstr.index(b'\0', o)].decode()
syms = [name(struct.unpack_from('<I', d, dynsym_off + i * 24)[0])
for i in range(dynsym_size // 24)]
want = {'sol_keccak256', 'sol_secp256k1_recover', 'sol_memcmp_'}
for i in range(rel_size // 16):
r_offset, r_info = struct.unpack_from('<QQ', d, rel_off + i * 16)
sym = syms[r_info >> 32]
if sym in want:
print(f'{r_offset:#08x} -> {sym}')
Output:
0x007540 -> sol_keccak256
0x007600 -> sol_secp256k1_recover
0x02edd8 -> sol_memcmp_
There is exactly one keccak256 and exactly one secp256k1_recover, and they sit ~0xC0 bytes apart — so the entire Ethereum-signature verifier is the routine around 0x7400–0x7738.
ASIDE
If you ever do need to match a syscall by its runtime hash, Solana hashes syscall names with murmur3-32, seed 0, e.g.
sol_secp256k1_recover = 0x17e40350,sol_keccak256 = 0xd7793abb.
Step 4 — Read the verifier
solx disasm solfi-v2.so --start 0x7400 --limit 200
Decoded, the routine does exactly what an EVM ecrecover check does:
- Build the message. Allocate a scratch buffer and copy a 72-byte message (the quote/swap parameters) into it; a length field is set to 72 (
stdw [r10-240], 72). - Hash it.
call sol_keccak256(msg, 1 segment, out=digest[32])at0x7540. - Recover the key.
call sol_secp256k1_recover(digest, recovery_id & 0xff, signature[64], out=pubkey[64])at0x7600. If it fails (r0 != 0) the handler bails with error code 56. - Compare. The recovered 64-byte public key is checked (64-byte compare) against the authorized signer; only on equality does the swap proceed.
So the shape is: pubkey = ecrecover(keccak256(quote), sig) → require(pubkey == authorized_okx_signer). This is byte-for-byte the primitive OKX would use to sign an order on the EVM side; here the same signature is verified on Solana.
Step 5 — Where does the authorized key live? (hardcoded vs. account)
We scanned .rodata (0x34be0) and .data.rel.ro (0x360b0) for a high-entropy 20-byte (EVM address) or 64-byte (uncompressed secp256k1 pubkey) constant. We found only compiler-generated tables (Unicode property tables, panic::Location structs, vtables) — no embedded signer constant.
Combined with the presence of an update_market_config instruction (and update_big_blacklist / update_fast_blacklist), the evidence says the authorized OKX signer is stored in an on-chain config account and is rotatable, not baked into the binary. The verifier compares the recovered key against that account-held value.
CAVEAT
A raw key whose bytes happen to fall in the printable-ASCII range could evade an entropy scan. To pin the literal bytes with certainty you would either (a) read the config account from chain, or (b) drive the routine under
solx tracewith a mock account and observe the compare operand. The static evidence already rules out a hardcoded constant.
Step 6 — Map the router (context)
solx graph solfi-v2.so > solfi-v2-depth5.dot
dot -Tsvg solfi-v2-depth5.dot -o solfi-v2-depth5.svg
main_router (0xd8a8) dispatches on ix_data[0] into cases 0..14. We labeled each case in solfi-v2-router-labels.txt using four evidence sources: the leaked source paths, the referenced syscalls, the graph shape, and a live Jupiter route capture (solfi-v2-jupiter-*.json) that confirmed case 7 is the public swap (CPI payload begins 07). The swap_with_okx_sig verifier described above is the keccak/secp-bearing routine reached off the swap/admin paths.
Section 04 · Conclusions — What the disassembly proves (and doesn't)
Proven from the binary
- SolFi V2 has an instruction literally named
swap_with_okx_sig. - It verifies an Ethereum-style secp256k1 signature over a 72-byte message via keccak256 + secp256k1_recover, and gates the swap on the recovered key.
- These primitives are new in V2 and absent from V1 and HumidiFi.
- No signer constant is hardcoded; the authorized signer is account-configured / rotatable.
Not provable from the binary alone (needs on-chain data or dynamic tracing)
- The literal bytes / EVM address of the OKX signer.
- Whether that same key appears as an OKX solver/settlement key in EVM intent systems (e.g. LI.FI) — that is an on-chain cross-reference, not a static-analysis result.
Interpretation (consistent with, but beyond, the static evidence)
A desk that runs both an on-chain Solana market maker and a cross-chain intent/RFQ solver can drive both with one secp256k1 signer. swap_with_okx_sig is the on-chain half — a Solana swap authorized by an off-chain, Ethereum-style signed quote.
Section 05 · Reproduce it
SOLX=../solx/target/release/solx
# 0. Section map
$SOLX info solfi-v2.so
# 1. Leaked instruction names
$SOLX audit solfi-v2.so
strings -n 6 solfi-v2.so | rg "src/instructions/"
# 2. Syscall fingerprint (look for keccak256 + secp256k1_recover)
$SOLX syscalls solfi-v2.so
# 3. Exact call sites via relocations (see the Python snippet in Step 3)
python3 find_syscall_sites.py # -> 0x7540 keccak, 0x7600 secp256k1_recover
# 4. Read the verifier
$SOLX disasm solfi-v2.so --start 0x7400 --limit 200
# 5. Confirm V1 / HumidiFi lack the primitives
for f in solfi-v1.so humidifi-swap.so; do
echo "== $f =="; $SOLX syscalls "$f" | rg -i "keccak|secp" || echo " (none)"
done
Author · Unblock the Chain Corp.
Rafael Escrich
- Email · rafael@unblockthechain.com · contact@unblockthechain.com
- Web · unblockthechain.com
- 1007 N Orange St. 4th Floor, 2360, Wilmington, DE 19801 · US
Unblock the Chain — Blockchain security for enterprises.