Reverse Engineering14 min read

Disassembly Workflow: How We Reverse-Engineered SolFi / HumidiFi

Rafael EscrichJuly 2026
Summary: Tooling, artifacts, exact commands, and the analysis method that confirm SolFi V2 verifies an Ethereum-style secp256k1 signature inside a Solana program.

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.

Targetsolfi-v2.so
Findingswap_with_okx_sig
MethodStatic sBPF analysis
DomainSmart Contract Audits · Web3 Security
PlatformSolana · sBPF
AuthorRafael 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.so contains an instruction swap_with_okx_sig that does keccak256 + secp256k1_recover (Solana's ecrecover) — i.e. Ethereum's exact signing primitives — to authorize a swap. solfi-v1.so and humidifi-swap.so do 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:

CommandWhat it does
solx fetchDownload a deployed program's ELF (.so) from any cluster
solx auditReport what a built .so leaks: source paths, panic strings, serde vocab, symbols
solx infoELF section + size overview
solx disasmLite sBPF disassembly of the .text section
solx syscallsHeuristic list of Solana syscalls the program references
solx graphEmit a Graphviz DOT call graph
solx hardenPrint a build recipe that strips the leaks audit reports
solx traceStep 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

FileWhat it isHow it was produced
solfi-v1.so, solfi-v2.soThe two SolFi program binariessolx fetch from mainnet
humidifi-swap.so, humidifi-route.soHumidiFi program binariessolx fetch from mainnet
*.disasmFull .text disassemblysolx disasm <file> > file.disasm
*.dot / *.svgCall graphssolx graphdot -Tsvg
*-labeled.dotCall graphs with our human labels addedmanual annotation
solfi-v2-router-labels.txtConfirmed/inferred names for each router casemanual, from evidence below
solfi-v2-jupiter-*.jsonLive Jupiter quotes/sims/route accountsJupiter API capture
humidifi-trace/A Rust harness to step the bytecodesolx 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):

Binaryswap_with_okx_sigsol_secp256k1_recoversol_keccak256
solfi-v2.soyesyesyes
solfi-v1.sononono
humidifi-swap.sononono

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 0x74000x7738.

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:

  1. 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).
  2. Hash it. call sol_keccak256(msg, 1 segment, out=digest[32]) at 0x7540.
  3. Recover the key. call sol_secp256k1_recover(digest, recovery_id & 0xff, signature[64], out=pubkey[64]) at 0x7600. If it fails (r0 != 0) the handler bails with error code 56.
  4. 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 trace with 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

Unblock the Chain — Blockchain security for enterprises.