Graph-Topology Software Watermarking with LLVM — Part 1: Leaf Node Injection and Locating

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

This is the first post in a series on building a graph-topology watermarking compiler. It covers the full implementation of leaf-node injection and leaf-node locating. Part 2 will layer topological-shape encoding on top of this to build a two-tier watermarking scheme.

0x00 Background and motivation

Software watermarking is a core tool for copyright protection and provenance tracing. Traditional string-based or constant-based watermarks are easy for static scanners to find and strip by hand. Watermarks built on control-flow-graph (CFG) topology are, in theory, much harder to patch away — but they’re complex to implement and easy for the compiler’s optimizations to silently erase.

I chose to do this at the LLVM Pass layer because it lets me manipulate the CFG at the IR level — cleaner than patching binaries, and architecture-independent.

An earlier pure-topology attempt (encoding nibbles in the shape of a dummy subgraph) hit real problems:

  • SimplifyCFG recognizes always-false conditional branches and deletes them
  • SCCP sees a global variable initialized to a constant, derives the jump target, and silently erases the whole dummy subgraph
  • even with volatile, some passes still destroy the shape
  • a pure Graph-Diff approach isn’t stable in practice because LLVM’s DAG optimizations fight it; making it work means gutting or hooking into the DAG optimization passes, which means touching the source tree — not friendly for a one-person project

So this version (Part 1) deliberately steps back: first solve “can I reliably find it in the binary after injection?” by decoupling locating from encoding. Locating uses a signature instruction sequence (SAS); encoding is deferred to the topological shape in Part 2. I wrote three versions overall:

  1. V1 — feature-based watermark
  2. V2 — pure topological Graph Diff
  3. V3 — hybrid

0x01 Overall architecture

Architecture

There are two fully decoupled ends:

  1. LeafNode Injector — the LLVM side, which injects the signature
  2. LeafNode Scanner — my own cross-platform static-analysis framework

I picked leaf nodes for simplicity, since this is experimental. Non-leaf nodes are also doable — you just have to reason about true/false branches. I’ll explain that later.

The two ends share no runtime state: the injector only writes, the extractor only scans.

0x02 Leaf node injection

Why leaf nodes

Leaf nodes in a CFG (out-degree 0, i.e. blocks ending in ret) are the ideal injection point:

  1. They don’t affect normal execution — the normal path follows the false branch to the original ret
  2. A leaf node is a function’s natural exit, so injected code never runs in a loop and side effects stay contained
  3. After splitBasicBlock, the first half of the leaf keeps all original instructions, so the structure stays clear

Compared to an out-degree-1 intermediate block, a leaf node doesn’t disturb predecessor relationships of later blocks, minimizing CFG churn. (There’s a cleaner approach via DAG optimizations that would enable a pure Graph Diff, but it’s a lot of work for a solo effort.)

SAS design

SAS (Signature Access Sequence) is the characteristic instruction sequence planted in the leaf node, built from three volatile operations:

volatile load  @gs_wm_key             ; op-1: sentinel read — the locating anchor
or  %loaded, nibble                   ; op-2: encoding op — low 4 bits hold the watermark value
volatile store %encoded, @gs_wm_val_N ; op-3: encoding write
xor %encoded, 0xA5A5A5A5              ; checksum op
volatile store %checked, @gs_wm_val_N ; op-4: checksum write (overwrite)

Everything is volatile to stop SCCP and GVN from folding the globals into compile-time constants. Without volatile, an earlier version was eliminated entirely at -O1; with it, the sequence survives even -O2.

The checksum write lets the extractor verify structural integrity: enc XOR 0xA5A5A5A5 == chk. If the binary has been patched, that equality very likely breaks.

The injection flow

Four steps.

Step 1 — find leaf nodes. Iterate over all basic blocks in the function; the filter is a ReturnInst terminator with zero successors. These blocks are the function’s natural exits — appending code at their end doesn’t disturb any predecessor’s jump, the minimal-change injection point.

Step 2 — split the leaf block. Call splitBasicBlock on the chosen leaf, using the original ret as the split point. This splits the block in two: the first half keeps all original instructions with an unconditional jump to the second half (leafTail) auto-inserted at its end; the second half holds only the original ret. This must happen before creating sentinelBB — otherwise a terminator-less empty block hangs on the function during the window, and the in-pass verifier crashes.

Step 3 — plant the SAS. Insert the signature sequence just before the unconditional jump at the end of the first half: volatile load @gs_wm_key as the anchor, or %k, nibble to encode the watermark value into the low 4 bits, volatile store to @gs_wm_val_N, xor 0xA5A5A5A5 as the checksum, then a second volatile store to overwrite. All five instructions depend on volatile values, so neither SCCP nor GVN can derive them and DCE can’t eliminate them.

Step 4 — replace the terminator and wire in sentinelBB. Create sentinelBB, holding just two instructions: volatile store 0xC0FFEE00 | (idx<<8) → @gs_wm_magic and unreachable. Then delete the auto-inserted unconditional jump at the end of the first half and replace it with condBr(always_false, sentinelBB, leafTail). The always-false condition is built by a volatile load @gs_cv, squaring it, and comparing < 0 — a square is never negative, so the condition is always false, but because the load is volatile, SCCP can’t see that and can’t delete the sentinelBB branch.

Injection is done: original behavior is unchanged, and leafTail’s ret is the only actually-executed exit. The fake branch can also be swapped for other controllable random behaviors as the jump condition — or you can add MBA obfuscation to make the fake branch harder to spot. Some examples:

  1. a random stack-address read (when there’s leftover data)
  2. an identity expression
  3. the .text section

