noxis-verify::adversary::fold_constants walks maximal straight-line
Const(Int/Long)+Binary{Int,Long} runs, modeling the baseline attack
any deobfuscator runs first (and the one raised on the forum: two
adjacent literals and one op, foldable in your head). Each stack slot
carries its own provenance span, so several split constants placed
back to back without a separator are reported as separate findings
instead of merging into one run.
Two uses: an honest §7 measurement (answer: every Constants scheme,
mba included, folds under a generic evaluator - only pattern matchers
hardcoded to one opcode are defeated, documented already in
constants/forms.rs) and a correctness oracle (every scheme must fold
back to the exact source value, now pinned by a regression test).
Tests: bare_literals_are_not_folded,
every_constants_scheme_folds_back_to_the_source_value.
(a&b)+(a|b) = a+b, documented honestly: falls to the same symbolic
evaluator as xor/add/shift (every sub-expression is a foldable
LDC,LDC,BINOP triple), but defeats a pattern matcher hardcoded to one
opcode ("ldc,ldc,ixor = split constant"). Added to ConstantsConfig
default schemes.
Tests: mba_form_uses_and_or_add_not_a_single_binop, round-trip coverage
in int_forms_round_trip/long_forms_keep_shape.
Renaming mixin classes and patching mixins.json (90506a4) breaks Fabric
mods at runtime, proven on LoVisual (2026-10-13):
- When obfuscated packages are randomized per class the config patcher
computes an empty common prefix and writes `package: ""`. Fabric/Mixin
then drops every mixin as "orphaned", so `RenderPipelineMixin
implements IRenderPipeline` never applies, the interface is not
injected into `RenderPipeline`, and the entrypoint dies with
`ClassCastException: RenderPipeline cannot be cast to IRenderPipeline`.
- `IMixinConfigPlugin.shouldApplyMixin` compares the runtime mixin class
name against string literals (`Set.of("dev.loki...Mixin")`); renaming
desyncs those sets and optional sodium/iris/xaero mixins get applied
when the target mod is absent.
Re-enable absorb_mixins: mixin entry classes and the config plugin keep
their names, and locking a class freezes its members too (needed for
@Accessor/@Invoker/@Overwrite name lookups). Calls from locked mixin
bytecode are still rewritten consistently, and the family mechanism keeps
interface method names (IRenderPipeline) in sync with the frozen mixin
methods. patch_mixins_json stays but is identity for locked classes.
Verified: LoVisual obf jar loads all 221 mixins (0 orphaned), the main
menu renders, the entrypoint runs.
JPMS descriptors are never loaded by a class loader; parsing one and
rebuilding its pool left the unmodeled Module attribute with stale
indices, and ASM died in readModule on the rewritten jar (caught by the
ASM probe on Loreport, which ships META-INF/versions module-infos).
Byte-for-byte resource passthrough keeps the descriptor valid.
Type annotations (method- and code-level) and annotation defaults were
passed through as Raw bytes with the original pool indices; the writer
rebuilds the pool, so those indices pointed at the wrong utf8 or past
the pool end. HotSpot tolerates the garbage (lazy reflection parsing),
but ASM's ClassReader dies with ArrayIndexOutOfBoundsException while
Paper's plugin converter reads every method - 76 of 275 LoParkour
classes failed to convert. Target_info and type_path carry no pool
references and stay opaque bytes; type descriptors and element values
are modeled and rewritten by rename like plain annotations.
- Remove absorb_mixins keep rule (mixin classes now renameable)
- Add mixins.rs: patch_mixins_json rewrites package + entries via
common prefix of mapped names; patch_refmap_json rewrites all strings
- Resources pass routes *.mixins.json and *refmap.json to specialized
patchers, falls back to generic text remap
Reference: OBF fabric-obfuscator patchMixinsJson/patchRefmapJson.
Parameter annotations were unmodeled and fell through to Raw with stale
constant-pool indices after the pool rebuild. ASM (Paper converter, Fabric
mixin transformer) then failed with ArrayIndexOutOfBoundsException in
readParameterAnnotations. Now parsed and rewritten like class-level
annotations.
DebugConfig gains fake_line_numbers (deterministic upward walk
replacing LineNumberTable rows when stripping is off; strip wins)
and strip_source_file (drops SourceFile entirely, beating spoof).
Debug tests moved to passes/tests/debug_info.rs with new cases
for both flags and their precedence.
NoisePass now builds branching helper methods (counted int loop,
random arithmetic, even/odd merge, non-throwing ops only) whose
results the entry sums into a private static field, instead of an
empty run()V. Names come from the same NameGenerator styles as
real code and live in a random real package (no more shadow/decoy
uniformity for grep to catch); every generated method gets fresh
StackMapTable frames. weave_first_reachable takes (class, entry)
pairs. New NoiseConfig.junk_methods_per_class (default 3).
Tests assert invariants (unique valid identifiers, calls, field
store, determinism) plus real-JVM verification of junk classes;
paranoid+junk_ratio=1 smoke on lorep: 18 junk classes, all load.
KeepRules::absorb_databind finds X.class literals before Gson /
Jackson / fastjson2 / Moshi calls and locks every field of X
(reflection-bound names never reach the string-based detection).
The class itself still renames. Test in analysis/tests/dto.rs;
keep unit tests moved to tests/keep.rs.
ProgramStats gains locked_classes + locked_share() so analyst-bait
source names are loud in the report instead of surfacing later.
Gson/Jackson/fastjson2/Moshi bind fields by reflection from a Class
literal, with no field-name string for the string-based detection:
renaming such a field silently breaks deserialization. Absorb_databind
finds the nearest Const::Class before fromJson/readValue/parseObject/
adapter calls (10-insn window, control-flow boundaries stop the search)
and keeps every field of that class; the class itself still renames.
Tests: noxis-analysis/tests/dto.rs (red first), keep unit tests moved
to tests/keep.rs (file budget). Code comments migrated to English.
Proven by paranoid runs over lorep (17 classes, fully green),
LoVisual (4081 classes) and oc2r (1278 classes) with per-class
java -Xverify:all, each fix first reproduced as a red test:
- merge: Top absorbs instead of Top-wins (javac declares Top for
slots unwritten on some inflow; precise types reject those edges).
Regression: loop_head_keeps_top_for_loop_local.
- emit: collapse [Long|Double, Top] pairs on operand stacks
(javac writes one entry; the pair is rejected). Regression:
wide_stack_join_emits_single_entries.
- xfer ArrayLoad: push the wide pair, fixing lcmp underflow.
Regression: array_load_wide_pushes_pair.
- xfer <init>: convert only the receiver's own allocation site
instead of every Uninitialized (nested construction clobbered
the outer object). Regression: nested_construction_keeps_outer.
- xfer ANewArray: array operands gain one dimension ([B] -> [[B)
instead of an illegal L-wrap ([L[B;). Covered by the same test.
- jar load: duplicate class names (multi-release module-info
twins) keep the first occurrence instead of crashing save.
Test: duplicate_class_names_keep_first.
- strings/constants: skip methods past MAX_METHOD_INSNS (8000)
so giants cannot overflow the 64KB writer ceiling. Tests:
oversized_methods_are_skipped in both passes.
- merge errors now name both types being merged.
Also: em-dash removed repo-wide (724 spots, rule added to
todo.md), allow-lints only in tests (scratch example deleted),
code comments migrated to English in every file this change
touches.
FlowPass rebuilds one method per scheme picked from FlowConfig:
state-spread (dense tableswitch dispatcher), computed-switch
(sparse lookupswitch over a counter local), butterfly (branch
inversion), exception-goto (dispatcher under try with a dead
catch-all), plus opaque goto-splices inside every method and
StackMapTable recomputation afterwards. Now in standard_pipeline
(between constants and noise); --no-flow is an honest off-flag.
Acceptance is real JVM, not shapes: per-scheme verify, rotation
spread, calc-prints-45 execution, paranoid smoke on lorep-1.1.0
(131 methods rebuilt, no new failures over standard).
Two verifier rules learned the hard way are now construction
invariants: no inserted mark shares a bytecode offset with a
foreign one (nop pads), and no frameless gap follows a terminal
(dead blocks are opaque-entered and throw).
Also fixes a latent frame-emitter bug the pass exposed: wide
stack values were written as slot pairs ([Double, Top]) while
javac writes one entry ([Double]); HotSpot rejects the pair.
Stacks now collapse like locals (emit::stack_vars), with a
regression test in classfile/tests/frames.rs.
Gate ignores blank and full-line comment lines so the 250-line limit
measures code bulk, not documentation weight; docs/README.md synced.
Translate code comments to English in classfile, core config, namegen,
rename assign, constants forms. Expand Plausible word bank with
client-ecosystem terms (Module, Setting, Mixin, ...). cargo fmt clean,
check.sh green.
ristretto's ConstantPool never deduplicates: every add_* pushes a new entry.
HotSpot matches each LocalVariableTypeTable entry to a LocalVariableTable
entry by raw name_index, so when a name (e.g. a local's) was interned twice
the two tables got different indices and the class failed to load:
'ClassFormatError: LVTT entry ... does not match any LVT entry'.
The writer now memoizes every pool builder by its semantic key and rebuilds
composite constants (Class/NameAndType/Fieldref/Methodref/MethodHandle/
Dynamic) from our own utf8/class primitives so ristretto never mints a
shadow Utf8. This also shrinks the pool (no more ldc -> ldc_w widening that
changed code length on a pure round-trip).
Split pool.rs into a pool/ module (refs in pool/refs.rs) to respect the
file-size gate. Regression test: tests/pool asserts the written pool has no
duplicate CONSTANT_Utf8. Verified: pure parse->write of the neoforge jar
(92 classes) now loads under java -Xverify:all with 0 errors.
StackMapTable compact-vs-extended selection used the instruction delta,
but JVM validity requires the byte offset delta <= 63. Track byte offsets
in convert_frames and branch on byte_delta while keeping the instruction
delta stored (ristretto re-derives bytes).
Pipeline::run ignored config.disable, so --no-* CLI flags had no effect;
gate each pass on config.is_enabled.
Documentation had drifted from the actual implementation: the noxis
(CLI) and noxis-verify crates were fully built and tested but every
doc called them "not started". Verified against the code (build +
test run) and corrected status across README.md, todo.md,
architecture.md, techniques.md and learnings/{lessons,priorities}.md.
Also:
- restyled README.md (badges, no em dashes, usage examples, dropped
the redundant "Принципы" section already covered elsewhere)
- added docs/learnings/ai-resistance.md: a sourced writeup of what
actually works against LLM-driven reverse engineering (Elastic
Security Labs' Opus 4.6 vs Tigress study, the Acoda paper) and what
of it applies to noxis, plus concrete todo.md §7 items
- noxis-structure-check: the 250-line/4-file gate was scanning
docs/**, which fought against writing documentation freely; it now
only scans crates/*/{src,tests,benches} and only counts .rs files
NestHost/NestMembers were carried as Attribute::Raw with constant-pool
indices baked in. During writing the pool is rebuilt in a different order,
so those indices pointed at the wrong entries and the JVM rejected the
class: ClassFormatError: Nest member/host class_info_index N has bad
constant type.
Model them explicitly in the IR, convert on parse and write (resolving
class names through the new pool), and remap their class names during
rename. Add a NestMembers round-trip regression test. Verified on a real
jar: all Nest ClassFormatErrors gone.