Docs  /  Architecture & Internals

Rerius Architecture

This document describes the internal design of Rerius: how modules interact, how data flows through the pipeline, and the reasoning behind a few of the less obvious design decisions.

Repository: https://github.com/ECLS-Studio/rerius


Overview#

Rerius is structured as a layered analysis pipeline:

┌──────────────────────────────────────────────────────────────┐
│  Input: ELF / PE / Mach-O / Raw binary file                  │
└───────────────────────────┬──────────────────────────────────┘
                            │
            ┌───────────────▼──────────────────┐
            │  loader.c  -  Binary Parser       │
            │  ELF32/64 + PE32/PE64+ + Raw      │
            │  Sections, metadata, SHA-256       │
            └───────────────┬──────────────────┘
                            │  dax_binary_t
                            │
            ┌───────────────▼──────────────────┐
            │  dax_guard.h: Fault Isolation    │
            │  DAX_GUARD_BIN / DAX_RUN_PASS     │
            │  dax_clamp_counts() after load    │
            └───────────────┬──────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        │                   │                   │
   ┌────▼────┐         ┌────▼────┐        ┌─────▼────┐
   │ symbols │         │ disasm  │        │ analysis │
   │ .c      │         │ .c      │        │ .c       │
   │ symtab  │         │ decode  │        │ xrefs    │
   │ dynsym  │         │ x86/    │        │ groups   │
   │ PE exp  │         │ arm64/  │        │ strings  │
   └────┬────┘         │ riscv   │        └──────────┘
        │              └────┬────┘
        │                   │
        └────────┬──────────┘
                 │
         ┌───────▼────────┐
         │  cfg.c          │
         │  Two-pass CFG   │
         │  Pre-register   │
         │  branch targets │
         │  Dead-byte skip │
         └───────┬────────┘
                 │
    ┌────────────┼──────────────────────┐
    │            │                      │
┌───▼───┐  ┌────▼────┐         ┌───────▼──────┐
│ loops │  │callgraph│         │  Advanced (ARM64+RISC-V) │
│ .c    │  │ .c      │         │  symexec.c               │
│       │  │         │         │  decomp.c                │
└───────┘  └─────────┘         │  emulate.c               │
                                └──────────────────────────┘
                 │
         ┌───────▼────────┐
         │  main.c         │
         │  CLI output     │
         │  Banner + table │
         └───────┬────────┘
                 │
        ┌────────▼────────┐
        │  daxc.c          │
        │  .daxc snapshot  │
        └─────────────────┘

Project Layout#

The repository is organized by responsibility rather than by file type. Each subsystem gets its own directory in both src/ and include/, and the same category names line up across the two trees:

Rerius/
├── Makefile              entry point: chmod +x setup.sh && bash setup.sh
├── setup.sh               interactive build launcher (also prints the CLI banner)
├── build_js.sh             native Node addon build
├── scripts/
│   └── theme.sh            shared shell palette + ok/err/warn/label helpers
├── include/
│   ├── core/                dax.h, dax_guard.h, plugin.h
│   ├── formats/              elf.h, pe.h, macho.h
│   ├── arch/                  x86.h, arm64.h, riscv.h
│   └── ui/                    theme.h  -  CLI color palette (C side)
├── src/
│   ├── cli/                   main.c, interactive.c        - entry + REPL
│   ├── core/                   loader.c, config.c, plugin.c,
│   │                            daxc.c, hardening.c          - engine
│   ├── formats/                  macho.c                     - format parsing
│   ├── arch/                      disasm.c, x86_decode.c,
│   │                                arm64_decode.c, riscv_decode.c
│   ├── analysis/                    analysis.c, cfg.c, callgraph.c,
│   │                                  loops.c, symexec.c, decomp.c,
│   │                                  correct.c, dsa.c, entropy.c
│   ├── emu/                            emulate.c             - ARM64/RISC-V VM
│   └── util/                            sha256.c, unicode.c,
│                                          demangle.c, symbols.c
├── platform/               per-OS/arch asm entry stubs (.S / .asm)
├── js/                      Node.js bindings + REST server + examples
├── learn/                   numbered markdown lesson series
└── docs/                    everything except README/LICENSE/CHANGELOG

Why this shape: a contributor adding a new binary format should only need to touch include/formats/ and src/formats/, rather than searching a flat, mixed-purpose src/ directory. Each folder is meant as a bounded context: arch/ doesn't need to know about analysis/, cli/ is the only place argument parsing and REPL logic live, and util/ is dependency-free leaf code any layer can call into.

Include paths: headers keep flat names (#include "dax.h", not #include "core/dax.h"): only their location on disk changed. The build passes one -I flag per include subdirectory (include/core, include/formats, include/arch, include/ui, plus include itself), so no source file needed to change a single #include line for this reorganization.


Fault Isolation Model#

Rerius uses a fault isolation model loosely inspired by microkernel design: each analysis pass is treated as an independent unit. A pass that hits a structural fault on malformed input is fenced off, a diagnostic is emitted, and the pipeline continues to the next pass: the goal is that a single corrupt binary or bad pointer doesn't take down the whole run. This is a robustness mechanism, not a formal safety guarantee; see SECURITY.md for what it is and isn't expected to cover.

Three layers#

1. Input validation macros: check pointers, sizes, and counter bounds before any module does real work.

DAX_GUARD_BIN(bin);           // returns void if bin is invalid
DAX_GUARD_BIN_RET(bin, -1);  // returns -1 if bin is invalid
DAX_GUARD_FUNC(bin, fi);      // returns void if func index OOB
DAX_GUARD_FUNC_RET(bin,fi,r); // returns r if func index OOB

2. Safe array accessors: return NULL instead of dereferencing out of bounds.

uint8_t     *p  = dax_sec_ptr(bin, si);    // NULL if OOB or size=0
dax_func_t  *fn = dax_func_ptr(bin, fi);  // NULL if OOB
dax_block_t *b  = dax_block_ptr(bin, bi); // NULL if OOB
dax_symbol_t *s = dax_sym_ptr(bin, si);   // NULL if OOB

3. Pass-level wrapper: every top-level call in main.c uses DAX_RUN_PASS, which clears the global fault register, runs the pass, then checks whether it faulted and prints a recovery notice if so.

DAX_RUN_PASS("cfg-print", opts.color,
    dax_cfg_print(&bin, fi, &opts, stdout));

If the pass sets g_dax_fault (via dax_fault_set("reason")), the pipeline prints:

  [!] pass 'cfg-print' recovered from fault: reason

...and continues to the next pass. All 32 top-level module calls in main.c are wrapped this way.

Global fault register#

volatile int g_dax_fault;       // set by any module on fault
char         g_dax_fault_msg[]; // optional detail string

Defined in main.c, declared extern in dax_guard.h. Cleared at the start of each DAX_RUN_PASS.

Counter normalization#

After every loader phase and symbol load, dax_clamp_counts(&bin) normalizes all dax_binary_t counters to their DAX_MAX_* upper bounds, so a corrupted counter can't drive an unbounded loop downstream.

Code window helper#

dax_code_window(bin, fi, &code, &sz, &base, &fn_off, &fn_end) finds the section containing function fi, validates all bounds (including the overflow-safe size > bin->size - offset check), and returns the code pointer and offsets in a single call. Used by cfg.c, emulate.c, and symexec.c.

Loop budget guard#

Decode loops that iterate over function bytes use DAX_BUDGET_INIT(65536) + DAX_BUDGET_CHECK() to bound worst-case runtime on corrupt or adversarial data.


Core Data Structures#

All analysis state lives in structs defined in include/core/dax.h.

dax_binary_t#

The central object holding all parsed and analyzed data:

dax_binary_t
├── data / size          Raw file bytes
├── arch / fmt / os      Architecture, format, OS/ABI
├── entry / base         Entry point, image base
├── image_size           Total mapped image size
├── code_size / data_size Aggregated section sizes
├── sha256 / build_id    File hash, GNU Build-ID
├── is_pie / is_stripped / has_debug  Metadata flags
├── sections[128]        dax_section_t array (stack)
├── symbols*             dax_symbol_t array (heap)
├── xrefs*               dax_xref_t array (heap)
├── functions*           dax_func_t array (heap)
├── blocks*              dax_block_t array (heap)
├── comments*            dax_comment_t array (heap)
└── ustrings*            dax_ustring_t array (heap)

All counter fields (nsections, nfunctions, nsymbols, etc.) are clamped to DAX_MAX_* constants after every load phase. Any loop over these fields should still include an explicit && i < DAX_MAX_* guard rather than relying on the clamp alone.

dax_opts_t#

Flags controlling what the CLI produces. Each flag corresponds to one analysis pass:

show_bytes  show_addr  color  verbose
symbols     demangle   funcs  groups   xrefs  strings
cfg         loops      callgraph  switches
unicode     symexec    ssa    decompile  emulate
section     output_daxc  start_addr  end_addr

Module Descriptions#

loader.c: Binary Parser#

Reads the file into memory and dispatches to ELF, PE, or Mach-O parsing. Detects format by magic bytes: 0x7fELF → ELF, MZ → PE, 0xFEEDFACF/0xBEBAFECA etc. → Mach-O. All section offset arithmetic uses the overflow-safe form s->size > bin->size - s->offset rather than s->offset + s->size > bin->size. ELF string table (sh_name) accesses are bounds-checked against sh_size before use.

Populates: - bin->sections[]: virtual address, file offset, size, flags, type - bin->arch, bin->fmt, bin->os - bin->entry, bin->base, bin->image_size - bin->is_pie, bin->is_stripped, bin->has_debug - bin->build_id: extracted from .note.gnu.build-id

symbols.c: Symbol Loading#

Reads ELF SHT_SYMTAB and SHT_DYNSYM sections and the PE export directory. Filters ARM64 mapping symbols ($x, $d). Calls dax_demangle() for each symbol. Results go into bin->symbols[]. add_symbol() checks bin->nsymbols < DAX_MAX_SYMBOLS and a non-NULL array before every write; dax_sym_find() uses the overflow-safe midpoint lo + (hi - lo) / 2; ELF st_name is checked against strtab_size before pointer arithmetic.

analysis.c: Classification + Xref Builder#

  • dax_classify_x86() / dax_classify_arm64() / dax_classify_riscv(): map mnemonic strings to dax_igrp_t categories
  • dax_xref_build(): scans code sections decoding every instruction; when a call or branch with a known target is found, adds a dax_xref_t
  • dax_switch_detect(): null-guards opts and bin before any access
  • dax_sec_classify(): maps section names to dax_sec_type_t

disasm.c: Disassembly Output#

Produces annotated disassembly to a FILE*. For each instruction it: 1. Decodes using the appropriate architecture decoder 2. Resolves symbols at the address (from symbols.c) 3. Resolves string references from .rodata (via dax_resolve_string(), which handles UTF-8 multi-byte sequences) 4. Colors the mnemonic based on the dax_classify_*() result 5. Annotates cross-references (callers/callees) 6. Prints function boundary headers

Entry points use DAX_GUARD_BIN_RET; section lookups (find_section_by_name, find_exec_section) are capped at i < DAX_MAX_SECTIONS.

cfg.c: Control Flow Graph Builder#

Two-pass algorithm:

Pass 1 (pre-pass): scan the entire function body. For every branch/call instruction with a known target, call find_or_add_block() to register the target address as a block boundary. Also register conditional fall-through addresses.

Pass 2 (main pass): walk instructions sequentially. At each branch: - Conditional: add true/false edges; cur continues to the fall-through block - Unconditional: add a jump edge, then skip forward past dead bytes by scanning for the next pre-registered block boundary (this handles code that jumps into the middle of what would otherwise look like a contiguous block) - Return: mark the block is_exit; continue from the next block boundary, if any

find_block_by_addr, find_or_add_block, and get_or_make_indirect_block all null-check bin->blocks before use.

loops.c: Loop Detection#

A post-dominator based back-edge detector: a back edge (A → B) where B dominates A indicates a loop. Uses iterative dominator computation over the CFG. All loops over bin->nblocks are capped with && i < DAX_MAX_BLOCKS.

unicode.c: String Scanner#

Two independent scanners:

UTF-8 scanner: walks bytes; at each position attempts dax_utf8_decode(). Accepts the string if it is NUL-terminated, has ≥ 2 characters, and contains at least one multi-byte sequence (codepoint ≥ U+0080).

UTF-16LE scanner: only runs on sections not in the skip list (.dynstr, .dynsym, .strtab, etc.). For a candidate position: 1. Checks the preceding byte is 0x00 (string boundary, not mid-sequence) 2. Decodes code units, counting wide (hi-byte ≠ 0) and surrogate pairs 3. Rejects if all hi-bytes are 0 (pure null-padded ASCII) 4. Rejects if no codepoint > U+02FF (filters out ELF binary data that happens to look string-shaped) 5. Requires ≥ 6 code units and ≥ 3 wide units (or a surrogate pair)

This scanner reduces false positives on binary data but is heuristic: it will still occasionally flag non-string byte sequences and occasionally miss short legitimate strings.

macho.c: Mach-O Parser#

Handles Mach-O binaries (macOS/iOS):

FAT/universal: fat_find_slice() walks the big-endian FAT header (via bswap32), preferring the ARM64 slice and falling back to x86-64. File offsets in section structures are absolute from the start of the full FAT file, not slice-relative, so bin->data + section->offset is always correct.

Magic constants: Mach-O magic values are defined as what a little-endian CPU reads from the raw bytes: MACHO_MAGIC_64_LE = 0xFEEDFACF (bytes CF FA ED FE on disk). swap=1 only for big-endian files.

Load command walker: iterates ncmds load commands, handling LC_SEGMENT_64 (sections), LC_MAIN (entry point = text_vmbase + entryoff), and LC_SYMTAB (nlist_64 entries, stripping a leading _ from names).

Section naming: __TEXT,__text.text, __DATA,__data.data, etc.: stripping the __ prefix lets Mach-O sections use the same naming convention as ELF elsewhere in the codebase.

entropy.c: Entropy, RDA, IVF, Poly Map, AIRE#

Five related passes live in this file. All public entry points begin with DAX_GUARD_BIN and null-check opts/out; section loops use the overflow-safe bound sec->size > bin->size - sec->offset.

dax_entropy_scan(): computes Shannon entropy (H = -∑pᵢ log₂pᵢ) over 256-byte sliding windows with a 64-byte step. Classifies windows as normal / HIGH (≥ 6.8) / PACKED-OR-ENCRYPTED (≥ 7.0). High entropy is a signal, not proof: compressed or encrypted data both score high, and the scanner does not try to distinguish them.

dax_rda_section(): recursive-descent disassembly via BFS from the section entry point and all known symbols. Uses a bounded queue (16384 entries) and a bounded visited array (65536 entries). Output is sorted by address with [DEAD: ...] markers for gaps.

dax_ivf_scan(): a linear instruction-validity scan flagging: invalid mnemonics (??/dw), privileged ARM64/x86-64 instructions (static table lookup), NOP runs, INT3 runs, dead bytes after unconditional branches, candidate self-modifying-code patterns (four heuristics: adr+str look-back up to 8 instructions, eor+str XOR mutation stubs, stores inside a poly-mapped region, and emulator write cross-references), and candidate opaque predicates (subs xN, xA, xA, mrscmpb.cond).

dax_poly_map(): a sliding 48-byte window across code sections scoring 18 heuristic mutation signals (indirect dispatch, NOP junk, dead code, constant obfuscation, opaque predicates, opcode substitution, XOR arithmetic, rotation obfuscation, data-dependent branches, anti-debug gates, substitution chains, XOR mutation loops, hash-chain patterns, high local entropy, and a few others). Contiguous windows scoring above a threshold are merged into dax_poly_region_t records. The output also includes a best-effort fingerprint against known obfuscator families (e.g. OLLVM/Hikari-style, XOR-based custom packers, hash-chain patterns associated with Tigress): this is pattern matching against known signatures, not a definitive identification. Must run before AIRE, since AIRE reads the poly region data it produces.

dax_aire_analyze(): walks the accumulated dax_binary_t state (functions, xrefs, poly regions, DSA signals) and prints a ranked list of heuristic observations, each with a numeric confidence score. aire_count_callers()/aire_count_callees() null-check bin->xrefs; aire_scan_fn() validates section bounds with the overflow-safe pattern and null-guards decoder output. Beyond the ranked list, it prints: - a short summary naming the dominant observation category; - a handful of suggested next CLI commands based on that category (e.g. a VM-dispatch observation suggests --vm-trace and -C); - a memory-persistence note: results are written to .aire_memory (up to 32 entries, keyed by SHA-256), and on a later run against the same binary a short recall banner shows the run count, last-seen timestamp, and top finding from the previous run.

This is heuristic, rule-based pattern matching over static and (where available) traced execution data, not a trained model, and its output should be read as leads for a human analyst, not a verdict.

symexec.c: Symbolic Execution (ARM64, RISC-V, x86-64)#

Entry points use DAX_GUARD_BIN; dax_symexec_prepass() checks bin->functions != NULL; dax_symexec_all() is capped at DAX_MAX_FUNCTIONS. Register state is represented as expression trees built from a pool of sym_expr_t nodes: registers start symbolic (SEXPR_VAR), concrete values short-circuit to SEXPR_CONST, and binary operations produce SEXPR_binop(l, r) nodes that are evaluated numerically when both operands are concrete. Self-modifying-code tracking (smc_write_pc/target/old/new, 128-entry arrays) records repeated writes to the same address as mutation chains.

decomp.c: SSA/NR Lifting + Decompiler (ARM64, RISC-V, x86-64)#

Entry points use DAX_GUARD_BIN; dax_decompile_all() is capped at DAX_MAX_FUNCTIONS.

Lifting pass (referred to internally as "NR"): lifts instructions to a typed intermediate representation using standard SSA-style variable versioning: each register write becomes a new versioned nr_var_t:

mov  x8, x0       →  r8_1:u64  = r0_0
add  x0, x0, #7   →  r0_2:u64  = r0_0 + 0x7

Each nr_var_t carries an nr_type_t tag inferred from the producing instruction (pointer, flags, or a sized integer). Direct call instructions are resolved against bin->functions[] and annotated with the callee's index and name where the target is statically known; this is call-target resolution for display purposes, not full interprocedural dataflow analysis.

Decompiler pass: translates the lifted IR to pseudo-C with type-aware local declarations, argument inference from x0x7, tail-call detection, recognized rotation idioms rendered as __ror/__rol, and resolved call-site annotations. This produces a readable approximation of the source, not a guaranteed-correct C reconstruction: always cross-check against the disassembly for anything load-bearing.

Program-level pass: dax_decompile_all() collects call edges across all lifted functions into a whole-program call graph, plus a list of functions flagged for suspected self-modification.

emulate.c: Concrete Emulator (ARM64, RISC-V)#

dax_emulate_func() uses DAX_GUARD_BIN and dax_func_idx_ok(). Section reads in emu_read8() use the overflow-safe bound check before a separate bounds check on the final offset; the PC-to-section lookup loop is capped at DAX_MAX_SECTIONS. The emulator models: - 32 × 64-bit general-purpose registers - a stack (virtual allocation at 0x7fff0000) - page-based memory (emu_page_t[256], 4096 bytes each) - memory reads that fall back to binary section data for loads from .rodata - CPSR flags (Z, N, C, V) updated by cmp, adds, subs

Every byte write is checked against the code sections (emu_write8()); a write into executable memory is recorded as a candidate self-modifying-code event (bin->emu_smc_write_pc[], up to 64 entries). Execution terminates at ret (register x0 is reported as the return value), at a call to an external/unresolved function, or after EMU_MAX_STEPS instructions.

dsa.c: Dynamic Single Assignment#

DSA extends SSA with values observed during a concrete emulation trace: each definition carries the runtime value seen on each traced path, rather than being purely structural. This lets a few obfuscation patterns collapse automatically: an opaque predicate where one branch is never taken in any trace, an indirect dispatch where every observed target gets recorded, or a constant that always evaluates to a known crypto constant. Each phase (simulate, build_chains, mark_dead, build_phis, print) is independently fenced with its own fault flag, so a fault during simulate doesn't prevent build_chains from running on whatever data was collected. All counters are clamped (DSA_CLAMP) before iteration, and nresolved_indirect/nsmc_patches are checked against DSA_INDIRECT_MAX/DSA_SMC_MAX before use.

As with AIRE, DSA's output is trace-derived: it reflects the paths actually observed during emulation, not an exhaustive analysis of every possible execution path.


JS Binding Architecture#

Rerius JS layer
────────────────────────────────────────────────────
js/index.js            ReriusBinary class
                        wraps _handle (napi external)
                        validates file exists
                        converts BigInt ↔ address
          │
          │  require('./rerius.node')
          ▼
js/src/rerius_napi.c    26 N-API functions
                        each gets handle → dax_binary_t*
                        calls ensure_symbols() / ensure_functions()
                        open_memstream() for text output
                        returns napi_value objects/arrays
          │
          │  direct C calls
          ▼
Rerius C core (all src/*.c)
────────────────────────────────────────────────────

Handle lifecycle: 1. ndx_load(): calloc(dax_binary_t)napi_create_external(ptr) 2. Every other function: get_handle() unwraps the external back to dax_binary_t* 3. ndx_close(): dax_free_binary() + free() + sets a closed flag in the JS wrapper

Text output: functions like disasm, symexec, ssa, decompile, and emulate write to an open_memstream buffer, then return the buffer contents as a UTF-8 JS string.


The .daxc Snapshot Format#

.daxc is a binary format for saving and reloading full analysis results:

daxc_header_t          fixed-size header (magic, version, offsets, counts)
sections[]             dax_section_t array
symbols[]              dax_symbol_t array
xrefs[]                dax_xref_t array
functions[]            dax_func_t array
blocks[]               dax_block_t array
comments[]             dax_comment_t array
insns[]                daxc_insn_t array (decoded instructions)
ustrings[]             dax_ustring_t array

Magic: 0x584F454E (NEOX in little-endian ASCII). Version: 4.


Adding a New Architecture#

  1. Add ARCH_NEWARCH to the dax_arch_t enum in include/core/dax.h
  2. Create include/arch/newarch.h with instruction type definitions
  3. Create src/arch/newarch_decode.c implementing newarch_decode()
  4. Add a disassembly entry point dax_disasm_newarch() in src/arch/disasm.c
  5. Add classification dax_classify_newarch() in src/analysis/analysis.c
  6. Wire up the new architecture in src/core/loader.c, src/cli/main.c, and src/analysis/cfg.c
  7. Add the new source files to SRCS in the Makefile

This is a substantial undertaking: the decoder is the foundation everything else (CFG, xrefs, loop detection, call graph) builds on. Expect to spend most of the effort on decoder correctness and coverage before the rest of the pipeline is useful for the new architecture.


Adding a New Analysis Module#

  1. Create the module under the appropriate subdirectory (e.g. src/analysis/mymodule.c)
  2. Add #include "dax_guard.h" after #include "dax.h"
  3. Start every public entry point with DAX_GUARD_BIN(bin) or DAX_GUARD_BIN_RET(bin, retval)
  4. Declare public functions in include/core/dax.h
  5. Add a flag to dax_opts_t (e.g., int mymodule)
  6. Add CLI flag parsing in src/cli/main.c
  7. Call the module from src/cli/main.c using DAX_RUN_PASS("mymodule", opts.color, mymodule_fn(&bin, &opts, stdout))
  8. Add the source to SRCS in the Makefile and LIB_SRCS in build_js.sh
  9. Expose it via N-API in js/src/rerius_napi.c if a JS API is needed
  10. Add a method to js/index.js and its type to js/index.d.ts
Edit this page on GitHub Source: docs/ARCHITECTURE.md · Rerius v1.0.0
On this page
Rerius Architecture Overview Project Layout Fault Isolation Model Three layers Global fault register Counter normalization Code window helper Loop budget guard Core Data Structures dax_binary_t dax_opts_t Module Descriptions loader.c: Binary Parser symbols.c: Symbol Loading analysis.c: Classification + Xref Builder disasm.c: Disassembly Output cfg.c: Control Flow Graph Builder loops.c: Loop Detection unicode.c: String Scanner macho.c: Mach-O Parser entropy.c: Entropy, RDA, IVF, Poly Map, AIRE symexec.c: Symbolic Execution (ARM64, RISC-V, x86-64) decomp.c: SSA/NR Lifting + Decompiler (ARM64, RISC-V, x86-64) emulate.c: Concrete Emulator (ARM64, RISC-V) dsa.c: Dynamic Single Assignment JS Binding Architecture The .daxc Snapshot Format Adding a New Architecture Adding a New Analysis Module
ESC
↑↓ navigate openesc close