Fake branch options

The condBr condition is sq = gs_cv * gs_cv; sq < 0 — always false because a square is never negative — and gs_cv is volatile too, blocking SCCP.

Global variable layout

Variable Initial value Purpose
@gs_wm_key 0xDEAD5A5A watermark-region identification tag; SAS locating anchor
@gs_wm_val_N 0 encoding slot for the N-th nibble
@gs_wm_magic 0xC0FFEE00 sentinel integrity magic
@gs_cv 1 volatile source for the always-false condition

LLVM Pass implementation gotchas (all hard-won)

Leaf node collection:

for (BasicBlock& BB : F) {
    Instruction* term = BB.getTerminator();
    if (term && term->getNumSuccessors() == 0 && isa<ReturnInst>(term))
        leaves.push_back(&BB);
}

splitBasicBlock ordering: split first, then create sentinelBB. Creating sentinelBB first and splitting after leaves a terminator-less empty block on the function during the window, and the pass-pipeline verifier crashes.

// correct order
BasicBlock* leafTail = leaf->splitBasicBlock(retInst, ...);
// create sentinel only after split is done
BasicBlock* sentinelBB = BasicBlock::Create(Ctx, sentinelName, &F);

Multiple nibbles reusing one leaf: when there are fewer leaf nodes than nibbles needed, the same leaf gets injected repeatedly. Each injection operates on the leafTail produced by the previous one, so the leaf block ends up split into multiple segments, each with its own sentinelBB hanging off it. That’s a limitation of the current implementation; Part 2 will inject each leaf only once.

0x03 Leaf node locating

The extraction side runs on my own static-analysis framework. Input is .llir-format disassembly text; the framework parses it into CCFG/CBasicBlock/CInstruction in-memory representations.

Why not locate via sentinel blocks

Originally the sentinel block (the unreachable little block holding mov gs_wm_magic, 0xC0FFEExx) was the locating entry point. But in practice, when one leaf is injected multiple times, several sentinel blocks get merged into one contiguous region after compilation — no longer independent little blocks, with scrambled predecessors.

So I dropped sentinel-based locating in favor of scanning for the SAS pattern directly.

SAS pattern recognition

.llir uses Intel syntax; operands live in m_vecOperands[0].m_strRaw as a whole string, e.g.:

mov eax, dword ptr [$+602058h]   ; load gs_wm_key
or  eax, 0Dh                     ; nibble = 0xD
mov dword ptr [$+60208Ch], eax   ; store encoded
xor eax, -5A5A5A5Bh             ; XOR mask (two's complement = 0xA5A5A5A5)
mov dword ptr [$+60208Ch], eax   ; store checked

(My LLIR is close to Intel syntax but carries extra semantic detail, so don’t be surprised it looks like assembly.)

A few parsing details worth noting:

  • hex values carry an h suffix and need a dedicated parser
  • the XOR mask appears in .llir as -5A5A5A5Bh (signed two’s complement), so the parser must handle the minus sign
  • memory addresses are [$+xxxxxxh]; take the hex after $+ inside the brackets as the address key

A state machine matches the five instructions in order:

IDLE → mov reg, [key_addr]      → LOAD_FOUND
     → or  reg, imm(≤0xF)      → OR_FOUND
     → mov [val_addr], reg      → STORE1_FOUND
     → xor reg, 0xA5A5A5A5     → XOR_FOUND
     → mov [val_addr], reg      → record SASRecord, back to IDLE

Any mismatch resets to IDLE to avoid false positives.

Payload reconstruction

Each SASRecord carries:

  • valAddr — the address of gs_wm_val_N (distinguishes nibble slots)
  • nibble — the value read directly from or eax, 0xN

Sort by valAddr ascending (60208C < 602090 < 602094 < 602098) to map to nibbles 0–3, then concatenate into the full payload.

Dedup: when the same valAddr is injected in multiple functions, keep only the first occurrence.

Measured result

Extraction result

All four nibbles reconstructed, structOK=YES, XOR checksum passes.

0x04 Anti-optimization analysis

Optimization pass Effect Countermeasure
SCCP derives globals as constants blocked by volatile
GVN eliminates redundant loads blocked by volatile
SimplifyCFG deletes always-false branches blocked from deriving the condition
DCE deletes unused stores volatile store can’t be eliminated
InstCombine folds the or immediate nibble stays in the operand

At -O2, the SAS sequence survives intact, and the EXE grows by only ~200 bytes over a 12864-byte baseline.

0x05 Limitations and next steps

Current limitations:

  • the nibble is stored in plaintext in the or instruction’s immediate — a static analyzer (and an attacker) can read it trivially
  • repeated injection into the same leaf merges sentinel blocks, degrading the locating logic
  • when a function has a single leaf node, all nibbles pile up in one block — no dispersion

Part 2 plan:

SAS degrades to a pure locating anchor (or eax, 0 carrying no real nibble), and the real nibble is encoded into the CFG topological subgraph shape after the condBr (reusing the v2 DummySubgraph scheme). Even if an attacker finds the SAS sequence and reads the immediate, all they get is a meaningless 0 — the actual watermark is in the shape.

The V3 two-tier structure:

SAS (locating) + topological shape (encoding) = full watermark

Neither tier alone is enough — an extractor must understand both mechanisms to recover the payload.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch