From e9ead4dc5f2ca29c72eea047263037c16c331c07 Mon Sep 17 00:00:00 2001 From: bfloat16 Date: Tue, 11 Aug 2026 14:19:56 +0800 Subject: [PATCH] refactor: init --- .gitattributes | 3 - .github/ISSUE_TEMPLATE/bug_report.yml | 48 - .github/ISSUE_TEMPLATE/config.yml | 1 - .github/ISSUE_TEMPLATE/feature_request.yml | 25 - .github/workflows/ci.yml | 99 - .github/workflows/release.yml | 79 - .gitignore | 7 +- AGENTS.md | 79 - CLAUDE.md | 1 - Cargo.lock | 31 +- Cargo.toml | 45 +- README.md | 77 - docs/design.md | 154 -- docs/development.md | 116 - docs/usage.md | 126 - rust-toolchain.toml | 3 - samples/README.md | 88 - senbei-cli/Cargo.toml | 15 + {src => senbei-cli/src}/main.rs | 29 +- senbei-crypto/Cargo.toml | 9 + .../src}/bytecode.rs | 0 {src/unpacker => senbei-crypto/src}/crc32.rs | 0 senbei-crypto/src/lib.rs | 77 + senbei-crypto/src/primitives.rs | 1098 ++++++++ {src/unpacker => senbei-crypto/src}/tables.rs | 10 +- senbei-io/Cargo.toml | 23 + {src => senbei-io/src}/job.rs | 61 +- {src => senbei-io/src}/lib.rs | 4 +- {src => senbei-io/src}/logfile.rs | 9 +- {src => senbei-io/src}/pause.rs | 0 {src => senbei-io/src}/scan.rs | 10 +- {src => senbei-io/src}/ui.rs | 2 +- senbei-metadata/Cargo.toml | 6 + senbei-metadata/src/lib.rs | 5 + {src => senbei-metadata/src}/metadata.rs | 0 senbei-pe/Cargo.toml | 10 + senbei-pe/src/engine/dll/mod.rs | 3 + .../src/engine/dll/pipeline.rs | 18 +- senbei-pe/src/engine/error.rs | 243 ++ senbei-pe/src/engine/exe/mod.rs | 3 + .../src/engine/exe/pipeline.rs | 1368 +--------- senbei-pe/src/engine/exe/pipeline/pe32.rs | 1063 ++++++++ .../src/engine}/integrity.rs | 0 senbei-pe/src/engine/layout.rs | 14 + senbei-pe/src/engine/layout/dd8.rs | 321 +++ senbei-pe/src/engine/layout/discovery.rs | 507 ++++ senbei-pe/src/engine/layout/image.rs | 481 ++++ {src/unpacker => senbei-pe/src/engine}/mod.rs | 44 +- .../src/engine}/parallel.rs | 2 +- senbei-pe/src/lib.rs | 5 + src/unpacker/primitives.rs | 2394 ----------------- tests/common/mod.rs | 10 - tests/job.rs | 31 - tests/logfile.rs | 47 - tests/run_log.rs | 80 - tests/samples.rs | 229 -- web/Cargo.lock | 441 --- web/Cargo.toml | 19 - web/LICENSE | 662 ----- web/README.md | 75 - web/app.js | 392 --- web/index.html | 78 - web/src/lib.rs | 180 -- web/style.css | 369 --- web/worker.js | 41 - 65 files changed, 4028 insertions(+), 7442 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 AGENTS.md delete mode 120000 CLAUDE.md delete mode 100644 README.md delete mode 100644 docs/design.md delete mode 100644 docs/development.md delete mode 100644 docs/usage.md delete mode 100644 rust-toolchain.toml delete mode 100644 samples/README.md create mode 100644 senbei-cli/Cargo.toml rename {src => senbei-cli/src}/main.rs (80%) create mode 100644 senbei-crypto/Cargo.toml rename {src/unpacker => senbei-crypto/src}/bytecode.rs (100%) rename {src/unpacker => senbei-crypto/src}/crc32.rs (100%) create mode 100644 senbei-crypto/src/lib.rs create mode 100644 senbei-crypto/src/primitives.rs rename {src/unpacker => senbei-crypto/src}/tables.rs (92%) create mode 100644 senbei-io/Cargo.toml rename {src => senbei-io/src}/job.rs (94%) rename {src => senbei-io/src}/lib.rs (59%) rename {src => senbei-io/src}/logfile.rs (92%) rename {src => senbei-io/src}/pause.rs (100%) rename {src => senbei-io/src}/scan.rs (98%) rename {src => senbei-io/src}/ui.rs (97%) create mode 100644 senbei-metadata/Cargo.toml create mode 100644 senbei-metadata/src/lib.rs rename {src => senbei-metadata/src}/metadata.rs (100%) create mode 100644 senbei-pe/Cargo.toml create mode 100644 senbei-pe/src/engine/dll/mod.rs rename src/unpacker/dll.rs => senbei-pe/src/engine/dll/pipeline.rs (98%) create mode 100644 senbei-pe/src/engine/error.rs create mode 100644 senbei-pe/src/engine/exe/mod.rs rename src/unpacker/exe.rs => senbei-pe/src/engine/exe/pipeline.rs (59%) create mode 100644 senbei-pe/src/engine/exe/pipeline/pe32.rs rename {src/unpacker => senbei-pe/src/engine}/integrity.rs (100%) create mode 100644 senbei-pe/src/engine/layout.rs create mode 100644 senbei-pe/src/engine/layout/dd8.rs create mode 100644 senbei-pe/src/engine/layout/discovery.rs create mode 100644 senbei-pe/src/engine/layout/image.rs rename {src/unpacker => senbei-pe/src/engine}/mod.rs (90%) rename {src/unpacker => senbei-pe/src/engine}/parallel.rs (99%) create mode 100644 senbei-pe/src/lib.rs delete mode 100644 src/unpacker/primitives.rs delete mode 100644 tests/common/mod.rs delete mode 100644 tests/job.rs delete mode 100644 tests/logfile.rs delete mode 100644 tests/run_log.rs delete mode 100644 tests/samples.rs delete mode 100644 web/Cargo.lock delete mode 100644 web/Cargo.toml delete mode 100644 web/LICENSE delete mode 100644 web/README.md delete mode 100644 web/app.js delete mode 100644 web/index.html delete mode 100644 web/src/lib.rs delete mode 100644 web/style.css delete mode 100644 web/worker.js diff --git a/.gitattributes b/.gitattributes index 10d65d1..1ad8843 100644 --- a/.gitattributes +++ b/.gitattributes @@ -47,6 +47,3 @@ LICENSE text eol=lf *.gz binary *.xz binary *.dat binary - -# wasm-pack output is generated. -web/pkg/** linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 2bddcf0..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Bug report -description: Report a file that fails to unpack, unpacks incorrectly, or crashes senbei -title: "[Bug]: " -labels: [bug] -body: - - type: markdown - attributes: - value: | - Thanks for the report. Attaching the protected input file helps us - better diagnose the issue. Please only attach files you are - authorized to share. - - type: textarea - id: symptom - attributes: - label: What happened? - description: The command you ran and the output/error you got. Use -v/--verbose and paste the stage log if unpacking failed midway. - placeholder: "senbei app.exe -> error: ..." - validations: - required: true - - type: textarea - id: expected - attributes: - label: What did you expect? - placeholder: "A runnable unpacked image at unpack\\app.unpack.exe" - validations: - required: true - - type: input - id: version - attributes: - label: Senbei version - description: Output of `senbei -V` - validations: - required: true - - type: textarea - id: fileinfo - attributes: - label: About the input file - description: | - Without attaching it: is it an EXE or DLL? 32-bit or 64-bit? Managed - (.NET) or native? Roughly how large? Does it have a `._` companion - file next to it? - - type: checkboxes - id: legal - attributes: - label: Confirmation - options: - - label: I am authorized to analyze and share the attached file(s), and I understand attachments are removed after investigation. - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 3ba13e0..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 613aacb..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Feature request -description: Suggest an improvement or support for a new layout -title: "[Feature]: " -labels: [enhancement] -body: - - type: markdown - attributes: - value: | - For a new Crackproof layout, attaching a sample protected file helps - us better understand the request. Please only attach files you are - authorized to share. - - type: textarea - id: idea - attributes: - label: What would you like? - description: The problem it solves for you, and any layout details you can share. - validations: - required: true - - type: checkboxes - id: legal - attributes: - label: Confirmation - options: - - label: This request is for lawful research/interoperability use, and I am authorized to share any file I attach. - required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 65d0959..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -env: - CARGO_TERM_COLOR: always - -jobs: - fmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: cargo fmt --all -- --check - - clippy: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - run: cargo clippy --all-targets -- -D warnings - - test: - # The test suite exercises Windows path semantics, so it runs on Windows. - # The golden corpus (samples/) is user-managed and absent on CI; the - # samples test is a no-op pass there by design. - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - run: cargo test --release - - check-portable: - # Build-only portability gate: non-Windows host and the wasm target the - # web build uses. Clippy runs here too, not just on Windows: the platform - # `cfg` branches (the POSIX `localtime_r` path, the non-Windows stubs) are - # invisible to the Windows clippy job, so without this they are never - # linted at all. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: cargo clippy --all-targets -- -D warnings - - run: cargo check --target wasm32-unknown-unknown - - cli: - strategy: - matrix: - include: - - os: windows-latest - target: x86_64-pc-windows-msvc - bin: senbei.exe - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bin: senbei - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - run: cargo build --release --locked - - name: Package senbei-- - shell: bash - run: | - set -euo pipefail - version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) - name="senbei-$version-${{ matrix.target }}" - mkdir -p "stage/$name" - cp "target/release/${{ matrix.bin }}" "stage/$name/" - cp LICENSE "stage/$name/" - awk '/^## Legal notice and intended use/{f=1} f && /^## / && !/Legal notice/{exit} f' \ - README.md > "stage/$name/LEGAL-NOTICE.md" - echo "package=$name" >> "$GITHUB_ENV" - # upload-artifact always wraps the upload in its own zip, so the staged - # directory is uploaded loose — pre-compressing here would nest archives. - # The download is already .zip with the folder inside. - - uses: actions/upload-artifact@v4 - with: - name: ${{ env.package }} - path: stage/ - retention-days: 14 - - web: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: cargo install wasm-pack --locked - # `-- --locked` forwards to cargo: web/Cargo.lock is committed on - # purpose, so the wasm build must be pinned by it rather than silently - # re-resolving (which is how it drifted out of sync with the manifest). - - run: wasm-pack build --target web --release -- --locked - working-directory: web - - uses: actions/upload-artifact@v4 - with: - name: senbei-web - path: | - web/index.html - web/app.js - web/worker.js - web/style.css - web/pkg/ - retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index ce1c71c..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Release - -# Publishing a GitHub release: -# - cli-assets builds the Windows and Linux CLI binaries and attaches -# senbei--.zip (binary + LICENSE + the legal -# notice extracted from README.md) to the release. -# - deploy-web rebuilds the browser app and pushes it to Cloudflare Pages -# (https://senbei.pages.dev). Requires two repository secrets: -# CLOUDFLARE_API_TOKEN (Pages:Edit) and CLOUDFLARE_ACCOUNT_ID. The Pages -# project is `senbei`; direct-upload projects default to `main` as the -# production branch, which `--branch=main` targets. - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - cli-assets: - permissions: - contents: write # attach assets to the release - strategy: - matrix: - include: - - os: windows-latest - target: x86_64-pc-windows-msvc - bin: senbei.exe - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bin: senbei - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - run: cargo build --release --locked - - name: Package senbei-- - shell: bash - run: | - set -euo pipefail - version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) - name="senbei-$version-${{ matrix.target }}" - mkdir -p "stage/$name" - cp "target/release/${{ matrix.bin }}" "stage/$name/" - cp LICENSE "stage/$name/" - awk '/^## Legal notice and intended use/{f=1} f && /^## / && !/Legal notice/{exit} f' \ - README.md > "stage/$name/LEGAL-NOTICE.md" - # Release assets are served as-is (unlike CI artifacts, which the - # upload action always re-zips), so the archive is created here. - # Zip on every OS, matching the CI artifacts; 7z is preinstalled on - # both windows-latest and ubuntu-latest. - (cd stage && 7z a "$name.zip" "$name" > /dev/null) - echo "package=$name" >> "$GITHUB_ENV" - - name: Attach to release - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${{ github.event.release.tag_name }}" "stage/${{ env.package }}".* --clobber - - deploy-web: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: cargo install wasm-pack --locked - # `-- --locked` forwards to cargo: web/Cargo.lock is committed on - # purpose, so the wasm build must be pinned by it rather than silently - # re-resolving (which is how it drifted out of sync with the manifest). - - run: wasm-pack build --target web --release -- --locked - working-directory: web - - name: Stage static site - run: | - mkdir dist - cp web/index.html web/app.js web/worker.js web/style.css dist/ - cp -r web/pkg dist/pkg - - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy dist --project-name=senbei --branch=main diff --git a/.gitignore b/.gitignore index 7285ac2..24a516a 100644 --- a/.gitignore +++ b/.gitignore @@ -118,7 +118,7 @@ debug/ target/ # Cargo.lock is committed on purpose: senbei is a binary, and CI builds with -# --locked (web/Cargo.lock likewise, for the wasm build). +# --locked. # These are backup files generated by rustfmt **/*.rs.bk @@ -151,8 +151,3 @@ target/ # descend into it to find the re-included README). See senbei/samples/README.md. /samples/* !/samples/README.md - -### senbei web build ### -/web/pkg/ -/web/target/ -/web/.playwright-cli \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 55d7781..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,79 +0,0 @@ -# AGENTS.md - -Guidance for AI coding agents (and human contributors) working in this repo. - -## Project - -Senbei is a static unpacker for Crackproof-protected PE files: a pure, -panic-free, no-I/O unpacker core (`src/unpacker/`) plus a thin CLI shell -(`src/`), an il2cpp metadata de-obfuscator (`src/metadata.rs`), and a -WebAssembly browser frontend (`web/`). Read `docs/design.md` first. - -## Commands - -```cmd -cargo build --release :: CLI -cargo test --release :: full suite (golden corpus: samples/, git-ignored) -cargo clippy --all-targets -- -D warnings -cargo fmt --all -cd web && wasm-pack build --target web --release :: browser build -``` - -The `samples/` corpus is user-managed and absent on CI; without it the -samples test is a no-op pass. `SENBEI_REQUIRE_SAMPLES=1` makes an absent -corpus fail (use this on a private CI that *does* have the corpus). Do not -delete `samples/` with `rm -rf` — it may be a junction; use git -worktree-aware cleanup. - -## Hard rules - -- **The unpacker core stays pure**: no file I/O, no `unsafe`, no panics across - the public boundary, no platform-specific code. It must keep compiling to - `wasm32-unknown-unknown` (`cargo check --target wasm32-unknown-unknown`). -- **`catch_unwind` does not work on wasm** (the prebuilt std can't unwind; a - caught panic becomes a fatal `unreachable` trap). Native code may rely on - `catch_unpack`, but any routing decision must also work without a catchable - panic: spliced companion inputs route straight to the EXE pipeline, and the - web app isolates every unpack in a disposable Web Worker, retrying trapped - DLLs with `job::unpack_bytes_force_exe`. Never make correctness on wasm - depend on catching a panic. -- **Byte-identical output is the contract.** Any pipeline change must re-run - the full golden corpus; a byte mismatch on any golden is a regression. -- **Trial-and-validate, never trust a heuristic.** A silently wrong offset - produces a silently broken binary — worse than an error. Every layout - candidate must be validated (checksum / structural oracle) with fall-through - to the next candidate. -- **Determinism under parallelism.** Block fan-out must stay byte-identical - regardless of thread count (`SENBEI_THREADS=1` is the sequential reference). -- **Folder scanning: deny-list, never allow-list.** Targets are recognised by - content, not extension, and can carry arbitrary names — there is no closed - set of target extensions an allow-list could enumerate. Only known - bulk-asset formats are excluded. -- **No binaries in the repo** — not as fixtures, not in commits. The only - corpus is the local git-ignored `samples/`. (Issue attachments of - protected inputs are fine when the user is authorized to share them, but - never commit them.) - -## Public-repo hygiene (important) - -This is a public research repository. In code comments, docs, tests, and -commit messages: - -- **Never name specific games, publishers, or product codenames.** Refer to - build families generically ("older EXE-64 builds", "the marker-less - layout", "external-companion builds"). Keep offsets/numbers — drop names. -- **Never name specific protected filenames** from real distributions. Test - fixtures use generic names (`app.exe`, `managed.dll`, `daemon.exe`). - Exceptions (platform-standard technology names, allowed): `il2cpp`, - `Unity`, `global-metadata.dat`, the Crackproof magic `KONN`. -- **Never reference other tools, projects, implementations, or paths outside - this repo.** Describe behavior and layout directly; do not mention prior - art, porting, or where any algorithm came from. - -## Conventions - -- Comments explain *why* (layout rationale, observed variants, failure modes), - not *what*. -- Rust 2024 edition; clippy-clean at `-D warnings`; rustfmt default style. -- CLI behavior (flags, exit codes, output naming) is documented in - `docs/usage.md` — update the doc when changing behavior. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8fb6378..cd7546b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,19 +208,46 @@ dependencies = [ ] [[package]] -name = "senbei" +name = "senbei-cli" +version = "1.0.0" +dependencies = [ + "senbei-io", +] + +[[package]] +name = "senbei-crypto" +version = "1.0.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "senbei-io" version = "1.0.0" dependencies = [ "anyhow", "indicatif", "libc", "owo-colors", + "senbei-metadata", + "senbei-pe", "tempfile", - "thiserror", "walkdir", "windows", ] +[[package]] +name = "senbei-metadata" +version = "1.0.0" + +[[package]] +name = "senbei-pe" +version = "1.0.0" +dependencies = [ + "senbei-crypto", + "thiserror", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index eba1196..fcef7df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,39 +1,36 @@ -[package] -name = "senbei" +[workspace] +members = [ + "senbei-cli", + "senbei-crypto", + "senbei-io", + "senbei-metadata", + "senbei-pe", +] +default-members = ["senbei-cli"] +resolver = "2" + +[workspace.package] version = "1.0.0" edition = "2024" -description = "Static unpacker for Crackproof-protected PE files" license = "AGPL-3.0-only" -keywords = ["unpacker", "reverse-engineering", "pe", "security-research"] -categories = ["command-line-utilities"] -[lib] -name = "senbei" -path = "src/lib.rs" - -[[bin]] -name = "senbei" -path = "src/main.rs" - -[dependencies] +[workspace.dependencies] anyhow = "1" +indicatif = "0.18" +libc = "0.2" +owo-colors = "4" +tempfile = "3" thiserror = "2" walkdir = "2" -indicatif = "0.18" -owo-colors = "4" - -[target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_System_Console", "Win32_System_SystemInformation", ] } - -[target.'cfg(all(not(windows), not(target_arch = "wasm32")))'.dependencies] -libc = "0.2" - -[dev-dependencies] -tempfile = "3" +senbei-crypto = { path = "senbei-crypto" } +senbei-io = { path = "senbei-io" } +senbei-metadata = { path = "senbei-metadata" } +senbei-pe = { path = "senbei-pe" } [profile.release] opt-level = 3 diff --git a/README.md b/README.md deleted file mode 100644 index 3c2215b..0000000 --- a/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Senbei - -A static unpacker for Crackproof-protected 64-bit and 32-bit PE files. Point it -at a file or a folder and it writes decrypted copies — no launch of the -protected program, no kernel driver, no code runs out of the protected binary. - -> _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._ - -Senbei reads a protected `.exe` or `.dll`, replays the unpacking algorithm -entirely in memory, and writes the recovered image to a new file. The core is a -pure, panic-free library with no file I/O; the CLI wraps it with scanning, a -progress bar, and a run log. A browser version (WebAssembly, fully client-side) -lives in [`web/`](web/). - -## Legal notice and intended use - -**Read this before using Senbei.** - -- Senbei is a research and interoperability tool. It exists to enable lawful - reverse engineering, security research, preservation, and interoperability - with software you already legitimately possess. -- **Only process binaries you own or are explicitly authorized to analyze.** - Depending on your jurisdiction and license agreements, circumventing - technological protection measures may be restricted (for example under - DMCA §1201 in the United States, which contains exemptions for security - research and interoperability). It is your responsibility to ensure your use - is lawful. -- Senbei does not bypass any access control for you: it performs a purely - static transformation of a file already on your disk. It derives everything - it needs from the input file itself, contains no vendor code or secrets, and - distributes no keys, cracks, or copyrighted content. -- Senbei does not enable online play, license fraud, or cheating, and must not - be used to redistribute decrypted binaries. Do not upload outputs anywhere. -- The authors provide this software "as is", without warranty of any kind, and - accept no liability for misuse. See [LICENSE](LICENSE) (AGPL-3.0). -- "Crackproof" is a trademark of its respective owner; this project is not - affiliated with or endorsed by the protection vendor or any software - publisher. Names are used for identification only. - -## What it handles - -| Kind | Description | -| --- | --- | -| `Exe` | Crackproof-protected executable (PE32+ and PE32). | -| `NativeDll` | Protected native (unmanaged) DLL. | -| `ManagedDll` | Protected .NET assembly (has a CLR data directory). | -| `._` companion | Stub + external encrypted payload layout, spliced automatically. | -| `global-metadata.dat` | il2cpp metadata with obfuscated method tokens, de-obfuscated in place. | - -Detection is content-based (header key-table at offset 4096, magic `KONN`), -not extension-based. Anything unrecognized is left untouched. - -## Quick start - -```cmd -cargo build --release - -senbei protected.exe -:: -> unpack\protected.unpack.exe - -senbei "C:\Games\MyGame" -:: -> C:\Games\MyGame\unpack\... (recursive, skips non-targets) -``` - -Every output is sanity-checked statically; structurally broken results are -flagged as suspect rather than silently trusted. - -## Documentation - -- [Usage reference](docs/usage.md) — CLI flags, exit codes, integrity check -- [Design](docs/design.md) — architecture, routing, and error model -- [Development](docs/development.md) — building, testing, environment variables -- [Web version](web/README.md) — run Senbei in a browser - -## License - -[GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0-only). diff --git a/docs/design.md b/docs/design.md deleted file mode 100644 index 653f34d..0000000 --- a/docs/design.md +++ /dev/null @@ -1,154 +0,0 @@ -# Design - -Senbei is a fully static unpacker: it replays the unpacking algorithm on the -file bytes in memory and writes the recovered PE image. No code from the -protected binary is ever executed, no process is launched or attached to, and -no driver or proxy DLL is involved. - -## Crate layout - -The crate is split into a pure core and a thin CLI shell: - -- **`src/unpacker/`** — the core. Pure functions over byte slices: no file - I/O, no environment access (beyond a few debugging overrides, see - [development.md](development.md)), panic-free at the public boundary (all - internal panics are trapped and converted to `UnpackError::InternalPanic`). This - is what the WebAssembly build embeds. -- **`src/` (top level)** — the CLI shell: argument parsing, recursive folder - scanning, per-run log file, progress bar, Explorer-friendly exit pause, and - the single-file/folder orchestration in `job.rs`. -- **`src/metadata.rs`** — il2cpp `global-metadata.dat` method-token - de-obfuscation (format version 31; other versions are left untouched). - -``` -src/ -├── main.rs argument parsing + dispatch -├── lib.rs module roots -├── job.rs single-file + folder orchestration, out-naming, -│ companion splice, stub overlay/TLS restore, -│ pipeline routing (incl. the wasm-safe byte API) -├── scan.rs recursive Crackproof + metadata discovery -├── metadata.rs il2cpp global-metadata.dat de-obfuscation -├── logfile.rs per-run timestamped log -├── ui.rs progress bar + status lines -├── pause.rs Explorer-friendly exit pause -└── unpacker/ pure, panic-free, no-I/O core - ├── mod.rs detection + unpack_auto dispatch - ├── exe.rs EXE pipeline (PE32+ and PE32) - ├── dll.rs native + managed DLL pipeline - ├── integrity.rs static post-unpack sanity check - ├── primitives.rs decrypt_data* steps, key/shift selection - ├── bytecode.rs bytecode VM - ├── parallel.rs deterministic block-parallel fan-out - ├── tables.rs constant tables - └── crc32.rs checksum -``` - -## Detection and routing - -Detection is content-based (`unpacker::detect`), never extension-based: the -key table is derived from the file header and checked against the format -magic, then the PE characteristics classify the input as EXE, native DLL, or -managed DLL. - -`unpack_auto` then dispatches: - -- `Exe` → the EXE pipeline (handles both PE32+ and PE32). -- `NativeDll` / `ManagedDll` → the DLL pipeline first; on failure, the EXE - pipeline as a fallback. Two DLL layouts exist in the wild: an older layout - the DLL pipeline parses, and a newer one that protects DLLs with the - EXE-style shell layout instead. The DLL-first order keeps old-layout outputs - byte-identical (the EXE pipeline also "succeeds" on old-layout DLLs but - produces different bytes); the fallback handles the new layout (including - the managed-DLL .NET metadata restore). - -One routing shortcut bypasses `unpack_auto`: inputs spliced from an external -companion (`job.rs`, both the CLI and the wasm byte API) go **straight to the -EXE pipeline**. The companion layout is definitionally the EXE-style shell, -so the DLL probe can never be right for it — and the probe's rejection of -EXE-shell DLLs relies on a caught panic, which is a fatal trap on targets -without unwinding (WebAssembly). Output bytes are identical to the -probe-then-fallback route. - -## External-companion inputs - -Some builds split a protected module into an on-disk loader stub plus an -encrypted `._` companion. When a `._` sibling matches the stub's header -region, `job.rs` splices the two before unpacking and afterwards overlays the -export table and TLS directory from the stub — pieces the encrypted companion -does not carry. All overlay steps are best-effort no-ops when their inputs -can't be mapped, so a malformed stub can never corrupt an otherwise-good -unpack. - -## Pipelines - -Both pipelines are **heuristic with trial-and-validate**: where a layout -leaves ambiguity (e.g. which block is the real file decryptor, or a page-XOR -shift), the pipeline tries candidates and validates the result structurally -(an entry-stub oracle, checksum stamps, cluster stamps) instead of trusting -the first match. A validation failure falls through to the next candidate -rather than producing silently wrong output. - -Several protected stages are themselves little bytecode programs. The core -includes a small VM (`bytecode.rs`) that generates and interprets those -programs rather than hardcoding each variant's constants. - -The PE32+ configuration block has two observed anchor-relative alignments. -Senbei selects between them by validating the stage1 `(RVA, length)` -descriptor against the image, rather than relying on a version-like word whose -value is not stable across build families. Stage3 seed advancement also varies: -the usual four-round result is tried first, then bounded alternatives are -replayed from the untouched ciphertext and accepted only when decompression -writes the exact target size and the recovered stage has its expected function -tail structure. - -## Integrity check - -Every produced image passes through `integrity::check` — a static, execution- -free sanity check that only flags defects impossible in a correctly unpacked -image (malformed headers, unmapped/non-executable/all-zero/all-int3 entry -point, a native DLL with no base-relocation directory, any import descriptor -whose DLL name is still ciphertext, a managed image whose COR20 header or BSJB -metadata did not survive). See [usage.md](usage.md#integrity-check). -A clean report is not a proof of correctness; a non-clean report is a reliable -"broken" signal. - -## Parallelism - -Section decrypt/decompress blocks write disjoint output spans and read only -immutable input plus snapshotted key tables, so `parallel.rs` fans them out -across worker threads with **byte-identical** output regardless of thread -count. There is no `unsafe`: the buffer is carved with safe `split_at_mut` -chains so the borrow checker proves spans never alias. Overlapping spans (only -possible on corrupt input) degrade to the sequential whole-buffer pass, -preserving the deterministic last-writer-wins behavior of the serial -pipeline. `SENBEI_THREADS=1` forces the sequential path; on targets without -threads (WebAssembly) the sequential path is used automatically. - -## Error model - -The public API never panics: every pipeline runs under a `catch_unwind` -wrapper (`catch_unpack`) that converts a trapped panic to -`UnpackError::InternalPanic`, including the Rust source location and panic -payload. The capture context is propagated into section worker threads; panics -outside an active unpack continue through the previously installed panic hook. -Expected validation failures use structured variants carrying the failed stage, -block index, table kind, or invalid range instead of collapsing unrelated causes -into a generic corruption error. Huffman/LZ failures distinguish invalid code -lengths, tree traversal, pending-length overflow, invalid back-references, -output overflow, and size mismatch. When both DLL parsing and the EXE-layout -fallback fail, the returned error retains both pipeline errors. -Size requests are bounds-checked against a 1 GiB `MAX_IMAGE_SIZE` before -allocation so a crafted header cannot abort the process with a huge -allocation. In folder mode each file is isolated: one file's failure is logged -and counted, never fatal to the run. - -**WebAssembly caveat:** the prebuilt wasm std cannot unwind, so a caught -panic becomes a fatal `unreachable` trap there. The DLL-routing probe relies -on this mechanism to reject EXE-shell-layout DLLs, so the web build routes -around it instead of through it: spliced companion inputs skip the probe -entirely (see "Detection and routing"), and the web app isolates every unpack -in a disposable Web Worker — a trapped DLL is retried once in a fresh worker -with the forced-EXE pipeline (`job::unpack_bytes_force_exe`), reproducing the -probe-then-fallback outcome without a catchable panic. A trap on any other -input is reported as a clean error rather than freezing the page. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 0dbaad8..0000000 --- a/docs/development.md +++ /dev/null @@ -1,116 +0,0 @@ -# Development - -## Building - -Requires a Rust toolchain (MSVC backend is the default on Windows; -`rustup-init.exe` from installs it). The pinned toolchain -and targets are in `rust-toolchain.toml`. - -```cmd -cargo build --release -``` - -Output: `target\release\senbei.exe`. The binary is self-contained — no driver, -no proxy DLL, no external assets. - -The library and CLI also build for Linux/macOS (`cfg`-gated platform code -only) and for `wasm32-unknown-unknown` (see the [web version](../web/README.md)). - -## Testing - -```cmd -cargo test --release -``` - -The suite covers CLI behavior, detection, the folder driver, the run log, and -byte-exact golden tests over `samples/` — a user-managed corpus (git-ignored, -see `samples/README.md`) of real Crackproof inputs plus `.golden.` -reference outputs. Every input goes through `job::unpack_bytes` — the same -routing the CLI uses, so an `._` companion in the corpus is spliced and -the stub export/TLS overlays run — and is gated on **two** checks: the static -integrity check (catches runtime-broken outputs even when a stale golden would -still byte-match) and, when a golden exists, a bit-for-bit comparison. il2cpp -`*.dat` inputs are routed through `metadata::deobfuscate` instead. An empty or -absent corpus is a no-op pass; set `SENBEI_REQUIRE_SAMPLES` to make it fail -instead (useful on a private CI that has the corpus — public CI never does, -since binaries are not committed). - -> **Note:** goldens encode expected *bytes*, not runtime behavior. A golden -> produced before a pipeline fix may byte-match while still being wrong — the -> integrity check is the second gate for exactly this reason. Re-verify -> goldens against real runs when touching the affected pipeline stages. -> -> **The corpus only protects what it contains.** Wire the test to the routing -> the CLI actually takes (it is), and keep a sample for every layout family — -> marker-based, marker-less, external-companion, PE32, PE32+, native, managed, -> metadata. An unrepresented family has no regression gate at all, which is -> how a "re-run the golden corpus" rule can pass while silently covering -> nothing. - -## Debugging levers (environment variables) - -- `DD8_SHIFT` — override the `decrypt_data8` page-XOR shift (`99` skips dd8 - entirely). -- `SEL_DIAG` — print the dd8 selector's scores: the per-shift `0xCC` counts and - the plaintext baseline they are compared against (PE32+), and the per-formula - counts, baseline and net gain (PE32). -- `SENBEI_THREADS` — cap the block-parallel fan-out (`1` forces the fully - sequential path). -- `SENBEI_SCAN_ALL` — same as `--scan-all` (probe every file in a folder). - -## Conventions - -- The `src/unpacker/` core is pure: no file I/O, no panics across the public - boundary, no `unsafe`. Keep it that way — it is what the WebAssembly build - embeds. -- Layout heuristics must **trial-and-validate**: never pick a candidate offset - on shape alone and trust it; validate by decryption/checksum and fall - through to the next candidate on failure. A silent wrong offset produces a - silently broken output, which is worse than an error. -- Output must remain byte-identical against the golden corpus for every - supported layout. When fixing one build family, re-run the full golden - corpus to prove no other family regressed. -- Folder scanning uses a size floor plus an extension **deny**-list, never an - allow-list: targets are recognised by content, not extension, and can carry - arbitrary names, so only known bulk-asset extensions are excluded. The - pre-filter exists because folder-scan cost is per-file I/O latency, not the - walk — probe fewer files, don't parallelize the probe loop. -- `cargo fmt` and `cargo clippy` must stay clean (CI enforces both). - -## Repository layout - -``` -senbei/ -├── Cargo.toml senbei lib + bin package -├── rust-toolchain.toml pinned toolchain + targets -├── src/ CLI shell + pure unpacker core (see docs/design.md) -├── tests/ CLI, detection, golden, and folder tests -├── samples/ local-only test corpus (git-ignored) -├── web/ WebAssembly browser build -├── docs/ usage, design, and development documentation -└── .github/ CI workflows and issue templates -``` - -## Web build - -See [web/README.md](../web/README.md). In short: - -```cmd -cd web -wasm-pack build --target web --release -``` - -then serve `web/` statically and open `index.html`. Everything runs -client-side; no file leaves the browser. - -## Contributing - -Issues and pull requests are welcome. A few ground rules: - -- **Never commit binaries** (protected or decrypted) to the repository — - the only corpus is the local git-ignored `samples/`. Attaching a protected - input file to an issue is welcome if it helps diagnose the problem; only - attach files you are authorized to share. -- Run `cargo test --release`, `cargo clippy`, and `cargo fmt` before - submitting. -- Keep the unpacker core free of I/O, `unsafe`, and platform-specific code. diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index 9bfcc3f..0000000 --- a/docs/usage.md +++ /dev/null @@ -1,126 +0,0 @@ -# Usage - -``` -senbei [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] - [--no-log] [--no-pause] [-V|--version] [-h|--help] -``` - -Real runs print `Senbei ` once at start. Use `-V` / `--version` to -print the version and exit. - -## Single file - -The decrypted image is written under `/unpack/` with `.unpack` inserted -before the extension. A `senbei-.log` is written in the same -directory. With `--out DIR`, both the output and the log go into `DIR` instead: - -```cmd -senbei app.exe -:: -> unpack\app.unpack.exe -:: -> unpack\senbei-YYYYMMDD-HHMMSS.log - -senbei app.exe --out C:\out -:: -> C:\out\app.unpack.exe -:: -> C:\out\senbei-YYYYMMDD-HHMMSS.log -``` - -Pointing senbei directly at an il2cpp `global-metadata.dat` rewrites its -obfuscated method tokens back to the contiguous per-module range il2cpp -expects; the output is `global-metadata.unpack.dat`, written only when tokens -actually changed. Only metadata format version 31 is rewritten; other versions -are reported and left untouched. - -## Folder mode - -Senbei walks the directory recursively, skips any subdirectory literally named -`unpack`, and unpacks every file it recognises as Crackproof-protected (by -content, not extension — renamed files and `.bak` backups are still found). -Results land under `/unpack/` (or `--out DIR`), mirroring the input -tree's relative paths. The run log is written **in that same out directory**: - -```cmd -senbei "C:\Games\MyGame" -:: -> C:\Games\MyGame\unpack\... -:: -> C:\Games\MyGame\unpack\senbei-YYYYMMDD-HHMMSS.log -``` - -Folder mode also picks up `global-metadata.dat` files and external-companion -`._` payloads: a module whose `._` sibling matches its header region is -spliced with the companion automatically (no flag needed) and unpacked as one -image, with the output named for the stub. - -Each file is processed in isolation: an error or panic on one file is caught, -counted, and logged, and the run continues. Folder mode finishes with a summary -line, then duration: - -``` -12 unpacked · 3 skipped · 0 errors · 1 suspect · 2 metadata -done in 1234 ms -``` - -## Integrity check - -A successful unpack is not always a runnable one: a layout heuristic can pick -the wrong offset and leave the entry-point stub or import strings encrypted, so -the pipeline reports success but the OS loader faults at runtime (typically -`0xC0000005`, STATUS_ACCESS_VIOLATION). To catch this, senbei runs a static -sanity check over every output it produces — inspecting the bytes alone, with -no reference image and no execution. - -It flags only defects that cannot occur in a correctly unpacked image: - -- malformed DOS/PE headers, bad optional-header magic, implausible section - count, zero `SizeOfImage`, or section raw-data ranges that run past EOF; -- an entry point that doesn't map into a section, isn't in an executable - section, or whose stub is all zeros or all `0xCC` int3 padding (the classic - left-encrypted symptom); -- a native (unmanaged) DLL with no base-relocation directory — it cannot - survive being mapped at a non-preferred base; -- **any** import descriptor whose DLL name doesn't resolve or isn't readable - ASCII (imports left encrypted) — the whole table is walked, not just the - first entry; -- for a managed assembly, a COR20 header whose `cb` isn't `0x48` or a - MetaData stream missing its `BSJB` signature (the CLR would reject the - image outright). - -The entry-point and import checks are skipped for managed assemblies, whose -native EP and import stub are legitimately not what the native loader expects. - -The check is deliberately conservative: a clean report is **not** a proof of -correctness, but a non-clean report is a reliable "this is broken" signal. A -suspect file is still written (the bytes are the best available) and flagged — -single-file mode prints a warning to stderr, folder mode prints a yellow `!` -line, adds a `SUSPECT` entry to the run log, and counts it in the summary's -`suspect` total (which is additive to `unpacked`). - -## Flags - -| Flag | Behavior | -| --- | --- | -| `--out DIR` | Write outputs (and the log, unless `--no-log`) under `DIR`. | -| `-v`, `--verbose` | Print detailed `[N/9]` per-stage unpack progress (and the destination path) for each file. In folder mode this replaces the progress bar. | -| `-q`, `--quiet` | Once: hide progress bar and per-file lines; keep banner, summary, and duration. Twice (`-q -q`): suppress all stdio (exit code only). | -| `--no-log` | Do not write `senbei-*.log`. Console output is unchanged by this flag alone. | -| `--scan-all` | Probe every file in a folder, including ones the scan pre-filter skips (under 4128 bytes, or a bulk-asset extension like `.ab`/`.xml`/`.acb`). Much slower on large game trees; finds the same targets in practice. | -| `--no-pause` | Skip the "Press Enter to exit" prompt (for scripted runs). | -| `-V`, `--version` | Print `Senbei ` and exit. | -| `-h`, `--help` | Show usage. | - -On Windows, when launched from Explorer (the process owns its console) senbei -pauses for Enter before exiting so the window doesn't vanish. `--no-pause` -disables this; it has no effect when stdout is piped or run from another -process. - -## Exit codes - -| Code | Meaning | -| --- | --- | -| `0` | Success (single file unpacked, or folder run with no errors). | -| `1` | At least one file failed, a scan probe was unreadable, or a single-file unpack errored. | -| `2` | Usage error: no path given, unknown option, missing `--out` value, or multiple input paths (help printed). | - -A folder run also fails with `1` when parts of the tree could not be scanned -(unreadable directory entries or files that failed the content probe) — those -are potential missed targets, not clean skips. An il2cpp metadata blob whose -format version senbei does not handle is *not* an error: it is reported, left -untouched, and counted as skipped. diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index f45a9a0..0000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "stable" -targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"] diff --git a/samples/README.md b/samples/README.md deleted file mode 100644 index 2a5761d..0000000 --- a/samples/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# senbei/samples - -Drop-in corpus for the `samples` integration test (`tests/samples.rs`). - -This folder is **git-ignored** (only this `README.md` is tracked), so it holds -whatever Crackproof binaries happen to be on your machine. Nothing here is -committed. - -## What to put here - -Place protected inputs in this folder, either directly or grouped in -subdirectories: - -- `*.exe` — Crackproof-protected executables (PE32 or PE32+) -- `*.dll` — Crackproof-protected DLLs (native or managed) -- `*.dat` — il2cpp `global-metadata.dat` blobs (method-token de-obfuscation) - -For an **external-companion** module, copy the `._` payload in as well, -keeping the exact `._` suffix on the full file name. The test splices it the -same way the CLI does; without it the loader stub alone is meaningless and the -splice / export-overlay / TLS-restore code is never exercised. - -The corpus scan is recursive and skips directories named `unpack`, matching the -CLI's output-directory rule. - -Optionally, place a **golden** next to each input — the known-good unpacked -output, named `.golden.`: - -``` -samples/ - app.exe <- input - app.golden.exe <- golden (optional) - managed.dll <- input - managed.golden.dll <- golden (optional) - stub.dll <- input (external-companion layout) - stub.dll._ <- its encrypted payload (NOT an input itself) - stub.golden.dll <- golden - global-metadata.dat <- input - global-metadata.golden.dat<- golden - mystery.exe <- input, no golden -``` - -The type (EXE vs native/managed DLL vs metadata) is auto-detected from the file -contents, not the extension, so you don't need to classify anything by hand. - -Since the corpus is the only regression gate on byte-identical output, keep it -broad: each build family, each layout (marker-based and marker-less), and at -least one external-companion pair. A family with no sample here is a family no -test protects. - -## How the test treats each input - -Run with: - -``` -cargo test --release --test samples -``` - -For every input file, the test runs the same routing the CLI uses -(`job::unpack_bytes`, so companions splice and the stub overlays run) — or -`metadata::deobfuscate` for an il2cpp blob — and then: - -| Situation | Result | -| ------------------------------------------- | ------------------------------- | -| Golden present, bytes **identical** | **pass** | -| Golden present, bytes **differ** | **fail** (test fails) | -| **No golden** found | **warning** (needs manual check)| -| Unpack errored / file unreadable | **fail** | - -Warnings are printed but do not fail the test — they flag outputs you should -eyeball or promote to a golden once verified. Failures fail the test. An empty -or absent folder is a no-op pass. - -To see the per-file warning/pass/fail summary, run with output shown: - -``` -cargo test --release --test samples -- --nocapture -``` - -## Naming rules - -- An **input** is any `*.exe` / `*.dll` / `*.dat` whose name does **not** - contain the `.golden.` segment. -- A **golden** is `.golden.` sitting next to its input. Files with - `.golden.` in the name are never treated as inputs. -- A **companion** is `._` (e.g. `stub.dll._` for `stub.dll`). - Its extension is `_`, so it is never picked up as an input of its own; it is - read only when its base module is processed. diff --git a/senbei-cli/Cargo.toml b/senbei-cli/Cargo.toml new file mode 100644 index 0000000..f1beb2b --- /dev/null +++ b/senbei-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "senbei-cli" +version.workspace = true +edition.workspace = true +description = "Command-line entry point for Senbei" +license.workspace = true +keywords = ["unpacker", "reverse-engineering", "pe", "security-research"] +categories = ["command-line-utilities"] + +[[bin]] +name = "senbei" +path = "src/main.rs" + +[dependencies] +senbei-io.workspace = true diff --git a/src/main.rs b/senbei-cli/src/main.rs similarity index 80% rename from src/main.rs rename to senbei-cli/src/main.rs index da93dcc..f861f10 100644 --- a/src/main.rs +++ b/senbei-cli/src/main.rs @@ -1,4 +1,4 @@ -use senbei::{job, pause}; +use senbei_io::{job, pause, scan}; use std::path::Path; fn main() -> std::process::ExitCode { @@ -27,9 +27,6 @@ fn main() -> std::process::ExitCode { "--no-log" => no_log = true, "--scan-all" => scan_all = true, "--out" => match args.next() { - // Reject a missing value (and a following flag swallowed as the - // value): previously `--out` at end of argv silently fell back - // to the default output directory. Some(v) if !v.starts_with('-') => out = Some(v), _ => { eprintln!("error: --out requires a directory argument"); @@ -42,7 +39,6 @@ fn main() -> std::process::ExitCode { return std::process::ExitCode::from(2); } other => { - // Previously the last positional silently won. if let Some(prev) = &path { eprintln!("error: multiple input paths given ('{prev}' and '{other}')"); return std::process::ExitCode::from(2); @@ -63,33 +59,36 @@ fn main() -> std::process::ExitCode { } let p = Path::new(&p); let out_path = out.as_deref().map(Path::new); - let r = if p.is_dir() { + let result = if p.is_dir() { job::run_folder_opts( p, out_path, quiet, verbose, no_log, - scan_all || senbei::scan::scan_all_env(), + scan_all || scan::scan_all_env(), ) } else { job::run_file_v(p, out_path, quiet, verbose, no_log) }; - match r { - Ok(s) => { + match result { + Ok(summary) => { if quiet < 2 { println!( "{} unpacked · {} skipped · {} errors · {} suspect · {} metadata", - s.unpacked, s.skipped, s.errors, s.suspect, s.metadata + summary.unpacked, + summary.skipped, + summary.errors, + summary.suspect, + summary.metadata ); - println!("done in {} ms", s.duration_ms); + println!("done in {} ms", summary.duration_ms); } - if s.errors > 0 { 1 } else { 0 } + if summary.errors > 0 { 1 } else { 0 } } - Err(e) => { - // Fatal: out-dir/log create, etc. + Err(error) => { if quiet < 2 { - eprintln!("error: {e:#}"); + eprintln!("error: {error:#}"); } 1 } diff --git a/senbei-crypto/Cargo.toml b/senbei-crypto/Cargo.toml new file mode 100644 index 0000000..3ee7e4b --- /dev/null +++ b/senbei-crypto/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "senbei-crypto" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Cryptographic and compression primitives for Senbei" + +[dependencies] +thiserror.workspace = true diff --git a/src/unpacker/bytecode.rs b/senbei-crypto/src/bytecode.rs similarity index 100% rename from src/unpacker/bytecode.rs rename to senbei-crypto/src/bytecode.rs diff --git a/src/unpacker/crc32.rs b/senbei-crypto/src/crc32.rs similarity index 100% rename from src/unpacker/crc32.rs rename to senbei-crypto/src/crc32.rs diff --git a/senbei-crypto/src/lib.rs b/senbei-crypto/src/lib.rs new file mode 100644 index 0000000..2f99e7f --- /dev/null +++ b/senbei-crypto/src/lib.rs @@ -0,0 +1,77 @@ +//! Cryptographic, checksum, compression, and bytecode primitives. + +pub mod bytecode; +pub mod crc32; +pub mod primitives; +mod tables; + +/// Maximum buffer size accepted by allocation-sensitive transforms. +pub const MAX_IMAGE_SIZE: u64 = 1 << 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BufferOperation { + Read, + CopySource, + CopyDestination, + ZeroFill, +} + +impl std::fmt::Display for BufferOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Read => "read", + Self::CopySource => "copy source", + Self::CopyDestination => "copy destination", + Self::ZeroFill => "zero-fill", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error( + "{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})" + )] + BufferRangeOutOfBounds { + operation: BufferOperation, + offset: usize, + size: usize, + buffer_len: usize, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum DecompressionFailure { + #[error("compressed source size {size} exceeds limit {max}")] + SourceTooLarge { size: u32, max: u64 }, + #[error("Huffman code length {bits} is invalid")] + InvalidCodeLength { bits: u8 }, + #[error("Huffman tree traversal exceeded 64 levels")] + HuffmanTraversalLimit, + #[error("pending length accumulator overflowed at {pending}")] + PendingLengthOverflow { pending: u32 }, + #[error("output step {step} at byte {written} exceeds expected size {expected}")] + OutputOverflow { + written: u32, + step: u32, + expected: u32, + }, + #[error("run-fill width {width} reads before output offset 0x{destination:08X}")] + RunFillBeforeOutput { width: u32, destination: u32 }, + #[error("run-fill width {width} is unsupported")] + InvalidRunFillWidth { width: u32 }, + #[error("back-reference distance {distance} exceeds {written} written bytes")] + InvalidBackReference { distance: u32, written: u32 }, + #[error("Huffman symbol consumed no input and produced no output")] + NoProgress, + #[error( + "output size mismatch (wrote {written}/{expected} bytes after consuming {consumed}/{source_size})" + )] + OutputSizeMismatch { + written: u32, + expected: u32, + consumed: u32, + source_size: u32, + }, +} diff --git a/senbei-crypto/src/primitives.rs b/senbei-crypto/src/primitives.rs new file mode 100644 index 0000000..236f137 --- /dev/null +++ b/senbei-crypto/src/primitives.rs @@ -0,0 +1,1098 @@ +//! Shared crypto primitives and helper utilities. +//! +//! These functions form the low-level API used by the PE pipelines. +//! Each free function is self-contained: it takes the relevant byte buffer(s) +//! and parameters explicitly, with no coupling to the EXE `Unpacker` struct. + +use crate::bytecode::{Op, OpsLut}; +use crate::crc32; +use crate::tables::{COLUMMIX1, COLUMMIX2, COLUMMIX3, COLUMMIX4, SBOX}; +use std::cell::RefCell; + +thread_local! { + /// Reusable scratch for `decompress`. A single unpack runs `decompress` + /// hundreds of times over small blocks; reusing one growable buffer avoids a + /// fresh allocation each call. Thread-local, so it stays correct (one buffer + /// per worker) under the parallel block fan-out. + static DECOMPRESS_SCRATCH: RefCell> = const { RefCell::new(Vec::new()) }; +} + +// --------------------------------------------------------------------------- +// Byte-order accessors +// --------------------------------------------------------------------------- + +pub fn get_u16(data: &[u8], offset: u32) -> u16 { + let i = offset as usize; + u16::from_le_bytes([data[i], data[i + 1]]) +} + +pub fn get_u32(data: &[u8], offset: u32) -> u32 { + let i = offset as usize; + u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) +} + +pub fn get_u64(data: &[u8], offset: u32) -> u64 { + let i = offset as usize; + u64::from_le_bytes([ + data[i], + data[i + 1], + data[i + 2], + data[i + 3], + data[i + 4], + data[i + 5], + data[i + 6], + data[i + 7], + ]) +} + +pub fn write_u16(data: &mut [u8], offset: u32, value: u32) { + let i = offset as usize; + let v = value as u16; + let b = v.to_le_bytes(); + data[i] = b[0]; + data[i + 1] = b[1]; +} + +pub fn write_u32(data: &mut [u8], offset: u32, value: u32) { + let i = offset as usize; + let b = value.to_le_bytes(); + data[i] = b[0]; + data[i + 1] = b[1]; + data[i + 2] = b[2]; + data[i + 3] = b[3]; +} + +// --------------------------------------------------------------------------- +// Checked accessors (return Err instead of panicking on OOB) +// --------------------------------------------------------------------------- + +#[allow(dead_code)] +pub fn try_u32(d: &[u8], off: usize) -> Result { + let end = off + .checked_add(4) + .ok_or(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::Read, + offset: off, + size: 4, + buffer_len: d.len(), + })?; + d.get(off..end) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + .ok_or(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::Read, + offset: off, + size: 4, + buffer_len: d.len(), + }) +} + +#[allow(dead_code)] +pub fn try_i32(d: &[u8], off: usize) -> Result { + try_u32(d, off).map(|v| v as i32) +} + +/// Checked copy with distinct source and destination range errors. +pub fn try_copy_from_slice( + dst: &mut [u8], + dst_off: usize, + dst_len: usize, + src: &[u8], + src_off: usize, +) -> Result<(), crate::Error> { + let dst_end = dst_off + .checked_add(dst_len) + .ok_or(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopyDestination, + offset: dst_off, + size: dst_len, + buffer_len: dst.len(), + })?; + let src_end = src_off + .checked_add(dst_len) + .ok_or(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopySource, + offset: src_off, + size: dst_len, + buffer_len: src.len(), + })?; + if dst_end > dst.len() { + return Err(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopyDestination, + offset: dst_off, + size: dst_len, + buffer_len: dst.len(), + }); + } + if src_end > src.len() { + return Err(crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopySource, + offset: src_off, + size: dst_len, + buffer_len: src.len(), + }); + } + dst[dst_off..dst_end].copy_from_slice(&src[src_off..src_end]); + Ok(()) +} + +/// Reproduce the LFSR keystream that decrypt_data6 XORs in. Used to +/// trial-decrypt candidate bytecode positions without mutating the buffer. +pub fn lfsr_keystream(out: &mut [u8]) { + let mut state: u32 = 1; + for byte in out.iter_mut() { + let mut b: u8 = 0; + for k in 0..8u32 { + b |= ((state & 1) << k) as u8; + state <<= 1; + if state & 0x8000 != 0 { + state ^= 0x8003; + } + } + *byte = b; + } +} + +// --------------------------------------------------------------------------- +// AES primitives +// --------------------------------------------------------------------------- + +/// One AES-CBC-like round over a 16-byte block in `d` at `pos`, using the +/// expanded key schedule stored in `d` at `key_offset`. Works entirely within +/// the single `d` buffer (both ciphertext and key schedule live there). +pub fn aes_round(d: &mut [u8], pos: u32, key_offset: u32, round: u32) { + let cm1 = &COLUMMIX1; + let cm2 = &COLUMMIX2; + let cm3 = &COLUMMIX3; + let cm4 = &COLUMMIX4; + let sbox = &SBOX; + + let mut n0 = get_u32(d, pos).swap_bytes() ^ get_u32(d, key_offset); + let mut n1 = + get_u32(d, pos.wrapping_add(4)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(4)); + let mut n2 = + get_u32(d, pos.wrapping_add(8)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(8)); + let mut n3 = + get_u32(d, pos.wrapping_add(12)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(12)); + + let mut r = 1u32; + while r < round { + let off = key_offset.wrapping_add(r.wrapping_mul(16)); + let a = get_u32(cm2, ((n3 >> 16) & 0xFF) * 4) + ^ get_u32(cm3, ((n2 >> 8) & 0xFF) * 4) + ^ get_u32(cm1, ((n0 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n1 & 0xFF) * 4) + ^ get_u32(d, off); + let b = get_u32(cm2, ((n0 >> 16) & 0xFF) * 4) + ^ get_u32(cm1, ((n1 >> 24) & 0xFF) * 4) + ^ get_u32(cm3, ((n3 >> 8) & 0xFF) * 4) + ^ get_u32(cm4, (n2 & 0xFF) * 4) + ^ get_u32(d, off.wrapping_add(4)); + let c = get_u32(cm2, ((n1 >> 16) & 0xFF) * 4) + ^ get_u32(cm3, ((n0 >> 8) & 0xFF) * 4) + ^ get_u32(cm1, ((n2 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n3 & 0xFF) * 4) + ^ get_u32(d, off.wrapping_add(8)); + let e = get_u32(cm3, ((n1 >> 8) & 0xFF) * 4) + ^ get_u32(cm2, ((n2 >> 16) & 0xFF) * 4) + ^ get_u32(cm1, ((n3 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n0 & 0xFF) * 4) + ^ get_u32(d, off.wrapping_add(12)); + n0 = a; + n1 = b; + n2 = c; + n3 = e; + r = r.wrapping_add(1); + } + + let s0 = (get_u32(sbox, ((n0 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n3 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n2 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n1 & 0xFF) * 4) & 0x0000_00FF); + let s1 = (get_u32(sbox, ((n1 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n0 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n3 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n2 & 0xFF) * 4) & 0x0000_00FF); + let s2 = (get_u32(sbox, ((n2 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n1 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n0 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n3 & 0xFF) * 4) & 0x0000_00FF); + let s3 = (get_u32(sbox, ((n3 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n2 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n1 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n0 & 0xFF) * 4) & 0x0000_00FF); + + let last = key_offset.wrapping_add(round.wrapping_mul(16)); + n0 = s0 ^ get_u32(d, last); + n1 = s1 ^ get_u32(d, last.wrapping_add(4)); + n2 = s2 ^ get_u32(d, last.wrapping_add(8)); + n3 = s3 ^ get_u32(d, last.wrapping_add(12)); + + write_u32(d, pos, n0.swap_bytes()); + write_u32(d, pos.wrapping_add(4), n1.swap_bytes()); + write_u32(d, pos.wrapping_add(8), n2.swap_bytes()); + write_u32(d, pos.wrapping_add(12), n3.swap_bytes()); +} + +/// AES-CBC-like decryption over `size` bytes starting at `pos` in `d`. +/// The key schedule lives at `key_offset` within the same buffer `d`. +pub fn aes_decrypt(d: &mut [u8], pos: u32, size: u32, key_offset: u32) { + let mut prev = [0u8; 16]; + let mut cur = [0u8; 16]; + let round = get_u16(d, key_offset.wrapping_add(2)) as u32; + let blocks = size >> 4; + for i in 0..blocks { + let p = pos.wrapping_add(i.wrapping_mul(16)); + let pi = p as usize; + cur.copy_from_slice(&d[pi..pi + 16]); + aes_round(d, p, key_offset.wrapping_add(4), round); + for j in 0..16 { + d[pi + j] ^= prev[j]; + } + prev = cur; + } +} + +/// [`aes_decrypt`] variant reading the key schedule from a separate snapshot +/// slice instead of the data buffer. `ks` is a snapshot of `d[key_offset..]` +/// taken by [`aes_schedule_snapshot`] (round count at `ks[2]`, round keys from +/// `ks[4]`), so the schedule extent is exactly right by construction. Used by +/// the parallel block fan-out, where each worker owns a disjoint `&mut` span +/// of the image and cannot read the schedule out of the shared buffer. +pub fn aes_decrypt_ks(ks: &[u8], d: &mut [u8], pos: u32, size: u32) { + let mut prev = [0u8; 16]; + let mut cur = [0u8; 16]; + let round = u16::from_le_bytes([ks[2], ks[3]]) as u32; + let sched = &ks[4..]; + let blocks = size >> 4; + for i in 0..blocks { + let p = pos.wrapping_add(i.wrapping_mul(16)); + let pi = p as usize; + cur.copy_from_slice(&d[pi..pi + 16]); + aes_round_ks(sched, d, p, round); + for j in 0..16 { + d[pi + j] ^= prev[j]; + } + prev = cur; + } +} + +/// [`aes_round`] with the round keys in a separate slice (see +/// [`aes_decrypt_ks`]). Identical math; only the key source differs. +fn aes_round_ks(ks: &[u8], d: &mut [u8], pos: u32, round: u32) { + let cm1 = &COLUMMIX1; + let cm2 = &COLUMMIX2; + let cm3 = &COLUMMIX3; + let cm4 = &COLUMMIX4; + let sbox = &SBOX; + let k = |i: u32| get_u32(ks, i); + + let mut n0 = get_u32(d, pos).swap_bytes() ^ k(0); + let mut n1 = get_u32(d, pos.wrapping_add(4)).swap_bytes() ^ k(4); + let mut n2 = get_u32(d, pos.wrapping_add(8)).swap_bytes() ^ k(8); + let mut n3 = get_u32(d, pos.wrapping_add(12)).swap_bytes() ^ k(12); + + let mut r = 1u32; + while r < round { + let off = r.wrapping_mul(16); + let a = get_u32(cm2, ((n3 >> 16) & 0xFF) * 4) + ^ get_u32(cm3, ((n2 >> 8) & 0xFF) * 4) + ^ get_u32(cm1, ((n0 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n1 & 0xFF) * 4) + ^ k(off); + let b = get_u32(cm2, ((n0 >> 16) & 0xFF) * 4) + ^ get_u32(cm1, ((n1 >> 24) & 0xFF) * 4) + ^ get_u32(cm3, ((n3 >> 8) & 0xFF) * 4) + ^ get_u32(cm4, (n2 & 0xFF) * 4) + ^ k(off.wrapping_add(4)); + let c = get_u32(cm2, ((n1 >> 16) & 0xFF) * 4) + ^ get_u32(cm3, ((n0 >> 8) & 0xFF) * 4) + ^ get_u32(cm1, ((n2 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n3 & 0xFF) * 4) + ^ k(off.wrapping_add(8)); + let e = get_u32(cm3, ((n1 >> 8) & 0xFF) * 4) + ^ get_u32(cm2, ((n2 >> 16) & 0xFF) * 4) + ^ get_u32(cm1, ((n3 >> 24) & 0xFF) * 4) + ^ get_u32(cm4, (n0 & 0xFF) * 4) + ^ k(off.wrapping_add(12)); + n0 = a; + n1 = b; + n2 = c; + n3 = e; + r = r.wrapping_add(1); + } + + let s0 = (get_u32(sbox, ((n0 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n3 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n2 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n1 & 0xFF) * 4) & 0x0000_00FF); + let s1 = (get_u32(sbox, ((n1 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n0 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n3 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n2 & 0xFF) * 4) & 0x0000_00FF); + let s2 = (get_u32(sbox, ((n2 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n1 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n0 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n3 & 0xFF) * 4) & 0x0000_00FF); + let s3 = (get_u32(sbox, ((n3 >> 24) & 0xFF) * 4) & 0xFF00_0000) + | (get_u32(sbox, ((n2 >> 16) & 0xFF) * 4) & 0x00FF_0000) + | (get_u32(sbox, ((n1 >> 8) & 0xFF) * 4) & 0x0000_FF00) + | (get_u32(sbox, (n0 & 0xFF) * 4) & 0x0000_00FF); + + let last = round.wrapping_mul(16); + n0 = s0 ^ k(last); + n1 = s1 ^ k(last.wrapping_add(4)); + n2 = s2 ^ k(last.wrapping_add(8)); + n3 = s3 ^ k(last.wrapping_add(12)); + + write_u32(d, pos, n0.swap_bytes()); + write_u32(d, pos.wrapping_add(4), n1.swap_bytes()); + write_u32(d, pos.wrapping_add(8), n2.swap_bytes()); + write_u32(d, pos.wrapping_add(12), n3.swap_bytes()); +} + +/// Snapshot the AES key schedule at `key_offset` for [`aes_decrypt_ks`]: +/// `d[key_offset .. key_offset + 4 + (round+1)*16]` where `round` is read from +/// the schedule header. Returns `None` when the header is truncated or the +/// round count is implausible (corrupt input — the same bytes would otherwise +/// drive reads past the buffer). +pub fn aes_schedule_snapshot(d: &[u8], key_offset: u32) -> Option> { + let base = key_offset as usize; + let round = u16::from_le_bytes([*d.get(base + 2)?, *d.get(base + 3)?]) as usize; + if round > 64 { + return None; + } + let end = base.checked_add(4 + (round + 1) * 16)?; + if end > d.len() { + return None; + } + Some(d[base..end].to_vec()) +} + +// --------------------------------------------------------------------------- +// Checksum primitives +// --------------------------------------------------------------------------- + +/// CRC32-based checksum over a (offset, length) descriptor pair embedded in +/// `d` at `pos`. Returns `crc32(d[offset..offset+length]) ^ length`. +pub fn calculate_checksum(d: &[u8], pos: u32) -> u32 { + let offset = get_u32(d, pos); + let length = get_u32(d, pos.wrapping_add(4)); + crc32::compute(&d[offset as usize..(offset + length) as usize]) ^ length +} + +/// CRC32 chained checksum. The (offset, length) descriptor at `pos` is read +/// from `d`; the bytes themselves are read from the separate `clean` buffer +/// (the original file image). `start` is the initial CRC accumulator. +pub fn calculate_checksum2(d: &[u8], clean: &[u8], pos: u32, start: u32) -> u32 { + let offset = get_u32(d, pos); + let length = get_u32(d, pos.wrapping_add(4)); + crc32::append(start, &clean[offset as usize..(offset + length) as usize]) +} + +// --------------------------------------------------------------------------- +// Decompression (Huffman/LZ) +// --------------------------------------------------------------------------- + +/// Huffman/LZ decompression operating entirely within a single `d` buffer. +/// Reads `s_size` bytes from `src`, writes `d_size` bytes to `dest`. +/// The Huffman table lives at `key_offset` within `d`. +/// +/// Returns a structured reason when the stream cannot produce exactly +/// `d_size` bytes. +pub fn decompress_detailed( + d: &mut [u8], + src: u32, + mut dest: u32, + key_offset: u32, + s_size: u32, + d_size: u32, +) -> Result<(), crate::DecompressionFailure> { + use crate::DecompressionFailure; + + // Bound the scratch allocation: a corrupt descriptor could request a + // multi-gigabyte source size, and an allocation failure aborts the process + // (uncatchable). Real payloads are far below this. + if s_size as u64 > crate::MAX_IMAGE_SIZE { + return Err(DecompressionFailure::SourceTooLarge { + size: s_size, + max: crate::MAX_IMAGE_SIZE, + }); + } + DECOMPRESS_SCRATCH.with_borrow_mut(|buf| -> Result<(), DecompressionFailure> { + let mut bit_pos: i32 = 0; + let need = (s_size as usize).saturating_add(3); + if buf.len() < need { + buf.resize(need, 0); + } + let mut buf_off: u32 = 0; + let mut src_consumed: i32 = 0; + let mut pending: u32 = 0; + let mut written: u32 = 0; + let src_u = src as usize; + let s_size_u = s_size as usize; + // The bit-reader's final get_u32 may read up to 3 bytes past s_size; those + // must be zero. Reused scratch can hold stale bytes there, so zero them + // before copying the (exactly s_size) source over the head. + buf[s_size_u] = 0; + buf[s_size_u + 1] = 0; + buf[s_size_u + 2] = 0; + buf[..s_size_u].copy_from_slice(&d[src_u..src_u + s_size_u]); + + while (src_consumed as u32) < s_size && written < d_size { + let word = get_u32(&buf[..], buf_off) >> bit_pos; + let tab_addr = key_offset.wrapping_add((word & 0xFF).wrapping_mul(3)); + let mut tab = get_u16(d, tab_addr); + let bits: u8; + if (tab & 0x8000) != 0 { + tab &= 0x7FFF; + bits = d[tab_addr as usize + 2]; + } else { + let mut b2 = d[tab_addr as usize + 2]; + // A Huffman code longer than 32 bits cannot exist; a larger + // length byte comes from a corrupt table, and `1 << b2` would + // panic (debug) or wrap (release) on it. + if b2 >= 32 { + return Err(DecompressionFailure::InvalidCodeLength { bits: b2 }); + } + let mut mask: u32 = 1u32 << b2; + b2 = b2.wrapping_add(1); + let mut idx = (tab & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; + let mut t2 = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); + // A corrupt table can form a non-terminal cycle; cap the walk so it + // fails instead of spinning forever. + let mut depth = 0u32; + while (t2 & 0x8000) == 0 { + depth += 1; + if depth > 64 { + return Err(DecompressionFailure::HuffmanTraversalLimit); + } + mask <<= 1; + b2 = b2.wrapping_add(1); + idx = (t2 & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; + t2 = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); + } + tab = t2 & 0x7FFF; + bits = b2; + } + bit_pos += bits as i32; + let advance = bit_pos / 8; + buf_off = buf_off.wrapping_add(advance as u32); + src_consumed += advance; + bit_pos %= 8; + + let mode = (tab as u32) & 0x300; + let payload = (tab as u32) & 0xFF; + let step: u32; + match mode { + 0 => { + step = 1; + d[dest as usize] = payload as u8; + } + 0x100 => { + step = 0; + if pending >= 256 { + return Err(DecompressionFailure::PendingLengthOverflow { pending }); + } + pending = if pending == 0 { + payload + } else { + (pending << 8) | payload + }; + } + 0x200 => { + if pending == 0 { + pending = 1; + } + step = pending.wrapping_mul(payload); + if step.wrapping_add(written) > d_size { + return Err(DecompressionFailure::OutputOverflow { + written, + step, + expected: d_size, + }); + } + // Run-fill replicates the unit just written before `dest`. A + // corrupt stream can emit one of these before anything has been + // written, so guard against reading before the buffer start + // (an unsigned underflow would index astronomically far OOB). + match payload { + 1 => { + if dest < 1 { + return Err(DecompressionFailure::RunFillBeforeOutput { + width: payload, + destination: dest, + }); + } + let v = d[(dest as usize) - 1]; + for k in 0..pending { + d[(dest + k) as usize] = v; + } + } + 2 => { + if dest < 2 { + return Err(DecompressionFailure::RunFillBeforeOutput { + width: payload, + destination: dest, + }); + } + let v = get_u16(d, dest.wrapping_sub(2)); + for k in 0..pending { + write_u16(d, dest.wrapping_add(k.wrapping_mul(2)), v as u32); + } + } + 4 => { + if dest < 4 { + return Err(DecompressionFailure::RunFillBeforeOutput { + width: payload, + destination: dest, + }); + } + let v = get_u32(d, dest.wrapping_sub(4)); + for k in 0..pending { + write_u32(d, dest.wrapping_add(k.wrapping_mul(4)), v); + } + } + _ => { + // Only unit widths 1/2/4 exist. Any other payload comes + // from a corrupt stream: previously this wrote nothing + // yet still counted `step` bytes as written, leaving + // stale-buffer holes that later stages treated as + // plaintext. Report corruption instead. + return Err(DecompressionFailure::InvalidRunFillWidth { + width: payload, + }); + } + } + pending = 0; + } + _ => { + step = payload; + if written.wrapping_add(payload) > d_size + || pending.wrapping_add(payload) > written + { + let distance = pending.wrapping_add(payload); + if distance > written { + return Err(DecompressionFailure::InvalidBackReference { + distance, + written, + }); + } + return Err(DecompressionFailure::OutputOverflow { + written, + step: payload, + expected: d_size, + }); + } + let back = pending.wrapping_add(payload); + for k in 0..payload { + d[(dest + k) as usize] = d[(dest + k - back) as usize]; + } + pending = 0; + } + } + + dest = dest.wrapping_add(step); + written = written.wrapping_add(step); + if bits == 0 && step == 0 { + // Corrupt table: no input bits consumed and no output bytes + // written, so the loop condition can never advance — an + // infinite loop (and `catch_unpack` traps panics, not hangs). + // Every real symbol consumes ≥ 1 bit, so a valid stream can + // never hit this. + return Err(DecompressionFailure::NoProgress); + } + } + src_consumed += if bit_pos != 0 { 1 } else { 0 }; + if written != d_size { + return Err(DecompressionFailure::OutputSizeMismatch { + written, + expected: d_size, + consumed: src_consumed.max(0) as u32, + source_size: s_size, + }); + } + Ok(()) + }) +} + +/// Boolean compatibility wrapper used by candidate searches and block fan-out. +pub fn decompress( + d: &mut [u8], + src: u32, + dest: u32, + key_offset: u32, + s_size: u32, + d_size: u32, +) -> bool { + decompress_detailed(d, src, dest, key_offset, s_size, d_size).is_ok() +} + +/// Walk the Huffman table at `key_offset` and snapshot its bytes for +/// [`decompress_tbl`]. The table is a forest of 256 root entries (3 bytes +/// each); non-terminal entries point at a child index pair. Returns `None` +/// when the table is truncated or self-referential past the buffer (corrupt +/// input — the same bytes would otherwise drive reads out of bounds). +pub fn huffman_table_snapshot(d: &[u8], key_offset: u32) -> Option> { + let mut visited = vec![false; 0x1_0000usize]; + let mut stack: Vec = (0..256).collect(); + let mut max_idx: u32 = 255; + while let Some(idx) = stack.pop() { + if idx >= 0x1_0000 || visited[idx as usize] { + continue; + } + visited[idx as usize] = true; + let off = key_offset as usize + idx as usize * 3; + if off + 3 > d.len() { + return None; + } + let t = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); + if (t & 0x8000) == 0 { + let child = (t & 0x7FFF) as u32; + max_idx = max_idx.max(child).max(child.wrapping_add(1)); + stack.push(child); + stack.push(child.wrapping_add(1)); + } + } + let end = key_offset as usize + (max_idx as usize + 1) * 3; + if end > d.len() { + return None; + } + Some(d[key_offset as usize..end].to_vec()) +} + +/// [`decompress`] variant reading the Huffman table from a separate snapshot +/// slice (see [`huffman_table_snapshot`]) instead of the data buffer. Used by +/// the parallel block fan-out, where each worker owns a disjoint `&mut` span +/// and cannot read the table out of the shared image. Table reads are bounds +/// checked against the snapshot — past-the-end means corrupt table, reported +/// as `false` rather than a panic. +pub fn decompress_tbl( + tab: &[u8], + d: &mut [u8], + src: u32, + mut dest: u32, + s_size: u32, + d_size: u32, +) -> bool { + if s_size as u64 > crate::MAX_IMAGE_SIZE { + return false; + } + DECOMPRESS_SCRATCH.with_borrow_mut(|buf| { + // Table reads, bounds-checked against the snapshot. + let tab16 = |addr: usize| -> Option { + let b = tab.get(addr..addr + 3)?; + Some(u16::from_le_bytes([b[0], b[1]])) + }; + let tab8 = |addr: usize| -> Option { tab.get(addr + 2).copied() }; + + let mut bit_pos: i32 = 0; + let need = (s_size as usize).saturating_add(3); + if buf.len() < need { + buf.resize(need, 0); + } + let mut buf_off: u32 = 0; + let mut src_consumed: i32 = 0; + let mut pending: u32 = 0; + let mut written: u32 = 0; + let src_u = src as usize; + let s_size_u = s_size as usize; + buf[s_size_u] = 0; + buf[s_size_u + 1] = 0; + buf[s_size_u + 2] = 0; + buf[..s_size_u].copy_from_slice(&d[src_u..src_u + s_size_u]); + + while (src_consumed as u32) < s_size && written < d_size { + let word = get_u32(&buf[..], buf_off) >> bit_pos; + let tab_addr = ((word & 0xFF).wrapping_mul(3)) as usize; + let mut tab = match tab16(tab_addr) { + Some(t) => t, + None => { + return false; + } + }; + let bits: u8; + if (tab & 0x8000) != 0 { + tab &= 0x7FFF; + bits = match tab8(tab_addr) { + Some(b) => b, + None => return false, + }; + } else { + let mut b2 = match tab8(tab_addr) { + Some(b) => b, + None => return false, + }; + if b2 >= 32 { + return false; + } + let mut mask: u32 = 1u32 << b2; + b2 = b2.wrapping_add(1); + let mut idx = (tab & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; + let mut t2 = match tab16(idx as usize * 3) { + Some(t) => t, + None => { + return false; + } + }; + // A corrupt table can form a non-terminal cycle; cap the walk so it + // fails instead of spinning forever. + let mut depth = 0u32; + while (t2 & 0x8000) == 0 { + depth += 1; + if depth > 64 { + return false; + } + mask <<= 1; + b2 = b2.wrapping_add(1); + idx = (t2 & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; + t2 = match tab16(idx as usize * 3) { + Some(t) => t, + None => { + return false; + } + }; + } + tab = t2 & 0x7FFF; + bits = b2; + } + bit_pos += bits as i32; + let advance = bit_pos / 8; + buf_off = buf_off.wrapping_add(advance as u32); + src_consumed += advance; + bit_pos %= 8; + + let mode = (tab as u32) & 0x300; + let payload = (tab as u32) & 0xFF; + let step: u32; + match mode { + 0 => { + step = 1; + d[dest as usize] = payload as u8; + } + 0x100 => { + step = 0; + if pending >= 256 { + return false; + } + pending = if pending == 0 { + payload + } else { + (pending << 8) | payload + }; + } + 0x200 => { + if pending == 0 { + pending = 1; + } + step = pending.wrapping_mul(payload); + if step.wrapping_add(written) > d_size { + return false; + } + // Run-fill replicates the unit just written before `dest` + // (see `decompress` for the underflow rationale). + match payload { + 1 => { + if dest < 1 { + return false; + } + let v = d[(dest as usize) - 1]; + for k in 0..pending { + d[(dest + k) as usize] = v; + } + } + 2 => { + if dest < 2 { + return false; + } + let v = get_u16(d, dest.wrapping_sub(2)); + for k in 0..pending { + write_u16(d, dest.wrapping_add(k.wrapping_mul(2)), v as u32); + } + } + 4 => { + if dest < 4 { + return false; + } + let v = get_u32(d, dest.wrapping_sub(4)); + for k in 0..pending { + write_u32(d, dest.wrapping_add(k.wrapping_mul(4)), v); + } + } + _ => { + return false; + } + } + pending = 0; + } + _ => { + step = payload; + if written.wrapping_add(payload) > d_size + || pending.wrapping_add(payload) > written + { + return false; + } + let back = pending.wrapping_add(payload); + for k in 0..payload { + d[(dest + k) as usize] = d[(dest + k - back) as usize]; + } + pending = 0; + } + } + + dest = dest.wrapping_add(step); + written = written.wrapping_add(step); + if bits == 0 && step == 0 { + return false; + } + } + src_consumed += if bit_pos != 0 { 1 } else { 0 }; + let _ = src_consumed; + written == d_size + }) +} +// Decrypt primitives (free-function wrappers) +// --------------------------------------------------------------------------- + +/// decrypt_data3: XOR+rotate cipher. Reads/writes dwords in `d` starting at +/// the address stored at `d[pos]`, for `d[pos+4]>>2` words. `shift` is the +/// right-rotate amount (19 or 21 depending on caller). +pub fn decrypt_data3(d: &mut [u8], pos: u32, mut key: u32, shift: u32) { + let base_addr = get_u32(d, pos); + let length = get_u32(d, pos.wrapping_add(4)); + let words = length >> 2; + for i in 0..words { + let off = base_addr.wrapping_add(i.wrapping_mul(4)); + let v = get_u32(d, off) ^ key; + key = key.wrapping_add(i); + let rotated = v.rotate_right(shift); + write_u32(d, off, rotated.wrapping_sub(i)); + } +} + +/// decrypt_data1 (called `decrypt_data` in the original): decode the 8-dword +/// info header from `file_data` at offset 4096 and write results into `info`. +pub fn decrypt_data1(file_data: &[u8], info: &mut [u32; 8]) { + info[0] = get_u32(file_data, 4096); + let mut k = get_u32(file_data, 4096); + for i in 0..7u32 { + let off = i.wrapping_mul(4).wrapping_add(4); + let cell = get_u32(file_data, 4096u32.wrapping_add(off)); + info[(i + 1) as usize] = k ^ cell; + k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i)); + } +} + +/// decrypt_data6: LFSR XOR decryption of a bytecode block at `pos` in `d`. +/// The block length is read from `d[pos + 95]`. +pub fn decrypt_data6(d: &mut [u8], pos: u32) { + let len = d[(pos + 95) as usize] as usize; + // The keystream is exactly `lfsr_keystream`'s — generate it once (len is a + // byte, so 256 always covers it) instead of keeping a second copy of the + // LFSR that a future poly fix would have to update separately. + let mut ks = [0u8; 256]; + lfsr_keystream(&mut ks); + let pos = pos as usize; + for i in 0..len { + d[pos + i] ^= ks[i]; + } +} + +/// decrypt_data7: nibble-swap + key-rolling byte cipher applied to a +/// null-terminated string in `d` starting at `pos`. +pub fn decrypt_data7(d: &mut [u8], pos: u32, mut key: u8) { + let mut i: u32 = 0; + loop { + let idx = (pos + i) as usize; + if d[idx] == 0 { + break; + } + let mut b = d[idx]; + b = b.rotate_right(4); + b = b.wrapping_sub(key); + if b == 0 { + b = 0u8.wrapping_sub(key); + } + d[idx] = b; + key = key.wrapping_add(67); + i += 1; + } +} + +// --------------------------------------------------------------------------- +// Higher-level composite: AES + decrypt3 + optional bytecode + decompress +// --------------------------------------------------------------------------- + +/// Decrypt and optionally decompress a stage payload descriptor. +/// `pos` points to a (src, src_len, dest, dest_len) quad of dwords in `d`. +/// - AES-decrypts `src..src+src_len` using key at `key3_offset` +/// - XOR+rotate-decrypts with `decrypt_data3(pos, key, 19)` +/// - Applies optional custom `ops` bytecode per-byte +/// - If `src_len != dest_len`, Huffman/LZ-decompresses `src..` → `dest..` +/// +/// Returns the decompression success status (always `true` when no +/// decompression was needed). The PE32 eighth-stage key search relies on this. +pub fn decrypt_and_decompress_data_detailed( + d: &mut [u8], + pos: u32, + key: u32, + key1_offset: u32, + key3_offset: u32, + ops: Option<&[Op]>, +) -> Result<(), crate::DecompressionFailure> { + let src = get_u32(d, pos); + let src_len = get_u32(d, pos.wrapping_add(4)); + aes_decrypt(d, src, src_len, key3_offset); + decrypt_data3(d, pos, key, 19); + if let Some(ops) = ops + && src_len != 0 + { + OpsLut::new(ops).map_region(d, src as usize, src_len as usize); + } + let dest = get_u32(d, pos.wrapping_add(8)); + let dest_len = get_u32(d, pos.wrapping_add(12)); + if src_len != dest_len { + return decompress_detailed(d, src, dest, key1_offset, src_len, dest_len); + } + Ok(()) +} + +/// Boolean compatibility wrapper used by key searches that trial candidates. +pub fn decrypt_and_decompress_data( + d: &mut [u8], + pos: u32, + key: u32, + key1_offset: u32, + key3_offset: u32, + ops: Option<&[Op]>, +) -> bool { + decrypt_and_decompress_data_detailed(d, pos, key, key1_offset, key3_offset, ops).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_copy_distinguishes_source_and_destination_ranges() { + let mut short_destination = [0u8; 2]; + let source = [1u8; 4]; + let error = try_copy_from_slice(&mut short_destination, 0, 3, &source, 0) + .expect_err("destination must be rejected"); + assert!(matches!( + error, + crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopyDestination, + offset: 0, + size: 3, + buffer_len: 2, + } + )); + + let mut destination = [0u8; 4]; + let short_source = [1u8; 2]; + let error = try_copy_from_slice(&mut destination, 0, 3, &short_source, 0) + .expect_err("source must be rejected"); + assert!(matches!( + error, + crate::Error::BufferRangeOutOfBounds { + operation: crate::BufferOperation::CopySource, + offset: 0, + size: 3, + buffer_len: 2, + } + )); + } + + #[test] + fn aes_ks_variant_matches_single_buffer() { + // Random-ish key schedule at ko and data block; both variants must + // produce identical output. + let ko: usize = 0x40; + let mut d = vec![0u8; 0x400]; + let mut x: u32 = 0x12345678; + for b in d.iter_mut() { + x = x.wrapping_mul(1664525).wrapping_add(1013904223); + *b = (x >> 24) as u8; + } + d[ko + 2] = 10; // round count = 10 + d[ko + 3] = 0; + let snap = aes_schedule_snapshot(&d, ko as u32).expect("snapshot"); + + let mut a = d.clone(); + aes_decrypt(&mut a, 0x100, 0x80, ko as u32); + let mut b = d.clone(); + aes_decrypt_ks(&snap, &mut b, 0x100, 0x80); + if a != b { + let idx = (0..a.len()).find(|&i| a[i] != b[i]).unwrap(); + panic!( + "first diff at {idx:#x}: a={:02x} b={:02x}\n a[..]: {:02x?}\n b[..]: {:02x?}", + a[idx], + b[idx], + &a[idx..idx + 16], + &b[idx..idx + 16] + ); + } + } + + #[test] + fn dtbl_variant_matches_single_buffer() { + // Real table + real compressed block lifted from an actual unpack is + // covered by the golden suite; here we just check a trivial stream: + // build a table where every byte is a literal (mode 0, 8 bits), then + // a source stream of N bytes should expand to N identical bytes. + let ko: usize = 0x100; + let mut d = vec![0u8; 0x1000]; + for e in 0..256usize { + let off = ko + e * 3; + let sym = 0x8000u16 | (e as u16 & 0xFF); // terminal, mode 0, payload=e + d[off] = (sym & 0xFF) as u8; + d[off + 1] = (sym >> 8) as u8; + d[off + 2] = 8; // 8 bits per symbol + } + // Source: 16 bytes 0x00..0x0F at src. + let src = 0x600u32; + for i in 0..16u32 { + d[(src + i) as usize] = i as u8; + } + let snap = huffman_table_snapshot(&d, ko as u32).expect("table snapshot"); + + let mut a = vec![0u8; 0x1000]; + a[..d.len()].copy_from_slice(&d); + assert!(decompress(&mut a, src, 0x800, ko as u32, 16, 16)); + let mut b = d.clone(); + assert!(decompress_tbl(&snap, &mut b, src, 0x800, 16, 16)); + assert_eq!(&a[0x800..0x810], &b[0x800..0x810]); + assert_eq!(&b[0x800..0x810], &(0u8..16).collect::>()[..]); + } + + /// Review regression: a run-fill token with a unit width other than 1/2/4 + /// comes from a corrupt stream and must report failure — previously it + /// wrote nothing yet still counted the bytes as written, leaving stale + /// holes that later stages treated as plaintext. + #[test] + fn decompress_rejects_unknown_run_fill_width() { + // Huffman table at key_offset 0, entry 0: terminal symbol with + // mode 0x200 (run-fill), payload 3 (invalid width), code length 8. + let mut d = vec![0u8; 0x100]; + let sym: u16 = 0x8000 | 0x203; + d[0..2].copy_from_slice(&sym.to_le_bytes()); + d[2] = 8; + // All-zero source -> symbol index 0 -> the invalid run-fill. + assert_eq!( + decompress_detailed(&mut d, 0x40, 0x80, 0, 4, 3), + Err(crate::DecompressionFailure::InvalidRunFillWidth { width: 3 }) + ); + } + + /// Control for the above: a width-1 run-fill is legal and succeeds. + #[test] + fn decompress_accepts_width1_run_fill() { + let mut d = vec![0u8; 0x100]; + d[0x7F] = 0x5A; // unit to replicate + let sym: u16 = 0x8000 | 0x201; + d[0..2].copy_from_slice(&sym.to_le_bytes()); + d[2] = 8; + assert!(decompress(&mut d, 0x40, 0x80, 0, 4, 3)); + assert_eq!(&d[0x80..0x83], &[0x5A, 0x5A, 0x5A]); + } +} diff --git a/src/unpacker/tables.rs b/senbei-crypto/src/tables.rs similarity index 92% rename from src/unpacker/tables.rs rename to senbei-crypto/src/tables.rs index 04dcc9b..29fb9fd 100644 --- a/src/unpacker/tables.rs +++ b/senbei-crypto/src/tables.rs @@ -146,11 +146,11 @@ mod tests { #[test] fn generated_tables_match_committed_bytes() { assert_eq!(COLUMMIX1.len(), 1024); - assert_eq!(super::super::crc32::compute(&COLUMMIX1), 0x7e8d_5d5f); - assert_eq!(super::super::crc32::compute(&COLUMMIX2), 0xfcc4_acfc); - assert_eq!(super::super::crc32::compute(&COLUMMIX3), 0x637a_f0cd); - assert_eq!(super::super::crc32::compute(&COLUMMIX4), 0x1e7b_c381); - assert_eq!(super::super::crc32::compute(&SBOX), 0x10fd_6dc1); + assert_eq!(crate::crc32::compute(&COLUMMIX1), 0x7e8d_5d5f); + assert_eq!(crate::crc32::compute(&COLUMMIX2), 0xfcc4_acfc); + assert_eq!(crate::crc32::compute(&COLUMMIX3), 0x637a_f0cd); + assert_eq!(crate::crc32::compute(&COLUMMIX4), 0x1e7b_c381); + assert_eq!(crate::crc32::compute(&SBOX), 0x10fd_6dc1); // Spot-check the first dword of each (matches the original first row). assert_eq!(&COLUMMIX1[..4], &[0x50, 0xa7, 0xf4, 0x51]); assert_eq!(&COLUMMIX2[..4], &[0xa7, 0xf4, 0x51, 0x50]); diff --git a/senbei-io/Cargo.toml b/senbei-io/Cargo.toml new file mode 100644 index 0000000..e5eb407 --- /dev/null +++ b/senbei-io/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "senbei-io" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Filesystem, scanning, logging, and CLI orchestration for Senbei" + +[dependencies] +anyhow.workspace = true +indicatif.workspace = true +owo-colors.workspace = true +senbei-metadata.workspace = true +senbei-pe.workspace = true +walkdir.workspace = true + +[target.'cfg(windows)'.dependencies] +windows.workspace = true + +[target.'cfg(not(windows))'.dependencies] +libc.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/src/job.rs b/senbei-io/src/job.rs similarity index 94% rename from src/job.rs rename to senbei-io/src/job.rs index 5d3d22d..32d315a 100644 --- a/src/job.rs +++ b/senbei-io/src/job.rs @@ -1,4 +1,4 @@ -use crate::unpacker; +use senbei_pe as unpacker; use std::path::{Path, PathBuf}; /// Crackproof header key table lives at this fixed file offset. For the @@ -518,7 +518,7 @@ pub fn run_folder_opts( // il2cpp metadata pass. Crackproof's `-GMD` option obfuscates the method // tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked // il2cpp game assembly resolves methods instead of indexing its per-module - // tables out of bounds (see [`crate::metadata`]). This is additive to the + // tables out of bounds (see [`senbei_metadata`]). This is additive to the // Crackproof module unpack above — the metadata blob is not itself a // Crackproof file. for meta in metas { @@ -660,7 +660,7 @@ pub fn run_file_v( let mut buf = [0u8; 4]; std::fs::File::open(input) .and_then(|mut f| f.read_exact(&mut buf)) - .map(|_| crate::metadata::is_metadata(&buf)) + .map(|_| senbei_metadata::is_metadata(&buf)) .unwrap_or(false) }; @@ -801,13 +801,13 @@ fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String { } } -/// If `e`'s chain contains [`crate::metadata::Error::UnsupportedVersion`], +/// If `e`'s chain contains [`senbei_metadata::Error::UnsupportedVersion`], /// return the version. Used to apply the folder-mode "leave untouched, don't /// fail the run" policy to metadata versions this build can't de-obfuscate. fn unsupported_version(e: &anyhow::Error) -> Option { for cause in e.chain() { - if let Some(crate::metadata::Error::UnsupportedVersion(v)) = - cause.downcast_ref::() + if let Some(senbei_metadata::Error::UnsupportedVersion(v)) = + cause.downcast_ref::() { return Some(*v); } @@ -831,26 +831,20 @@ fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> { r } -/// Detect `bytes` and run the right pipeline. The EXE pipeline is invoked -/// directly (no DLL-pipeline probe) when the input was spliced from an -/// external companion (`spliced`) or when the caller forces it (`force_exe` -/// — the web app's recovery path after a DLL-probe trap; see -/// [`unpack_bytes_force_exe`]). +/// Detect `bytes` and run the right pipeline. Spliced external companions use +/// the EXE pipeline directly because that layout is definitionally EXE-style. /// /// Routing spliced inputs straight to the EXE pipeline is safe: the /// companion layout is definitionally the EXE-style shell (the runtime /// loader maps the companion and runs the standard shell unpack), so the DLL -/// pipeline probe can never be right for it — and probing is not a no-op on -/// targets without unwinding (wasm), where the probe's caught panic becomes -/// a fatal trap. Output bytes are identical to the dll-first + exe-fallback -/// route for every input that route handles. +/// pipeline probe can never be right for it. Output bytes are identical to the +/// DLL-first + EXE-fallback route for every input that route handles. fn unpack_spliced_or_auto( bytes: &[u8], spliced: bool, - force_exe: bool, verbose: bool, ) -> Result<(unpacker::Kind, Vec), unpacker::UnpackError> { - if spliced || force_exe { + if spliced { let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?; let out = unpacker::unpack_exe_v(bytes, verbose)?; return Ok((detected.kind, out)); @@ -880,38 +874,23 @@ pub struct UnpackedImage { /// Unpack in-memory `input` bytes, optionally paired with an external /// companion payload `companion` (the `._` file's contents). /// -/// This is the I/O-free counterpart of [`unpack_one_v`], used by the -/// WebAssembly build: splice (when the companion's first 32 bytes match the -/// stub header), unpack, overlay the export table and TLS directory from the -/// stub, then run the static integrity check. +/// This is the in-memory counterpart of [`unpack_one_v`]: splice a matching +/// companion, unpack, overlay the export table and TLS directory from the stub, +/// then run the static integrity check. pub fn unpack_bytes( input: &[u8], companion: Option<&[u8]>, ) -> Result { - unpack_bytes_impl(input, companion, false) -} - -/// Like [`unpack_bytes`], but forces the EXE pipeline (no DLL-pipeline -/// probe). This is the web app's recovery path: the DLL-first probe relies -/// on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be -/// caught on wasm — the probe traps the whole call. The web app runs each -/// unpack in a disposable Web Worker and retries trapped DLLs with this -/// entry point, reproducing the CLI's dll-first/exe-fallback routing. -pub fn unpack_bytes_force_exe( - input: &[u8], - companion: Option<&[u8]>, -) -> Result { - unpack_bytes_impl(input, companion, true) + unpack_bytes_impl(input, companion) } fn unpack_bytes_impl( input: &[u8], companion: Option<&[u8]>, - force_exe: bool, ) -> Result { let spliced = companion.and_then(|c| splice_companion(input, c)); let bytes: &[u8] = spliced.as_deref().unwrap_or(input); - let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?; + let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), false)?; if spliced.is_some() { overlay_exports_from_stub(&mut out, input); restore_tls_from_stub(&mut out, input); @@ -933,7 +912,7 @@ pub fn unpack_one_v( verbose: bool, ) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> { let UnpackerInput { bytes, stub } = read_unpacker_input(input)?; - let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), false, verbose)?; + let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), verbose)?; // External-companion layout: restore the export table from the stub, which // the encrypted companion does not carry (the loader rebuilds it at runtime). if let Some(stub) = stub { @@ -960,7 +939,7 @@ pub fn unpack_one_v( /// into a sparse, original-metadata-style value; il2cpp expects the contiguous /// per-module index it indexes its codegen tables with, so a statically-unpacked /// il2cpp game assembly reads garbage and crashes during init. This rewrites -/// the tokens back to their canonical form (see [`crate::metadata::deobfuscate`]). +/// the tokens back to their canonical form (see [`senbei_metadata::deobfuscate`]). /// /// The output is written only when something actually changed /// (`report.remapped > 0`); an already-clean metadata is left untouched and no @@ -970,11 +949,11 @@ pub fn deobfuscate_metadata_to( input: &Path, dest: &Path, verbose: bool, -) -> anyhow::Result { +) -> anyhow::Result { let data = std::fs::read(input)?; // Preserve the metadata::Error in the chain (rather than stringifying it) // so the folder driver can apply its unsupported-version policy. - let (out, report) = crate::metadata::deobfuscate(&data) + let (out, report) = senbei_metadata::deobfuscate(&data) .map_err(|e| anyhow::Error::new(e).context(format!("{input:?}")))?; if report.remapped > 0 { if let Some(parent) = dest.parent() { diff --git a/src/lib.rs b/senbei-io/src/lib.rs similarity index 59% rename from src/lib.rs rename to senbei-io/src/lib.rs index 270c372..5147a04 100644 --- a/src/lib.rs +++ b/senbei-io/src/lib.rs @@ -1,7 +1,7 @@ +//! Filesystem and command-line orchestration. + pub mod job; pub mod logfile; -pub mod metadata; pub mod pause; pub mod scan; pub mod ui; -pub mod unpacker; diff --git a/src/logfile.rs b/senbei-io/src/logfile.rs similarity index 92% rename from src/logfile.rs rename to senbei-io/src/logfile.rs index 7027520..ead7ed2 100644 --- a/src/logfile.rs +++ b/senbei-io/src/logfile.rs @@ -79,7 +79,7 @@ fn local_parts() -> LocalParts { second: st.wSecond as u32, } } - #[cfg(all(not(windows), not(target_arch = "wasm32")))] + #[cfg(not(windows))] { // Local wall clock via POSIX localtime_r — same semantics as Windows GetLocalTime. use std::time::{SystemTime, UNIX_EPOCH}; @@ -102,13 +102,6 @@ fn local_parts() -> LocalParts { second: tm.tm_sec as u32, } } - #[cfg(all(not(windows), target_arch = "wasm32"))] - { - // wasm has no local timezone database and SystemTime::now() panics - // without a JS time source. The run log is a CLI concern — the wasm - // build never writes one — so a fixed epoch stamp suffices. - utc_parts(0) - } } /// Convert Unix UTC seconds to civil Y-M-D h:m:s (Howard Hinnant). diff --git a/src/pause.rs b/senbei-io/src/pause.rs similarity index 100% rename from src/pause.rs rename to senbei-io/src/pause.rs diff --git a/src/scan.rs b/senbei-io/src/scan.rs similarity index 98% rename from src/scan.rs rename to senbei-io/src/scan.rs index d0308f4..43f46ee 100644 --- a/src/scan.rs +++ b/senbei-io/src/scan.rs @@ -1,4 +1,4 @@ -use crate::unpacker::detect; +use senbei_pe::detect; use std::io::Read; use std::path::{Path, PathBuf}; use walkdir::WalkDir; @@ -16,12 +16,12 @@ const DETECT_PREFIX: u64 = 8 * 1024; /// Smallest file that can possibly be a target, so anything shorter is skipped /// without ever being opened. /// -/// A Crackproof module needs ≥ 4128 bytes for [`crate::unpacker::detect`]'s key +/// A Crackproof module needs ≥ 4128 bytes for [`senbei_pe::detect`]'s key /// table (it reads the dword at 4124), so the bound is exact for the unpack /// path. An il2cpp `global-metadata.dat` only needs 4 bytes to match its magic, /// but its header alone runs to offset 0xB0 and the images/types/methods tables /// it indexes make every real one megabytes long — a sub-4 KiB "metadata" could -/// only ever fail [`crate::metadata::deobfuscate`] with `Malformed`, so nothing +/// only ever fail [`senbei_metadata::deobfuscate`] with `Malformed`, so nothing /// processable is lost. const MIN_SIZE: u64 = 4128; @@ -223,7 +223,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec, Vec> = vec![Some(Class::None); n]; - let workers = crate::unpacker::parallel::thread_cap().clamp(1, n.max(1)); + let workers = senbei_pe::thread_cap().clamp(1, n.max(1)); if workers <= 1 { for (p, c) in paths.iter().zip(class.iter_mut()) { *c = classify(p); @@ -304,7 +304,7 @@ fn classify(path: &Path) -> Option { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if detect(&head).is_some() { Class::Crackproof - } else if crate::metadata::is_metadata(&head) { + } else if senbei_metadata::is_metadata(&head) { Class::Metadata } else { Class::None diff --git a/src/ui.rs b/senbei-io/src/ui.rs similarity index 97% rename from src/ui.rs rename to senbei-io/src/ui.rs index d882745..8d49ce7 100644 --- a/src/ui.rs +++ b/senbei-io/src/ui.rs @@ -1,6 +1,6 @@ -use crate::unpacker::{IntegrityReport, Kind}; use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; +use senbei_pe::{IntegrityReport, Kind}; use std::path::Path; /// Create a progress bar for `n` items. Hidden when `quiet` is true. diff --git a/senbei-metadata/Cargo.toml b/senbei-metadata/Cargo.toml new file mode 100644 index 0000000..c9d49e2 --- /dev/null +++ b/senbei-metadata/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "senbei-metadata" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Unity il2cpp metadata de-obfuscation for Senbei" diff --git a/senbei-metadata/src/lib.rs b/senbei-metadata/src/lib.rs new file mode 100644 index 0000000..18e6fc7 --- /dev/null +++ b/senbei-metadata/src/lib.rs @@ -0,0 +1,5 @@ +//! Unity il2cpp metadata de-obfuscation. + +mod metadata; + +pub use metadata::*; diff --git a/src/metadata.rs b/senbei-metadata/src/metadata.rs similarity index 100% rename from src/metadata.rs rename to senbei-metadata/src/metadata.rs diff --git a/senbei-pe/Cargo.toml b/senbei-pe/Cargo.toml new file mode 100644 index 0000000..8790c8b --- /dev/null +++ b/senbei-pe/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "senbei-pe" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "PE detection, unpacking, and validation for Senbei" + +[dependencies] +senbei-crypto.workspace = true +thiserror.workspace = true diff --git a/senbei-pe/src/engine/dll/mod.rs b/senbei-pe/src/engine/dll/mod.rs new file mode 100644 index 0000000..bf70ed1 --- /dev/null +++ b/senbei-pe/src/engine/dll/mod.rs @@ -0,0 +1,3 @@ +mod pipeline; + +pub use pipeline::*; diff --git a/src/unpacker/dll.rs b/senbei-pe/src/engine/dll/pipeline.rs similarity index 98% rename from src/unpacker/dll.rs rename to senbei-pe/src/engine/dll/pipeline.rs index 794e5e0..b14c4b0 100644 --- a/src/unpacker/dll.rs +++ b/senbei-pe/src/engine/dll/pipeline.rs @@ -13,12 +13,12 @@ //! CalculateChecksumWithSizeXor -> primitives::calculate_checksum //! CalculateCrc32 -> crc32::compute (via above) -use super::bytecode::{Op, OpsLut, generate}; -use super::primitives::{self, *}; -use super::{ +use super::super::{ BufferOperation, BytecodeStage, DecompressionStage, DescriptorTable, SectionPipeline, UnpackError, }; +use senbei_crypto::bytecode::{Op, OpsLut, generate}; +use senbei_crypto::primitives::{self, *}; /// Read a signed 32-bit little-endian value. fn get_i32(d: &[u8], offset: i32) -> i32 { @@ -291,7 +291,7 @@ fn decrypt_and_decompress_data( } Ok(()) }; - super::parallel::parallel_for(d, &spans, 1, do_block)?; + super::super::parallel::parallel_for(d, &spans, 1, do_block)?; } // Zero-fill loop. @@ -342,7 +342,7 @@ pub fn unpack_dll(input: &[u8]) -> Result, UnpackError> { pub fn unpack_dll_v(input: &[u8], verbose: bool) -> Result, UnpackError> { // Trap any out-of-bounds panic from a truncated/garbled file and report it // as a clean error so the public API stays panic-free. - super::catch_unpack(move || unpack_dll_inner(input, verbose)) + super::super::catch_unpack(move || unpack_dll_inner(input, verbose)) } fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> { @@ -369,7 +369,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> println!(" keys[6] anchor = 0x{:08X}", keys[6] as u32); } - if !super::is_supported_magic(keys[1] as u32) { + if !super::super::is_supported_magic(keys[1] as u32) { return Err(UnpackError::HeaderMagicMismatch { found: keys[1] as u32, }); @@ -395,10 +395,10 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> }); } let size_of_image = get_i32(file_data, pe_offset + 80); - if size_of_image <= 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE { + if size_of_image <= 0 || size_of_image as u64 > super::super::MAX_IMAGE_SIZE { return Err(UnpackError::InvalidImageSize { size: i64::from(size_of_image), - max: super::MAX_IMAGE_SIZE, + max: super::super::MAX_IMAGE_SIZE, }); } let mut out = vec![0u8; size_of_image as usize]; @@ -567,7 +567,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> let crc_val = { let a = crc_data_addr as usize; let n = crc_data_size as usize; - super::crc32::compute(&out[a..a + n]) as i32 + senbei_crypto::crc32::compute(&out[a..a + n]) as i32 }; let crc_xored = crc_data_size ^ crc_val; let trailing_val = get_i32(&out, crc_data_addr + crc_data_size - 4); diff --git a/senbei-pe/src/engine/error.rs b/senbei-pe/src/engine/error.rs new file mode 100644 index 0000000..3e7cfa6 --- /dev/null +++ b/senbei-pe/src/engine/error.rs @@ -0,0 +1,243 @@ +pub use senbei_crypto::{BufferOperation, DecompressionFailure}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecompressionStage { + ExeStage3, + ExeStage3Secondary, + ExeStage4, + ExeStage5, + Pe32FourthStage, + Pe32FifthStage, + Pe32SeventhStage, + DllCodeBlock1, + DllCodeBlock2, + DllCodeBlock3, + DllCodeBlock4, +} + +impl std::fmt::Display for DecompressionStage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExeStage3 => "EXE stage3", + Self::ExeStage3Secondary => "EXE secondary stage3", + Self::ExeStage4 => "EXE stage4", + Self::ExeStage5 => "EXE stage5", + Self::Pe32FourthStage => "PE32 fourth stage", + Self::Pe32FifthStage => "PE32 fifth stage", + Self::Pe32SeventhStage => "PE32 seventh stage", + Self::DllCodeBlock1 => "DLL code block 1", + Self::DllCodeBlock2 => "DLL code block 2", + Self::DllCodeBlock3 => "DLL code block 3", + Self::DllCodeBlock4 => "DLL code block 4", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BytecodeStage { + ExeStage4, + ExeStage5, + Pe32CustomDecryptor, + Pe32FileDecryptor, + DllPrimaryDecryptor, + DllSectionDecryptor, +} + +impl std::fmt::Display for BytecodeStage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExeStage4 => "EXE stage4", + Self::ExeStage5 => "EXE stage5", + Self::Pe32CustomDecryptor => "PE32 custom decryptor", + Self::Pe32FileDecryptor => "PE32 file decryptor", + Self::DllPrimaryDecryptor => "DLL primary decryptor", + Self::DllSectionDecryptor => "DLL section decryptor", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SectionPipeline { + ExePe32Plus, + ExePe32, + Dll, +} + +impl std::fmt::Display for SectionPipeline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::ExePe32Plus => "PE32+ EXE", + Self::ExePe32 => "PE32 EXE", + Self::Dll => "DLL", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DescriptorTable { + DllSectionBlocks, + DllZeroFill, +} + +impl std::fmt::Display for DescriptorTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::DllSectionBlocks => "DLL section-block", + Self::DllZeroFill => "DLL zero-fill", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum UnpackError { + #[error("input too short (need at least {required} bytes, got {actual})")] + InputTooShort { actual: usize, required: usize }, + + #[error("decrypted header magic mismatch (got 0x{found:08X})")] + HeaderMagicMismatch { found: u32 }, + + #[error("anchor field not found — corrupt data or wrong offset")] + AnchorNotFound, + + #[error("stage1 descriptor not found near anchor 0x{anchor:08X}")] + Stage1DescriptorNotFound { anchor: u32 }, + + #[error("stage2 field not found — corrupt data or wrong offset")] + Stage2NotFound, + + #[error("chk_src_start not found — corrupt data or wrong offset")] + ChkSrcStartNotFound, + + #[error("table_start not found — corrupt data or wrong offset")] + TableStartNotFound, + + #[error("{0} bytecode generation failed — corrupt data or wrong offset")] + BytecodeGenerationFailed(BytecodeStage), + + #[error("stage5 marker not found — this build's layout is not supported by this unpacker")] + Stage5MarkerNotFound, + + #[error("not a Crackproof-protected file")] + NotCrackproof, + + #[error("invalid PE header offset {offset} for {input_len}-byte input")] + InvalidPeOffset { offset: i64, input_len: usize }, + + #[error("DLL pipeline requires PE32+ optional-header magic, got 0x{found:04X}")] + UnsupportedDllPeMagic { found: u16 }, + + #[error( + "DLL primary descriptor address 0x{address:08X} is below layout base 0x{minimum:08X} or outside {image_len}-byte image" + )] + InvalidDllPrimaryDescriptor { + address: u32, + minimum: u32, + image_len: usize, + }, + + #[error("invalid SizeOfImage {size}; expected 1..={max}")] + InvalidImageSize { size: i64, max: u64 }, + + #[error( + "{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})" + )] + BufferRangeOutOfBounds { + operation: BufferOperation, + offset: usize, + size: usize, + buffer_len: usize, + }, + + #[error( + "{table} descriptor out of bounds (offset {offset}, size 16, image length {image_len})" + )] + DescriptorOutOfBounds { + table: DescriptorTable, + offset: usize, + image_len: usize, + }, + + #[error("PE32 tbl not found — corrupt data or wrong offset")] + Pe32TblNotFound, + + #[error("PE32 thirdStage decrypt failed — corrupt data or wrong offset")] + Pe32ThirdStageFailed, + + #[error("PE32 customDecryptor not found in sevenStage")] + Pe32CustomDecryptorNotFound, + + #[error("PE32 eighthStageKey not found")] + Pe32EighthKeyNotFound, + + #[error("PE32 file LFSR not found in eighthStage")] + Pe32FileLfsrNotFound, + + #[error("{stage} decompression failed: {reason}")] + StageDecompressionFailed { + stage: DecompressionStage, + reason: DecompressionFailure, + }, + + #[error("{pipeline} section block {block} decompression failed")] + SectionDecompressionFailed { + pipeline: SectionPipeline, + block: usize, + }, + + #[error("AES key schedule is outside the image at offset {offset}")] + InvalidAesKeySchedule { offset: u32 }, + + #[error("Huffman table is outside the image at offset {offset}")] + InvalidHuffmanTable { offset: u32 }, + + #[error("DLL pipeline failed: {dll}; EXE fallback failed: {exe}")] + PipelineFallbackFailed { + dll: Box, + exe: Box, + }, + + #[error( + "PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})" + )] + Pe32SecondStageRangeInvalid { + offset: u32, + size: u32, + image_len: usize, + }, + + #[error("PE32 relocation-data descriptor not found")] + Pe32RelocationDataNotFound, + + #[error("file decryptor candidate failed structural validation")] + FileDecryptorValidationFailed, + + #[error("PE32 memory image could not be rebuilt as a file-layout PE")] + Pe32OutputLayoutInvalid, + + #[error("internal panic at {file}:{line}:{column}: {message}")] + InternalPanic { + message: String, + file: String, + line: u32, + column: u32, + }, +} + +impl From for UnpackError { + fn from(error: senbei_crypto::Error) -> Self { + match error { + senbei_crypto::Error::BufferRangeOutOfBounds { + operation, + offset, + size, + buffer_len, + } => Self::BufferRangeOutOfBounds { + operation, + offset, + size, + buffer_len, + }, + } + } +} diff --git a/senbei-pe/src/engine/exe/mod.rs b/senbei-pe/src/engine/exe/mod.rs new file mode 100644 index 0000000..bf70ed1 --- /dev/null +++ b/senbei-pe/src/engine/exe/mod.rs @@ -0,0 +1,3 @@ +mod pipeline; + +pub use pipeline::*; diff --git a/src/unpacker/exe.rs b/senbei-pe/src/engine/exe/pipeline.rs similarity index 59% rename from src/unpacker/exe.rs rename to senbei-pe/src/engine/exe/pipeline.rs index 2dfd5ad..cac25da 100644 --- a/src/unpacker/exe.rs +++ b/senbei-pe/src/engine/exe/pipeline.rs @@ -1,285 +1,11 @@ -use super::bytecode::{Op, OpsLut, generate}; -use super::primitives; -use super::primitives::*; +use senbei_crypto::bytecode::{Op, OpsLut, generate}; +use senbei_crypto::primitives; +use senbei_crypto::primitives::*; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DecompressionStage { - ExeStage3, - ExeStage3Secondary, - ExeStage4, - ExeStage5, - Pe32FourthStage, - Pe32FifthStage, - Pe32SeventhStage, - DllCodeBlock1, - DllCodeBlock2, - DllCodeBlock3, - DllCodeBlock4, -} +use super::super::error::*; +use super::super::layout::{self, *}; -impl std::fmt::Display for DecompressionStage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::ExeStage3 => "EXE stage3", - Self::ExeStage3Secondary => "EXE secondary stage3", - Self::ExeStage4 => "EXE stage4", - Self::ExeStage5 => "EXE stage5", - Self::Pe32FourthStage => "PE32 fourth stage", - Self::Pe32FifthStage => "PE32 fifth stage", - Self::Pe32SeventhStage => "PE32 seventh stage", - Self::DllCodeBlock1 => "DLL code block 1", - Self::DllCodeBlock2 => "DLL code block 2", - Self::DllCodeBlock3 => "DLL code block 3", - Self::DllCodeBlock4 => "DLL code block 4", - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum DecompressionFailure { - #[error("compressed source size {size} exceeds limit {max}")] - SourceTooLarge { size: u32, max: u64 }, - #[error("Huffman code length {bits} is invalid")] - InvalidCodeLength { bits: u8 }, - #[error("Huffman tree traversal exceeded 64 levels")] - HuffmanTraversalLimit, - #[error("pending length accumulator overflowed at {pending}")] - PendingLengthOverflow { pending: u32 }, - #[error("output step {step} at byte {written} exceeds expected size {expected}")] - OutputOverflow { - written: u32, - step: u32, - expected: u32, - }, - #[error("run-fill width {width} reads before output offset 0x{destination:08X}")] - RunFillBeforeOutput { width: u32, destination: u32 }, - #[error("run-fill width {width} is unsupported")] - InvalidRunFillWidth { width: u32 }, - #[error("back-reference distance {distance} exceeds {written} written bytes")] - InvalidBackReference { distance: u32, written: u32 }, - #[error("Huffman symbol consumed no input and produced no output")] - NoProgress, - #[error( - "output size mismatch (wrote {written}/{expected} bytes after consuming {consumed}/{source_size})" - )] - OutputSizeMismatch { - written: u32, - expected: u32, - consumed: u32, - source_size: u32, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BytecodeStage { - ExeStage4, - ExeStage5, - Pe32CustomDecryptor, - Pe32FileDecryptor, - DllPrimaryDecryptor, - DllSectionDecryptor, -} - -impl std::fmt::Display for BytecodeStage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::ExeStage4 => "EXE stage4", - Self::ExeStage5 => "EXE stage5", - Self::Pe32CustomDecryptor => "PE32 custom decryptor", - Self::Pe32FileDecryptor => "PE32 file decryptor", - Self::DllPrimaryDecryptor => "DLL primary decryptor", - Self::DllSectionDecryptor => "DLL section decryptor", - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SectionPipeline { - ExePe32Plus, - ExePe32, - Dll, -} - -impl std::fmt::Display for SectionPipeline { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::ExePe32Plus => "PE32+ EXE", - Self::ExePe32 => "PE32 EXE", - Self::Dll => "DLL", - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DescriptorTable { - DllSectionBlocks, - DllZeroFill, -} - -impl std::fmt::Display for DescriptorTable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::DllSectionBlocks => "DLL section-block", - Self::DllZeroFill => "DLL zero-fill", - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BufferOperation { - Read, - CopySource, - CopyDestination, - ZeroFill, -} - -impl std::fmt::Display for BufferOperation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Read => "read", - Self::CopySource => "copy source", - Self::CopyDestination => "copy destination", - Self::ZeroFill => "zero-fill", - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum UnpackError { - #[error("input too short (need at least {required} bytes, got {actual})")] - InputTooShort { actual: usize, required: usize }, - - #[error("decrypted header magic mismatch (got 0x{found:08X})")] - HeaderMagicMismatch { found: u32 }, - - #[error("anchor field not found — corrupt data or wrong offset")] - AnchorNotFound, - - #[error("stage1 descriptor not found near anchor 0x{anchor:08X}")] - Stage1DescriptorNotFound { anchor: u32 }, - - #[error("stage2 field not found — corrupt data or wrong offset")] - Stage2NotFound, - - #[error("chk_src_start not found — corrupt data or wrong offset")] - ChkSrcStartNotFound, - - #[error("table_start not found — corrupt data or wrong offset")] - TableStartNotFound, - - #[error("{0} bytecode generation failed — corrupt data or wrong offset")] - BytecodeGenerationFailed(BytecodeStage), - - #[error("stage5 marker not found — this build's layout is not supported by this unpacker")] - Stage5MarkerNotFound, - - #[error("not a Crackproof-protected file")] - NotCrackproof, - - #[error("invalid PE header offset {offset} for {input_len}-byte input")] - InvalidPeOffset { offset: i64, input_len: usize }, - - #[error("DLL pipeline requires PE32+ optional-header magic, got 0x{found:04X}")] - UnsupportedDllPeMagic { found: u16 }, - - #[error( - "DLL primary descriptor address 0x{address:08X} is below layout base 0x{minimum:08X} or outside {image_len}-byte image" - )] - InvalidDllPrimaryDescriptor { - address: u32, - minimum: u32, - image_len: usize, - }, - - #[error("invalid SizeOfImage {size}; expected 1..={max}")] - InvalidImageSize { size: i64, max: u64 }, - - #[error( - "{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})" - )] - BufferRangeOutOfBounds { - operation: BufferOperation, - offset: usize, - size: usize, - buffer_len: usize, - }, - - #[error( - "{table} descriptor out of bounds (offset {offset}, size 16, image length {image_len})" - )] - DescriptorOutOfBounds { - table: DescriptorTable, - offset: usize, - image_len: usize, - }, - - #[error("PE32 tbl not found — corrupt data or wrong offset")] - Pe32TblNotFound, - - #[error("PE32 thirdStage decrypt failed — corrupt data or wrong offset")] - Pe32ThirdStageFailed, - - #[error("PE32 customDecryptor not found in sevenStage")] - Pe32CustomDecryptorNotFound, - - #[error("PE32 eighthStageKey not found")] - Pe32EighthKeyNotFound, - - #[error("PE32 file LFSR not found in eighthStage")] - Pe32FileLfsrNotFound, - - #[error("{stage} decompression failed: {reason}")] - StageDecompressionFailed { - stage: DecompressionStage, - reason: DecompressionFailure, - }, - - #[error("{pipeline} section block {block} decompression failed")] - SectionDecompressionFailed { - pipeline: SectionPipeline, - block: usize, - }, - - #[error("AES key schedule is outside the image at offset {offset}")] - InvalidAesKeySchedule { offset: u32 }, - - #[error("Huffman table is outside the image at offset {offset}")] - InvalidHuffmanTable { offset: u32 }, - - #[error("DLL pipeline failed: {dll}; EXE fallback failed: {exe}")] - PipelineFallbackFailed { - dll: Box, - exe: Box, - }, - - #[error( - "PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})" - )] - Pe32SecondStageRangeInvalid { - offset: u32, - size: u32, - image_len: usize, - }, - - #[error("PE32 relocation-data descriptor not found")] - Pe32RelocationDataNotFound, - - #[error("file decryptor candidate failed structural validation")] - FileDecryptorValidationFailed, - - #[error("PE32 memory image could not be rebuilt as a file-layout PE")] - Pe32OutputLayoutInvalid, - - #[error("internal panic at {file}:{line}:{column}: {message}")] - InternalPanic { - message: String, - file: String, - line: u32, - column: u32, - }, -} +mod pe32; pub fn unpack(input: &[u8]) -> Result, UnpackError> { unpack_v(input, false) @@ -324,7 +50,7 @@ pub fn unpack_v(input: &[u8], verbose: bool) -> Result, UnpackError> { // separate `decompressed` buffer), so the unpacker borrows it directly — no // owned copy is made here. catch_unwind uses AssertUnwindSafe, so a borrowing // (non-'static) closure is fine. - super::catch_unpack(move || Unpacker::run(input, verbose)) + super::super::catch_unpack(move || Unpacker::run(input, verbose)) } struct Unpacker<'a> { @@ -600,7 +326,7 @@ impl<'a> Unpacker<'a> { println!(" info[6] end_mark = 0x{:08X}", u.info[6]); println!(" info[7] = 0x{:08X}", u.info[7]); } - if !super::is_supported_magic(u.info[1]) { + if !super::super::is_supported_magic(u.info[1]) { return Err(UnpackError::HeaderMagicMismatch { found: u.info[1] }); } @@ -615,10 +341,10 @@ impl<'a> Unpacker<'a> { }); } let size_of_image = get_u32(u.file_data, pe_off.wrapping_add(80)); - if size_of_image == 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE { + if size_of_image == 0 || size_of_image as u64 > super::super::MAX_IMAGE_SIZE { return Err(UnpackError::InvalidImageSize { size: i64::from(size_of_image), - max: super::MAX_IMAGE_SIZE, + max: super::super::MAX_IMAGE_SIZE, }); } u.decompressed = vec![0u8; size_of_image as usize]; @@ -1210,7 +936,7 @@ impl<'a> Unpacker<'a> { // structurally. fileCS stays at bc2_off-0x58 as in the old layout. if new_layout { let compress_data_offset = (!get_u32(u.file_data, 0x1080)).wrapping_add(0x1000); - let slots = primitives::discover_eighth_slots( + let slots = layout::discover_eighth_slots( &u.decompressed, stage5_field, stage5_dlen, @@ -1386,7 +1112,7 @@ impl<'a> Unpacker<'a> { } Ok(()) }; - super::parallel::parallel_for(&mut u.decompressed, &spans, 1, do_block)?; + super::super::parallel::parallel_for(&mut u.decompressed, &spans, 1, do_block)?; } loop { u.decrypt_data5(walk4, 16); @@ -1578,9 +1304,7 @@ impl<'a> Unpacker<'a> { let dd8_shift: u32 = match std::env::var("DD8_SHIFT").ok().and_then(|s| s.parse().ok()) { Some(s) => s, - None => { - primitives::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]) - } + None => layout::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]), }; if dd8_shift != 99 { let mut page = text_va >> 12; @@ -1700,7 +1424,7 @@ impl<'a> Unpacker<'a> { let shift = match std::env::var("DD8_SHIFT").ok().and_then(|s| s.parse().ok()) { Some(s) => s, None => { - primitives::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]) + layout::select_dd8_shift(&u.decompressed, text_va, text_size, u.info[3]) } }; if shift != 99 { @@ -1888,7 +1612,8 @@ impl<'a> Unpacker<'a> { self.aes_decrypt(dst, len, self.key_offsets[2]); for k in 0..len { let idx = (dst + k) as usize; - self.decompressed[idx] = super::bytecode::apply(ops, self.decompressed[idx]); + self.decompressed[idx] = + senbei_crypto::bytecode::apply(ops, self.decompressed[idx]); } let ok = primitives::decompress( &mut self.decompressed, @@ -1980,7 +1705,8 @@ impl<'a> Unpacker<'a> { self.aes_decrypt(dst2, s_sz2, self.key_offsets[2]); for k in 0..s_sz2 { let idx = (dst2 + k) as usize; - self.decompressed[idx] = super::bytecode::apply(&ops, self.decompressed[idx]); + self.decompressed[idx] = + senbei_crypto::bytecode::apply(&ops, self.decompressed[idx]); } let ok = primitives::decompress( &mut self.decompressed, @@ -2141,1064 +1867,6 @@ impl<'a> Unpacker<'a> { self.decompressed[d..d + 24].copy_from_slice(&self.file_data[src..src + 24]); true } - - /// PE32 (32-bit) unpack pipeline. The shared Stage 1/2 setup (info decrypt, - /// payload decrypt, raw copy, header restore) has already run in `run()` - /// before dispatch; this takes over from "Locating shell offsets". - fn run_pe32(&mut self, pe_off: u32, verbose: bool) -> Result, UnpackError> { - let info = self.info; - let info3 = info[3]; - - // advance_key: replays the packer's per-iteration key walk. - let advance_key = |mut key: u32, iterations: u32| -> u32 { - for m in 0..iterations { - let bound = (m + 1).wrapping_mul(25) << 2; - let mut n: u32 = 1; - while n <= bound { - key = key.wrapping_add(n); - n += 1; - } - } - key - }; - - // ---- Locate tbl in shell ---- - let tbl = primitives::find_tbl_pe32(&self.decompressed, &info) - .ok_or(UnpackError::Pe32TblNotFound)?; - if verbose { - println!("[3/9] Locating config layout (PE32)..."); - println!(" tbl = 0x{:X}", tbl); - } - - // ---- PE header restore ---- - let val_bc = get_u32(&self.decompressed, tbl.wrapping_add(0xBC)); - let val_c8 = get_u32(&self.decompressed, tbl.wrapping_add(0xC8)); - let val_cc = get_u32(&self.decompressed, tbl.wrapping_add(0xCC)); - write_u32(&mut self.decompressed, pe_off.wrapping_add(0x80), val_bc); - write_u32(&mut self.decompressed, pe_off.wrapping_add(0x88), val_c8); - write_u32(&mut self.decompressed, pe_off.wrapping_add(0x8C), val_cc); - write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB0), 0); - write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB4), 0); - - // ---- Header-independent checksum inputs ---- - let first_stage_cs = self.calculate_checksum(tbl.wrapping_add(0xA8)); - let second_stage_key = get_u32(&self.decompressed, tbl.wrapping_add(0x40)); - - // ---- Stage 3: SecondStage ---- - // - // ss_key = headerChecksum ^ firstStageCS ^ secondStageKey, where the - // header checksum (a XOR of crc32(region)^size over the sub-regions at - // tbl+0x58) is taken over the *original* pre-pack PE header. For EXEs the - // import/resource restore above reconstructs that header exactly. Native - // DLLs additionally carry a packer-added BaseReloc data-directory entry - // (dir 5) that was absent from the checksummed original, so the header - // checksum only matches once that entry is treated as zero. EXEs have no - // dir-5 entry, so zeroing it is a no-op for them. - // - // Rather than branch on EXE-vs-DLL, try the header as-is and, on failure, - // with the BaseReloc entry zeroed; keep whichever ss_key decrypts a - // SecondStage whose ThirdStage (off,size) pair lands inside the image. - // This uses the same shift/key trial-and-validate the later stages - // already use, and keeps EXE output byte-identical (the as-is variant - // wins first). - let ss_pair = tbl.wrapping_add(0x98); - let ss = get_u32(&self.decompressed, ss_pair); - let ss_size = get_u32(&self.decompressed, ss_pair.wrapping_add(4)); - let ss_shift = ss_size.wrapping_sub(0xBC0); - // Back up the SecondStage ciphertext so a failed trial can be retried. - let ss_lo = ss as usize; - let ss_hi = ss_lo.wrapping_add(ss_size as usize); - if ss_hi < ss_lo || ss_hi > self.decompressed.len() { - return Err(UnpackError::Pe32SecondStageRangeInvalid { - offset: ss, - size: ss_size, - image_len: self.decompressed.len(), - }); - } - let ss_ct: Vec = self.decompressed[ss_lo..ss_hi].to_vec(); - // PE32 data dir 5 (BaseReloc) = optional_header(pe+24) + 0x60 + 5*8 = pe+0xA0. - let reloc_dir = pe_off.wrapping_add(0xA0); - let len = self.decompressed.len() as u64; - let pair_off = 0xB8Cu32.wrapping_add(ss_shift); - let mut found = false; - // Holds the winning variant's header checksum; the later stages - // (Forth/Fifth/Seven/Eighth) reuse it as a key component. - let mut header_checksum: u32 = 0; - for zero_reloc in [false, true] { - if zero_reloc { - write_u32(&mut self.decompressed, reloc_dir, 0); - write_u32(&mut self.decompressed, reloc_dir.wrapping_add(4), 0); - } - let mut hcs_addr = tbl.wrapping_add(0x58); - header_checksum = 0; - while get_u32(&self.decompressed, hcs_addr.wrapping_add(4)) != 0 { - header_checksum ^= self.calculate_checksum(hcs_addr); - hcs_addr = hcs_addr.wrapping_add(8); - } - let ss_key = header_checksum ^ first_stage_cs ^ second_stage_key; - self.decompressed[ss_lo..ss_hi].copy_from_slice(&ss_ct); - self.decrypt_data3(ss_pair, ss_key, 21); - // Validate: the ThirdStage (off,size) pair must reference the image. - let pair = ss.wrapping_add(pair_off); - let off = get_u32(&self.decompressed, pair) as u64; - let sz = get_u32(&self.decompressed, pair.wrapping_add(4)) as u64; - if off > 0x1000 && off < len && sz >= 4 && off.saturating_add(sz) <= len { - found = true; - break; - } - } - if !found { - return Err(UnpackError::Pe32RelocationDataNotFound); - } - if verbose { - println!( - " ss = 0x{:08X}, size = 0x{:X}, shift = 0x{:X}", - ss, ss_size, ss_shift - ); - } - - // ---- PE32 fixed offsets ---- - let third_key_off = 0x968u32.wrapping_add(ss_shift); - let forth_key_off = 0x964u32.wrapping_add(ss_shift); - let cs_base_off = 0x96Cu32.wrapping_add(ss_shift); - let dp_base_off = 0xA9Cu32.wrapping_add(ss_shift); - - // ---- Stage 4: ThirdStage (brute-force the rotate shift) ---- - let third_pair_off = 0xB8Cu32.wrapping_add(ss_shift); - let key = get_u32(&self.decompressed, ss.wrapping_add(third_key_off)); - let pair_addr = ss.wrapping_add(third_pair_off); - let ts_addr = get_u32(&self.decompressed, pair_addr); - let ts_size_raw = get_u32(&self.decompressed, pair_addr.wrapping_add(4)); - let backup: Vec = - self.decompressed[ts_addr as usize..(ts_addr + ts_size_raw) as usize].to_vec(); - let mut info_table: Option = None; - let mut keys_addr: u32 = 0; - let mut ts: u32 = 0; - for &shift in &[19u32, 21, 17, 23, 15, 25, 13, 11] { - self.decompressed[ts_addr as usize..(ts_addr + ts_size_raw) as usize] - .copy_from_slice(&backup); - write_u32(&mut self.decompressed, pair_addr, ts_addr); - write_u32( - &mut self.decompressed, - pair_addr.wrapping_add(4), - ts_size_raw, - ); - self.decrypt_data3(pair_addr, key, shift); - let mut off = 0u32; - while off + 32 < ts_size_raw { - let t0 = get_u32(&self.decompressed, ts_addr.wrapping_add(off)); - if t0 == 1 || t0 == 0x11 { - let t1 = get_u32(&self.decompressed, ts_addr.wrapping_add(off + 16)); - if t1 == 2 { - let addr0 = get_u32(&self.decompressed, ts_addr.wrapping_add(off + 4)); - if 0x1000 < addr0 && (addr0 as usize) < self.decompressed.len() { - let it = ts_addr.wrapping_add(off); - info_table = Some(it); - keys_addr = it.wrapping_sub(0x58); - ts = ts_addr; - break; - } - } - } - off = off.wrapping_add(4); - } - if info_table.is_some() { - break; - } - } - let info_table = info_table.ok_or(UnpackError::Pe32ThirdStageFailed)?; - if verbose { - println!("[4/9] Decrypting stages (PE32)..."); - println!( - " thirdStage start = 0x{:X}, infoTable = 0x{:X}", - ts, info_table - ); - } - - // ---- Process infoTable ---- - let mut it_addr = info_table; - for _ in 0..2 { - let tval = get_u32(&self.decompressed, it_addr); - if tval == 1 || tval == 0x11 { - self.decrypt_data4(it_addr.wrapping_add(4)); - } else if tval == 2 { - let mut copy_addr = get_u32(&self.decompressed, it_addr.wrapping_add(4)); - loop { - self.decrypt_data5(copy_addr, 16); - let s_a = get_u32(&self.decompressed, copy_addr); - let s_sz = get_u32(&self.decompressed, copy_addr.wrapping_add(4)); - let d_a = get_u32(&self.decompressed, copy_addr.wrapping_add(8)); - let d_sz = get_u32(&self.decompressed, copy_addr.wrapping_add(12)); - copy_addr = copy_addr.wrapping_add(16); - if s_sz == 0 { - break; - } - if s_a != 0 && d_a != 0 && d_sz == s_sz { - let sa = s_a as usize; - let da = d_a as usize; - let n = s_sz as usize; - self.decompressed.copy_within(sa..sa + n, da); - } - } - } - it_addr = it_addr.wrapping_add(16); - } - - // ---- keyOffsets ---- - let mut ka = keys_addr; - for k in 0..2usize { - let mut ka2 = ka; - for l in 0..2usize { - self.decrypt_data4(ka2); - self.key_offsets[k * 2 + l] = get_u32(&self.decompressed, ka2); - ka2 = ka2.wrapping_add(8); - } - ka = ka.wrapping_add(32); - } - - // ---- Checksum addresses ---- - let second_stage_cs_addr = tbl.wrapping_add(0xB0); - let forth_stage_cs_addr = ss.wrapping_add(cs_base_off); - let fifth_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x08); - let seven_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x10); - - // ---- ForthStage ---- - let second_stage_cs = self.calculate_checksum(second_stage_cs_addr); - let forth_stage_key = advance_key( - get_u32(&self.decompressed, ss.wrapping_add(forth_key_off)), - 4, - ); - let dp_base = ss.wrapping_add(dp_base_off); - let forth_addr = dp_base.wrapping_add(0x40); - let fk = header_checksum ^ second_stage_cs ^ forth_stage_key; - if let Err(reason) = self.decrypt_and_decompress_data(forth_addr, fk, None) { - return Err(UnpackError::StageDecompressionFailed { - stage: DecompressionStage::Pe32FourthStage, - reason, - }); - } - - // ---- FifthStage ---- - let fifth_addr = dp_base.wrapping_add(0x50); - let forth_cs = self.calculate_checksum(forth_stage_cs_addr); - let forth_region_off = get_u32(&self.decompressed, forth_stage_cs_addr); - let forth_region_sz = get_u32(&self.decompressed, forth_stage_cs_addr.wrapping_add(4)); - let fifth_key = get_u32( - &self.decompressed, - forth_region_off - .wrapping_add(forth_region_sz) - .wrapping_sub(4), - ); - let fk5 = header_checksum ^ forth_cs ^ fifth_key; - if let Err(reason) = self.decrypt_and_decompress_data(fifth_addr, fk5, None) { - return Err(UnpackError::StageDecompressionFailed { - stage: DecompressionStage::Pe32FifthStage, - reason, - }); - } - - // ---- SevenStage ---- - let seven_addr = dp_base.wrapping_add(0x70); - let seven_dsz = get_u32(&self.decompressed, seven_addr.wrapping_add(12)); - let fifth_cs = self.calculate_checksum(fifth_stage_cs_addr); - let cs1_addr = get_u32( - &self.decompressed, - ss.wrapping_add(cs_base_off).wrapping_add(0x08), - ); - let cs1_size = get_u32( - &self.decompressed, - ss.wrapping_add(cs_base_off) - .wrapping_add(0x08) - .wrapping_add(4), - ); - let seven_key = !get_u32( - &self.decompressed, - cs1_addr.wrapping_add(cs1_size).wrapping_sub(0x10), - ); - let fk7 = header_checksum ^ fifth_cs ^ seven_key; - if let Err(reason) = self.decrypt_and_decompress_data(seven_addr, fk7, None) { - return Err(UnpackError::StageDecompressionFailed { - stage: DecompressionStage::Pe32SeventhStage, - reason, - }); - } - - // ---- EighthStage ---- - let seven_start_actual = get_u32(&self.decompressed, seven_addr); - if verbose { - println!("[5/9] Decrypting eighthStage (PE32)..."); - println!( - " sevenStart = 0x{:X}, sevenDsz = 0x{:X}", - seven_start_actual, seven_dsz - ); - } - // Locate the customDecryptor LFSR block (scan backward from middle, then - // forward as fallback). - let scan_start = seven_dsz / 2; - let custom_dec_off = primitives::find_lfsr_block( - &self.decompressed, - seven_start_actual, - seven_dsz, - scan_start, - true, - ) - .or_else(|| { - primitives::find_lfsr_block(&self.decompressed, seven_start_actual, seven_dsz, 0, false) - }) - .ok_or(UnpackError::Pe32CustomDecryptorNotFound)?; - let custom_dec_addr = seven_start_actual.wrapping_add(custom_dec_off); - self.decrypt_data6(custom_dec_addr); - let custom_ops = generate(&self.decompressed, custom_dec_addr).ok_or( - UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32CustomDecryptor), - )?; - - let seven_cs = self.calculate_checksum(seven_stage_cs_addr); - let eighth_addr = dp_base.wrapping_add(0xC0); - let eighth_dsz = get_u32(&self.decompressed, eighth_addr.wrapping_add(12)); - let eighth_src = get_u32(&self.decompressed, eighth_addr); - let eighth_ssz = get_u32(&self.decompressed, eighth_addr.wrapping_add(4)); - let eighth_backup: Vec = - self.decompressed[eighth_src as usize..(eighth_src + eighth_ssz) as usize].to_vec(); - let eighth_pair_bak: Vec = - self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize].to_vec(); - let data_len = self.decompressed.len() as u32; - - // Build the eighthStageKey candidate list (offsets relative to - // sevenStart) using gap heuristics + scan. - let mut candidates: Vec = Vec::new(); - let push_cand = |c: &mut Vec, off: u32| { - if !c.contains(&off) { - c.push(off); - } - }; - for &end_gap in &[0xD0u32, 0xC0, 0xE0, 0xB0, 0xA0, 0xF0, 0x100] { - if end_gap <= seven_dsz { - let off = seven_dsz - end_gap; - if off < seven_dsz { - let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); - if val != 0 && val != 0xCCCC_CCCC { - push_cand(&mut candidates, off); - } - } - } - } - for &gap in &[ - 0x70u32, 0xD0, 0x28, 0x50, 0x48, 0x30, 0x40, 0x58, 0x60, 0x20, 0x38, 0x80, 0x90, 0xA0, - 0xB0, - ] { - if gap <= custom_dec_off { - let off = custom_dec_off - gap; - if off + 4 <= seven_dsz && !candidates.contains(&off) { - let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); - if val != 0 && val != 0xCCCC_CCCC { - push_cand(&mut candidates, off); - } - } - } - } - let scan_lo = custom_dec_off.saturating_sub(0x100); - let mut off = scan_lo; - while off < custom_dec_off { - if !candidates.contains(&off) { - let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); - let all_printable = (0..4u32).all(|i| { - let b = (val >> (i * 8)) & 0xFF; - (32..127).contains(&b) - }); - if val != 0 && val != 0xCCCC_CCCC && !all_printable { - push_cand(&mut candidates, off); - } - } - off = off.wrapping_add(4); - } - - let k1 = self.key_offsets[1]; - let k3 = self.key_offsets[3]; - let mut eighth_ok = false; - for ek_off in candidates { - self.decompressed[eighth_src as usize..(eighth_src + eighth_ssz) as usize] - .copy_from_slice(&eighth_backup); - self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize] - .copy_from_slice(&eighth_pair_bak); - let raw = get_u32(&self.decompressed, seven_start_actual.wrapping_add(ek_off)); - let test_key = advance_key(raw, 3); - let fk8 = header_checksum ^ fifth_cs ^ seven_cs ^ test_key; - let result = primitives::decrypt_and_decompress_data( - &mut self.decompressed, - eighth_addr, - fk8, - k1, - k3, - Some(&custom_ops), - ); - if result { - let est = get_u32(&self.decompressed, eighth_addr); - if 0x1000 < est && est < data_len { - eighth_ok = true; - break; - } - } - } - if !eighth_ok { - return Err(UnpackError::Pe32EighthKeyNotFound); - } - let eighth_start = get_u32(&self.decompressed, eighth_addr); - if verbose { - println!( - " eighthStart = 0x{:08X}, dsz = 0x{:X}", - eighth_start, eighth_dsz - ); - } - - // ---- Final processing offsets (anchored on the eighthStage config cluster) ---- - // - // The eighthStage holds a config cluster — importTable, fileCS, - // compressedInfo, zeroList — at fixed offsets from a cluster base - // (base+0x18 / +0x30 / +0x40 / +0x48) with the fileLFSR at +0x4B4. - // Classic builds stamp a 0x00007679 dword at that base; native DLLs and - // some older PE32 EXEs (ss_size=0xBE8) omit the stamp. - // Locate the cluster by stamp when present (validated by fileCS at - // base+0x30 pointing past info[3]); otherwise fall back to finding the - // fileCS slot by shape — (addr, size) with addr just past info[3] and a - // small 16-aligned size — and back-derive base = fileCS_off - 0x30. - // Hardcoded eighthStart-relative constants remain as a last-resort - // fallback for builds where neither discovery path fires. - let marker = { - let mut m: Option = None; - let hi = eighth_dsz.saturating_sub(0x4C); - let mut o = 0u32; - while o < hi { - if get_u32(&self.decompressed, eighth_start.wrapping_add(o)) == 0x7679 { - let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 0x30)); - if fc > info3 && (fc as usize) < self.decompressed.len() { - m = Some(o); - break; - } - } - o = o.wrapping_add(4); - } - if m.is_none() { - // fileCS-shaped slot: addr in (info3, info3+0x2000], size in - // 0x10..=0x200 and 16-aligned. Prefer the candidate whose addr - // is closest to (but past) info3 — matches every observed - // build (one PE32 EXE family dist ~0x1C0, another ~0x1A0). - let mut best: Option<(u32 /*dist*/, u32 /*off*/)> = None; - let mut o = 0u32; - let dlen = self.decompressed.len() as u32; - while o + 8 <= eighth_dsz.saturating_sub(0x4B4u32.saturating_sub(0x30)) { - let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o)); - let sz = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 4)); - if fc > info3 - && fc <= info3.wrapping_add(0x2000) - && fc < dlen - && (0x10..=0x200).contains(&sz) - && (sz & 0xF) == 0 - { - // Cluster base must leave room for the +0x4B4 LFSR slot - // (even if the exact LFSR is later adjusted by scan). - if o >= 0x30 { - let base = o - 0x30; - if base.wrapping_add(0x4C) <= eighth_dsz { - let dist = fc - info3; - match best { - None => best = Some((dist, base)), - Some((bd, _)) if dist < bd => best = Some((dist, base)), - _ => {} - } - } - } - } - o = o.wrapping_add(4); - } - if let Some((dist, base)) = best { - if verbose { - println!( - " pe32 cluster via fileCS (no 0x7679): base=+0x{:X} dist_info3=0x{:X}", - base, dist - ); - } - m = Some(base); - } - } - m - }; - let (off_import_table, off_file_cs, off_compressed_info, off_zero_list, off_file_lfsr) = - match marker { - Some(m) => (m + 0x18, m + 0x30, m + 0x40, m + 0x48, m + 0x4B4), - None => ( - 0x3C50u32.wrapping_add(ss_shift), - 0x3C68u32.wrapping_add(ss_shift), - 0x3C78u32.wrapping_add(ss_shift), - 0x3C80u32.wrapping_add(ss_shift), - 0x40ECu32.wrapping_add(ss_shift), - ), - }; - - // ---- File checksums (permanent decrypt) ---- - let file_cs_addr_ptr = eighth_start.wrapping_add(off_file_cs); - let mut file_cs_addr = get_u32(&self.decompressed, file_cs_addr_ptr); - let file_cs_size = get_u32(&self.decompressed, file_cs_addr_ptr.wrapping_add(4)); - if file_cs_size > 0 { - let file_cs_end = file_cs_addr.wrapping_add(file_cs_size); - while file_cs_addr < file_cs_end { - self.decrypt_data5(file_cs_addr, 16); - file_cs_addr = file_cs_addr.wrapping_add(16); - } - } else { - while get_u32(&self.decompressed, file_cs_addr.wrapping_add(4)) != 0 { - self.decrypt_data5(file_cs_addr, 16); - file_cs_addr = file_cs_addr.wrapping_add(16); - } - } - - // ---- File decryptor LFSR ---- - // - // When the marker-relative off_file_lfsr is in range, try that slot - // first (exact). If it is not a valid LFSR block, trial-and-validate - // candidates from off_zero_list forward — required for older PE32 EXEs - // without the 0x7679 stamp where the expected slot is empty and a loose - // decoded[0]+0xC3 nearest-hit picks the wrong decryptor. Fall back to - // the legacy loose scan only if no candidate trial-decompresses. Native - // DLLs have a smaller eighthStage where off_file_lfsr lands out of range - // and use the same trial-validate scan from just past the cluster (else - // branch). - let lfsr_off = if off_file_lfsr.wrapping_add(96) <= eighth_dsz { - let mut lfsr_off = off_file_lfsr; - let exact = primitives::find_lfsr_block( - &self.decompressed, - eighth_start, - eighth_dsz, - off_file_lfsr, - false, - ); - if exact != Some(off_file_lfsr) { - // Prefer trial-and-validate (same as the DLL branch): a loose - // decoded[0]+0xC3 scan can land on coincidental LFSR-shaped - // blocks that decode to a wrong file_ops and scramble every - // compressed block. Observed on older PE32 EXEs without the - // 0x7679 cluster stamp: the expected slot is empty and the - // nearest loose hit is not the real decryptor. - let ci_slot = eighth_start.wrapping_add(off_compressed_info); - let mut scan = off_zero_list; - let mut chosen: Option = None; - let mut considered = 0u32; - while let Some(cand) = primitives::find_lfsr_block( - &self.decompressed, - eighth_start, - eighth_dsz, - scan, - false, - ) { - considered = considered.wrapping_add(1); - if self.pe32_file_lfsr_validates(eighth_start.wrapping_add(cand), ci_slot) { - chosen = Some(cand); - break; - } - scan = cand + 1; - } - if let Some(c) = chosen { - if verbose { - println!( - " pe32 fileLFSR via trial-validate: +0x{:X} (expected +0x{:X}, considered {})", - c, off_file_lfsr, considered - ); - } - lfsr_off = c; - } else { - // No candidate trial-decompresses: fail loudly. The old - // "legacy loose scan" picked the nearest LFSR-shaped block - // by offset distance without any validation — that is - // exactly how a wrong file_ops got applied to every data - // block (uncompressed blocks never enter the decompressor), - // producing a plausible but fully wrong image (the PE32 - // .text scramble root cause). Trial-and-validate or error. - return Err(UnpackError::Pe32FileLfsrNotFound); - } - } - lfsr_off - } else { - // Native DLL: the marker-relative off_file_lfsr (EXE-tuned, marker + - // 0x4B4) overshoots the smaller DLL eighthStage, so the exact slot is - // unavailable. A plain forward scan returns the FIRST valid-opcode - // block, but the DLL eighthStage contains coincidental valid-opcode - // blocks that decode to trivial programs (e.g. a constant byte add) - // ahead of the real file decryptor. A wrong file_ops corrupts the - // per-block translate (applied before decompression), so every data - // block fails to decompress. Enumerate every candidate forward and - // keep the first whose decoded file_ops actually decompresses the - // first compressed data block — trial-and-validate, same idea as the - // D1/D2 fixes. Non-DLL (EXE) builds never reach this branch. - let ci_slot = eighth_start.wrapping_add(off_compressed_info); - let mut scan = off_zero_list.wrapping_add(8); - let mut chosen: Option = None; - while let Some(cand) = primitives::find_lfsr_block( - &self.decompressed, - eighth_start, - eighth_dsz, - scan, - false, - ) { - if self.pe32_file_lfsr_validates(eighth_start.wrapping_add(cand), ci_slot) { - chosen = Some(cand); - break; - } - scan = cand + 1; - } - chosen.ok_or(UnpackError::Pe32FileLfsrNotFound)? - }; - let file_dec_addr = eighth_start.wrapping_add(lfsr_off); - self.decrypt_data6(file_dec_addr); - let file_ops = generate(&self.decompressed, file_dec_addr).ok_or( - UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32FileDecryptor), - )?; - - // ---- PE32 metadata: EP and data dirs from info[3] ---- - let test_val = get_u32(&self.decompressed, info3.wrapping_add(0x10)); - let metadata_ep: u32; - let mut metadata_dirs = [0u8; 128]; - if test_val > 0x10000 { - // Layout B - let s = info3.wrapping_add(0x10) as usize; - let backup_meta = self.decompressed[s..s + 0x290].to_vec(); - self.decrypt_data5(info3.wrapping_add(0x10), 0x290); - metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x20)); - let d = info3.wrapping_add(0x30) as usize; - metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]); - self.decompressed[s..s + 0x290].copy_from_slice(&backup_meta); - } else { - // Layout A - let s = info3.wrapping_add(0x40) as usize; - let backup_meta = self.decompressed[s..s + 144].to_vec(); - self.decrypt_data5(info3.wrapping_add(0x40), 144); - metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x40)); - let d = info3.wrapping_add(0x50) as usize; - metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]); - self.decompressed[s..s + 144].copy_from_slice(&backup_meta); - } - - // ---- Zero-out list (runs BEFORE decompression) ---- - let zero_list_addr = eighth_start.wrapping_add(off_zero_list); - let mut zero_ptr = get_u32(&self.decompressed, zero_list_addr); - loop { - self.decrypt_data5(zero_ptr, 16); - let src3 = get_u32(&self.decompressed, zero_ptr); - let s_sz3 = get_u32(&self.decompressed, zero_ptr.wrapping_add(4)); - zero_ptr = zero_ptr.wrapping_add(16); - if s_sz3 == 0 { - break; - } - if src3.wrapping_add(s_sz3) as usize > self.decompressed.len() { - break; - } - for b in &mut self.decompressed[src3 as usize..(src3 + s_sz3) as usize] { - *b = 0; - } - } - - // ---- File data decompression ---- - if verbose { - println!("[6/9] Loading and decompressing file data (PE32)..."); - } - let compress_data_offset = (!get_u32(self.file_data, 0x1080)).wrapping_add(0x1000); - let compressed_info_addr = eighth_start.wrapping_add(off_compressed_info); - let mut compressed_info = get_u32(&self.decompressed, compressed_info_addr); - // Pass 1 (sequential): position-keyed descriptor chain (decrypt_data5), - // terminated by a zero source-size record. - struct Blk { - src: u32, - ssz: u32, - dst: u32, - dsz: u32, - } - let mut blocks: Vec = Vec::new(); - loop { - self.decrypt_data5(compressed_info, 16); - let src2 = get_u32(&self.decompressed, compressed_info); - let s_sz2 = get_u32(&self.decompressed, compressed_info.wrapping_add(4)); - let dst2 = get_u32(&self.decompressed, compressed_info.wrapping_add(8)); - let d_sz2 = get_u32(&self.decompressed, compressed_info.wrapping_add(12)); - compressed_info = compressed_info.wrapping_add(16); - if s_sz2 == 0 { - break; - } - blocks.push(Blk { - src: src2, - ssz: s_sz2, - dst: dst2, - dsz: d_sz2, - }); - } - // Pass 2: independent per-block work over disjoint dst spans (see - // `parallel_for` for how the spans are carved safely). - { - let lut = OpsLut::new(&file_ops); - let clean = &self.file_data; - let ko = self.key_offsets; - let ks_snap = primitives::aes_schedule_snapshot(&self.decompressed, ko[2]) - .ok_or(UnpackError::InvalidAesKeySchedule { offset: ko[2] })?; - let tab_snap = primitives::huffman_table_snapshot(&self.decompressed, ko[0]) - .ok_or(UnpackError::InvalidHuffmanTable { offset: ko[0] })?; - let spans: Vec<(usize, usize)> = blocks - .iter() - .map(|b| { - let s = b.dst as usize; - (s, s + b.ssz.max(b.dsz) as usize) - }) - .collect(); - let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> { - let b = &blocks[i]; - let file_src = b.src.wrapping_add(compress_data_offset) as usize; - let rel = b.dst as usize - base; - let n = b.ssz as usize; - span[rel..rel + n].copy_from_slice(&clean[file_src..file_src + n]); - primitives::aes_decrypt_ks(&ks_snap, span, rel as u32, b.ssz); - lut.map_region(span, rel, n); - if b.ssz != b.dsz { - // decompress reports corruption (after partial writes) via - // its bool; surface it instead of shipping a garbage block. - if !primitives::decompress_tbl( - &tab_snap, span, rel as u32, rel as u32, b.ssz, b.dsz, - ) { - return Err(UnpackError::SectionDecompressionFailed { - pipeline: SectionPipeline::ExePe32, - block: i, - }); - } - } - Ok(()) - }; - super::parallel::parallel_for(&mut self.decompressed, &spans, 1, do_block)?; - } - - // ---- Section fixup ---- - self.decompressed[..0x1000].copy_from_slice(&self.file_data[..0x1000]); - let opt_hdr_size = get_u16(self.file_data, pe_off.wrapping_add(20)) as u32; - let sec_hdr_table = pe_off.wrapping_add(24).wrapping_add(opt_hdr_size); - let export_va = get_u32( - self.file_data, - pe_off - .wrapping_add(24) - .wrapping_add(opt_hdr_size) - .wrapping_sub(128), - ); - let export_size = get_u32( - self.file_data, - pe_off - .wrapping_add(24) - .wrapping_add(opt_hdr_size) - .wrapping_sub(124), - ); - let mut export_file_off: u32 = 0; - let mut text_off: u32 = 0; - let mut text_size: u32 = 0; - // Walk by NumberOfSections (PE has no zero-VS sentinel; a real - // VirtualSize==0 section would truncate these fixups early), stopping - // at the all-zero padding in case NumberOfSections is overstated. - let num_sections = get_u16(self.file_data, pe_off.wrapping_add(6)) as u32; - for i in 0..num_sections.min(96) { - let sec_hdr = sec_hdr_table.wrapping_add(i.wrapping_mul(40)); - if self.file_data[sec_hdr as usize..sec_hdr as usize + 8] - .iter() - .all(|&b| b == 0) - { - break; - } - let va = get_u32(self.file_data, sec_hdr.wrapping_add(12)); - let sz = get_u32(self.file_data, sec_hdr.wrapping_add(8)); - let f_off = get_u32(self.file_data, sec_hdr.wrapping_add(20)); - let name = section_name(self.file_data, sec_hdr); - if name.starts_with(".text") { - text_size = sz; - text_off = va; - } - if export_size != 0 - && export_va >= va - && export_va.wrapping_add(export_size) <= va.wrapping_add(sz) - { - export_file_off = export_va.wrapping_sub(va).wrapping_add(f_off); - } - write_u32(&mut self.decompressed, sec_hdr.wrapping_add(16), sz); - write_u32(&mut self.decompressed, sec_hdr.wrapping_add(20), va); - if name.starts_with(".idata") { - write_u32( - &mut self.decompressed, - sec_hdr.wrapping_add(36), - 0xC000_0040, - ); - } - } - if export_size != 0 && export_file_off != 0 { - let d = export_va as usize; - let s = export_file_off as usize; - let n = export_size as usize; - self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]); - } - - // ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ---- - // `select_dd8_formula_pe32` returns None when `.text` was not packer-dd8- - // encrypted (native DLLs leave it plaintext); applying dd8 there would - // scramble valid code, so skip it entirely in that case. - if text_size > 0 && text_off > 0 { - if let Some(big) = - primitives::select_dd8_formula_pe32(&self.decompressed, text_off, text_size) - { - if verbose { - println!( - "[7/9] Decrypting .text (PE32 dd8, formula={})...", - if big { "0x8000*(page+1)" } else { "page+1" } - ); - } - let num_pages = text_size / 0x1000; - for page in 0..num_pages { - let pk = if big { - 0x8000u32.wrapping_mul(page.wrapping_add(1)) - } else { - page.wrapping_add(1) - }; - let pa = text_off.wrapping_add(page.wrapping_mul(0x1000)); - let mut k = pk; - let rk = k.rotate_right(15); - k = rk; - for bi in 1..256u32 { - let rk = k.rotate_right(15); - let ri = rk.wrapping_add(bi); - k = ri.wrapping_add(bi); - let tidx = - pa.wrapping_add(bi.wrapping_mul(16)).wrapping_add(ri & 0xF) as usize; - self.decompressed[tidx] ^= k as u8; - } - } - } else if verbose { - println!("[7/9] Skipping .text dd8 (already plaintext)..."); - } - } - - // ---- Fix data directories (PE32: data dirs at pe+0x78) ---- - let exe_pe = get_u32(&self.decompressed, 60); - for i in 0..128u32 { - self.decompressed[(exe_pe + 0x78 + i) as usize] = metadata_dirs[i as usize]; - } - // DLL-aware reloc / DllCharacteristics handling. An EXE's packer rebuilds - // the relocation table and clears DllCharacteristics, so the loader needs - // no relocations. A DLL, by contrast, is almost always mapped at a - // non-preferred base, so it MUST keep its base-relocation directory - // (restored above from metadata_dirs) and a valid DllCharacteristics - // (DYNAMIC_BASE) — zeroing them leaves the DLL unrelocatable and its - // imports pinned to the packer stub, so it fails to load (which looks - // like a missing/broken export table). - let is_dll = (get_u16(&self.decompressed, exe_pe.wrapping_add(22)) & 0x2000) != 0; - if !is_dll { - // EXE: clear BaseReloc (index 5 = pe+0xA0) and DllCharacteristics (pe+0x5E). - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xA0), 0); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xA4), 0); - write_u16(&mut self.decompressed, exe_pe.wrapping_add(0x5E), 0); - } else { - // DLL: keep the BaseReloc dir from metadata; ensure DYNAMIC_BASE. - let mut dll_chars = get_u16(&self.decompressed, exe_pe.wrapping_add(0x5E)); - if dll_chars == 0 { - dll_chars = 0x0040; // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE - } - write_u16( - &mut self.decompressed, - exe_pe.wrapping_add(0x5E), - dll_chars as u32, - ); - } - - // ---- TLS directory reconstruction (PE32: index 9 = pe+0xC0) ---- - let tls_dir_rva = get_u32(&self.decompressed, exe_pe.wrapping_add(0xC0)); - let tls_dir_sz = get_u32(&self.decompressed, exe_pe.wrapping_add(0xC4)); - if tls_dir_rva > 0 - && tls_dir_sz >= 24 - && (tls_dir_rva as usize + 24) <= self.decompressed.len() - { - let all_zero = (0..6u32) - .all(|i| get_u32(&self.decompressed, tls_dir_rva.wrapping_add(i * 4)) == 0); - if all_zero { - let image_base = get_u32(&self.decompressed, exe_pe.wrapping_add(52)); - // Prefer the module's real TLS directory, which survives in the - // loader stub's plaintext `.rdata`/`.tls`. Only when the stub - // cannot supply one does a placeholder get synthesized: it keeps - // the image loadable, but drops the initialized TLS template, - // `_tls_index` and the TLS callback array, so any module that - // actually uses `thread_local` faults once it runs. - if !self.restore_pe32_tls_from_stub(pe_off, tls_dir_rva, image_base) { - let mut tls_sec_va: u32 = 0; - let mut data_sec_va: u32 = 0; - let mut data_sec_sz: u32 = 0; - let sh = exe_pe - .wrapping_add(24) - .wrapping_add(get_u16(&self.decompressed, exe_pe.wrapping_add(20)) as u32); - let ns = get_u16(&self.decompressed, exe_pe.wrapping_add(6)) as u32; - for i in 0..ns { - let s = sh.wrapping_add(i * 40); - let nm = get_string_to_null(&self.decompressed, s); - let va = get_u32(&self.decompressed, s.wrapping_add(12)); - let sz = get_u32(&self.decompressed, s.wrapping_add(16)); - if nm.starts_with(".tls") { - tls_sec_va = va; - } - if nm.starts_with(".data") { - data_sec_va = va; - data_sec_sz = sz; - } - } - if tls_sec_va > 0 && data_sec_va > 0 { - let start_raw = image_base.wrapping_add(tls_sec_va); - let end_raw = start_raw; - let idx_addr = image_base - .wrapping_add(data_sec_va) - .wrapping_add(data_sec_sz) - .wrapping_sub(16); - let cb_addr = image_base - .wrapping_add(data_sec_va) - .wrapping_add(data_sec_sz) - .wrapping_sub(8); - let scratch = (data_sec_va + data_sec_sz - 16) as usize; - for b in &mut self.decompressed[scratch..scratch + 16] { - *b = 0; - } - write_u32(&mut self.decompressed, tls_dir_rva, start_raw); - write_u32(&mut self.decompressed, tls_dir_rva.wrapping_add(4), end_raw); - write_u32( - &mut self.decompressed, - tls_dir_rva.wrapping_add(8), - idx_addr, - ); - write_u32( - &mut self.decompressed, - tls_dir_rva.wrapping_add(12), - cb_addr, - ); - write_u32(&mut self.decompressed, tls_dir_rva.wrapping_add(16), 0); - write_u32( - &mut self.decompressed, - tls_dir_rva.wrapping_add(20), - 0x30_0000, - ); - } else { - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xC0), 0); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xC4), 0); - } - } - } - } - - // ---- Import table (PE32, 4-byte thunks) ---- - if verbose { - println!("[8/9] Decrypting import strings (PE32)..."); - } - let import_table_addr = eighth_start.wrapping_add(off_import_table); - let mut import_table_ptr = get_u32(&self.decompressed, import_table_addr); - let mut idt_size = get_u32(&self.decompressed, import_table_addr.wrapping_add(4)); - - let metadata_import_rva = get_u32(&metadata_dirs, 8); - let metadata_import_size = get_u32(&metadata_dirs, 12); - let dlen = self.decompressed.len() as u32; - - let mut eighth_import_valid = false; - if 0 < import_table_ptr && import_table_ptr < dlen && 0 < idt_size && idt_size < 0x10000 { - let test_name = if import_table_ptr + 20 <= dlen { - get_u32(&self.decompressed, import_table_ptr.wrapping_add(12)) - } else { - 0 - }; - let test_ilt = if import_table_ptr + 4 <= dlen { - get_u32(&self.decompressed, import_table_ptr) - } else { - 0 - }; - if 0x1000 < test_name && test_name < dlen && 0x1000 < test_ilt && test_ilt < dlen { - eighth_import_valid = true; - } - } - let mut metadata_import_valid = false; - if 0x1000 < metadata_import_rva && metadata_import_rva < dlen.wrapping_sub(20) { - let test_name2 = get_u32(&self.decompressed, metadata_import_rva.wrapping_add(12)); - let test_ilt2 = get_u32(&self.decompressed, metadata_import_rva); - if 0x1000 < test_name2 && test_name2 < dlen && 0x1000 < test_ilt2 && test_ilt2 < dlen { - metadata_import_valid = true; - } - } - if metadata_import_valid - && (!eighth_import_valid || metadata_import_rva != import_table_ptr) - { - import_table_ptr = metadata_import_rva; - idt_size = metadata_import_size; - } - - if 0 < import_table_ptr && import_table_ptr < dlen && 0 < idt_size && idt_size < 0x10000 { - let mut idt_pos = import_table_ptr; - let idt_end = import_table_ptr.wrapping_add(idt_size); - while idt_pos.wrapping_add(20) <= idt_end { - let ilt_rva = get_u32(&self.decompressed, idt_pos); - let name_rva = get_u32(&self.decompressed, idt_pos.wrapping_add(12)); - let iat_rva = get_u32(&self.decompressed, idt_pos.wrapping_add(16)); - if ilt_rva == 0 && name_rva == 0 && iat_rva == 0 { - break; - } - if 0 < name_rva && name_rva < dlen { - self.decrypt_data7(name_rva, name_rva as u8); - } - let thunk_base = if 0 < ilt_rva && ilt_rva < dlen { - ilt_rva - } else { - iat_rva - }; - if 0 < thunk_base && thunk_base < dlen.wrapping_sub(4) { - let mut thunk_pos = thunk_base; - while thunk_pos.wrapping_add(4) <= dlen { - let thunk_val = get_u32(&self.decompressed, thunk_pos); - if thunk_val == 0 { - break; - } - if thunk_val & 0x8000_0000 == 0 && thunk_val.wrapping_add(2) < dlen { - self.decrypt_data7(thunk_val.wrapping_add(2), thunk_val as u8); - write_u16(&mut self.decompressed, thunk_val, 0); - } - thunk_pos = thunk_pos.wrapping_add(4); - } - } - idt_pos = idt_pos.wrapping_add(20); - } - } - - // Update PE header: Import directory (index 1 = pe+0x80), clear IAT - // directory (index 12 = pe+0xD8). - write_u32( - &mut self.decompressed, - exe_pe.wrapping_add(0x80), - import_table_ptr, - ); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0x84), idt_size); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xD8), 0); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xDC), 0); - - // ---- EP (from metadata) ---- - if metadata_ep > 0 { - write_u32(&mut self.decompressed, exe_pe.wrapping_add(40), metadata_ep); - } else { - let real_ep = get_u32(self.file_data, pe_off.wrapping_add(40)); - write_u32(&mut self.decompressed, exe_pe.wrapping_add(40), real_ep); - } - - // ---- Output transforms ---- - if verbose { - println!("[9/9] Rebuilding PE file layout (PE32)..."); - } - let mut out = std::mem::take(&mut self.decompressed); - // kmiat import relocation is an EXE-only fixup: it discards the original - // import directory in favour of the loader-written IAT stub. A DLL keeps - // its real import table (restored above from metadata), so skip kmiat for - // DLLs (`!is_dll` guard). - let is_dll = (get_u16(&out, pe_off.wrapping_add(22)) & 0x2000) != 0; - if !is_dll && !primitives::pe32_imports_already_match_idata_layout(&mut out, pe_off) { - primitives::move_pe32_imports_to_kmiat(&mut out, pe_off); - } - let compact = primitives::compact_memory_image_to_pe(&out, pe_off) - .ok_or(UnpackError::Pe32OutputLayoutInvalid)?; - Ok(compact) - } } #[cfg(test)] diff --git a/senbei-pe/src/engine/exe/pipeline/pe32.rs b/senbei-pe/src/engine/exe/pipeline/pe32.rs new file mode 100644 index 0000000..0b319ba --- /dev/null +++ b/senbei-pe/src/engine/exe/pipeline/pe32.rs @@ -0,0 +1,1063 @@ +use super::super::super::layout; +use super::*; + +impl<'a> Unpacker<'a> { + /// PE32 (32-bit) unpack pipeline. The shared Stage 1/2 setup (info decrypt, + /// payload decrypt, raw copy, header restore) has already run in `run()` + /// before dispatch; this takes over from "Locating shell offsets". + pub(super) fn run_pe32(&mut self, pe_off: u32, verbose: bool) -> Result, UnpackError> { + let info = self.info; + let info3 = info[3]; + + // advance_key: replays the packer's per-iteration key walk. + let advance_key = |mut key: u32, iterations: u32| -> u32 { + for m in 0..iterations { + let bound = (m + 1).wrapping_mul(25) << 2; + let mut n: u32 = 1; + while n <= bound { + key = key.wrapping_add(n); + n += 1; + } + } + key + }; + + // ---- Locate tbl in shell ---- + let tbl = + layout::find_tbl_pe32(&self.decompressed, &info).ok_or(UnpackError::Pe32TblNotFound)?; + if verbose { + println!("[3/9] Locating config layout (PE32)..."); + println!(" tbl = 0x{:X}", tbl); + } + + // ---- PE header restore ---- + let val_bc = get_u32(&self.decompressed, tbl.wrapping_add(0xBC)); + let val_c8 = get_u32(&self.decompressed, tbl.wrapping_add(0xC8)); + let val_cc = get_u32(&self.decompressed, tbl.wrapping_add(0xCC)); + write_u32(&mut self.decompressed, pe_off.wrapping_add(0x80), val_bc); + write_u32(&mut self.decompressed, pe_off.wrapping_add(0x88), val_c8); + write_u32(&mut self.decompressed, pe_off.wrapping_add(0x8C), val_cc); + write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB0), 0); + write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB4), 0); + + // ---- Header-independent checksum inputs ---- + let first_stage_cs = self.calculate_checksum(tbl.wrapping_add(0xA8)); + let second_stage_key = get_u32(&self.decompressed, tbl.wrapping_add(0x40)); + + // ---- Stage 3: SecondStage ---- + // + // ss_key = headerChecksum ^ firstStageCS ^ secondStageKey, where the + // header checksum (a XOR of crc32(region)^size over the sub-regions at + // tbl+0x58) is taken over the *original* pre-pack PE header. For EXEs the + // import/resource restore above reconstructs that header exactly. Native + // DLLs additionally carry a packer-added BaseReloc data-directory entry + // (dir 5) that was absent from the checksummed original, so the header + // checksum only matches once that entry is treated as zero. EXEs have no + // dir-5 entry, so zeroing it is a no-op for them. + // + // Rather than branch on EXE-vs-DLL, try the header as-is and, on failure, + // with the BaseReloc entry zeroed; keep whichever ss_key decrypts a + // SecondStage whose ThirdStage (off,size) pair lands inside the image. + // This uses the same shift/key trial-and-validate the later stages + // already use, and keeps EXE output byte-identical (the as-is variant + // wins first). + let ss_pair = tbl.wrapping_add(0x98); + let ss = get_u32(&self.decompressed, ss_pair); + let ss_size = get_u32(&self.decompressed, ss_pair.wrapping_add(4)); + let ss_shift = ss_size.wrapping_sub(0xBC0); + // Back up the SecondStage ciphertext so a failed trial can be retried. + let ss_lo = ss as usize; + let ss_hi = ss_lo.wrapping_add(ss_size as usize); + if ss_hi < ss_lo || ss_hi > self.decompressed.len() { + return Err(UnpackError::Pe32SecondStageRangeInvalid { + offset: ss, + size: ss_size, + image_len: self.decompressed.len(), + }); + } + let ss_ct: Vec = self.decompressed[ss_lo..ss_hi].to_vec(); + // PE32 data dir 5 (BaseReloc) = optional_header(pe+24) + 0x60 + 5*8 = pe+0xA0. + let reloc_dir = pe_off.wrapping_add(0xA0); + let len = self.decompressed.len() as u64; + let pair_off = 0xB8Cu32.wrapping_add(ss_shift); + let mut found = false; + // Holds the winning variant's header checksum; the later stages + // (Forth/Fifth/Seven/Eighth) reuse it as a key component. + let mut header_checksum: u32 = 0; + for zero_reloc in [false, true] { + if zero_reloc { + write_u32(&mut self.decompressed, reloc_dir, 0); + write_u32(&mut self.decompressed, reloc_dir.wrapping_add(4), 0); + } + let mut hcs_addr = tbl.wrapping_add(0x58); + header_checksum = 0; + while get_u32(&self.decompressed, hcs_addr.wrapping_add(4)) != 0 { + header_checksum ^= self.calculate_checksum(hcs_addr); + hcs_addr = hcs_addr.wrapping_add(8); + } + let ss_key = header_checksum ^ first_stage_cs ^ second_stage_key; + self.decompressed[ss_lo..ss_hi].copy_from_slice(&ss_ct); + self.decrypt_data3(ss_pair, ss_key, 21); + // Validate: the ThirdStage (off,size) pair must reference the image. + let pair = ss.wrapping_add(pair_off); + let off = get_u32(&self.decompressed, pair) as u64; + let sz = get_u32(&self.decompressed, pair.wrapping_add(4)) as u64; + if off > 0x1000 && off < len && sz >= 4 && off.saturating_add(sz) <= len { + found = true; + break; + } + } + if !found { + return Err(UnpackError::Pe32RelocationDataNotFound); + } + if verbose { + println!( + " ss = 0x{:08X}, size = 0x{:X}, shift = 0x{:X}", + ss, ss_size, ss_shift + ); + } + + // ---- PE32 fixed offsets ---- + let third_key_off = 0x968u32.wrapping_add(ss_shift); + let forth_key_off = 0x964u32.wrapping_add(ss_shift); + let cs_base_off = 0x96Cu32.wrapping_add(ss_shift); + let dp_base_off = 0xA9Cu32.wrapping_add(ss_shift); + + // ---- Stage 4: ThirdStage (brute-force the rotate shift) ---- + let third_pair_off = 0xB8Cu32.wrapping_add(ss_shift); + let key = get_u32(&self.decompressed, ss.wrapping_add(third_key_off)); + let pair_addr = ss.wrapping_add(third_pair_off); + let ts_addr = get_u32(&self.decompressed, pair_addr); + let ts_size_raw = get_u32(&self.decompressed, pair_addr.wrapping_add(4)); + let backup: Vec = + self.decompressed[ts_addr as usize..(ts_addr + ts_size_raw) as usize].to_vec(); + let mut info_table: Option = None; + let mut keys_addr: u32 = 0; + let mut ts: u32 = 0; + for &shift in &[19u32, 21, 17, 23, 15, 25, 13, 11] { + self.decompressed[ts_addr as usize..(ts_addr + ts_size_raw) as usize] + .copy_from_slice(&backup); + write_u32(&mut self.decompressed, pair_addr, ts_addr); + write_u32( + &mut self.decompressed, + pair_addr.wrapping_add(4), + ts_size_raw, + ); + self.decrypt_data3(pair_addr, key, shift); + let mut off = 0u32; + while off + 32 < ts_size_raw { + let t0 = get_u32(&self.decompressed, ts_addr.wrapping_add(off)); + if t0 == 1 || t0 == 0x11 { + let t1 = get_u32(&self.decompressed, ts_addr.wrapping_add(off + 16)); + if t1 == 2 { + let addr0 = get_u32(&self.decompressed, ts_addr.wrapping_add(off + 4)); + if 0x1000 < addr0 && (addr0 as usize) < self.decompressed.len() { + let it = ts_addr.wrapping_add(off); + info_table = Some(it); + keys_addr = it.wrapping_sub(0x58); + ts = ts_addr; + break; + } + } + } + off = off.wrapping_add(4); + } + if info_table.is_some() { + break; + } + } + let info_table = info_table.ok_or(UnpackError::Pe32ThirdStageFailed)?; + if verbose { + println!("[4/9] Decrypting stages (PE32)..."); + println!( + " thirdStage start = 0x{:X}, infoTable = 0x{:X}", + ts, info_table + ); + } + + // ---- Process infoTable ---- + let mut it_addr = info_table; + for _ in 0..2 { + let tval = get_u32(&self.decompressed, it_addr); + if tval == 1 || tval == 0x11 { + self.decrypt_data4(it_addr.wrapping_add(4)); + } else if tval == 2 { + let mut copy_addr = get_u32(&self.decompressed, it_addr.wrapping_add(4)); + loop { + self.decrypt_data5(copy_addr, 16); + let s_a = get_u32(&self.decompressed, copy_addr); + let s_sz = get_u32(&self.decompressed, copy_addr.wrapping_add(4)); + let d_a = get_u32(&self.decompressed, copy_addr.wrapping_add(8)); + let d_sz = get_u32(&self.decompressed, copy_addr.wrapping_add(12)); + copy_addr = copy_addr.wrapping_add(16); + if s_sz == 0 { + break; + } + if s_a != 0 && d_a != 0 && d_sz == s_sz { + let sa = s_a as usize; + let da = d_a as usize; + let n = s_sz as usize; + self.decompressed.copy_within(sa..sa + n, da); + } + } + } + it_addr = it_addr.wrapping_add(16); + } + + // ---- keyOffsets ---- + let mut ka = keys_addr; + for k in 0..2usize { + let mut ka2 = ka; + for l in 0..2usize { + self.decrypt_data4(ka2); + self.key_offsets[k * 2 + l] = get_u32(&self.decompressed, ka2); + ka2 = ka2.wrapping_add(8); + } + ka = ka.wrapping_add(32); + } + + // ---- Checksum addresses ---- + let second_stage_cs_addr = tbl.wrapping_add(0xB0); + let forth_stage_cs_addr = ss.wrapping_add(cs_base_off); + let fifth_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x08); + let seven_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x10); + + // ---- ForthStage ---- + let second_stage_cs = self.calculate_checksum(second_stage_cs_addr); + let forth_stage_key = advance_key( + get_u32(&self.decompressed, ss.wrapping_add(forth_key_off)), + 4, + ); + let dp_base = ss.wrapping_add(dp_base_off); + let forth_addr = dp_base.wrapping_add(0x40); + let fk = header_checksum ^ second_stage_cs ^ forth_stage_key; + if let Err(reason) = self.decrypt_and_decompress_data(forth_addr, fk, None) { + return Err(UnpackError::StageDecompressionFailed { + stage: DecompressionStage::Pe32FourthStage, + reason, + }); + } + + // ---- FifthStage ---- + let fifth_addr = dp_base.wrapping_add(0x50); + let forth_cs = self.calculate_checksum(forth_stage_cs_addr); + let forth_region_off = get_u32(&self.decompressed, forth_stage_cs_addr); + let forth_region_sz = get_u32(&self.decompressed, forth_stage_cs_addr.wrapping_add(4)); + let fifth_key = get_u32( + &self.decompressed, + forth_region_off + .wrapping_add(forth_region_sz) + .wrapping_sub(4), + ); + let fk5 = header_checksum ^ forth_cs ^ fifth_key; + if let Err(reason) = self.decrypt_and_decompress_data(fifth_addr, fk5, None) { + return Err(UnpackError::StageDecompressionFailed { + stage: DecompressionStage::Pe32FifthStage, + reason, + }); + } + + // ---- SevenStage ---- + let seven_addr = dp_base.wrapping_add(0x70); + let seven_dsz = get_u32(&self.decompressed, seven_addr.wrapping_add(12)); + let fifth_cs = self.calculate_checksum(fifth_stage_cs_addr); + let cs1_addr = get_u32( + &self.decompressed, + ss.wrapping_add(cs_base_off).wrapping_add(0x08), + ); + let cs1_size = get_u32( + &self.decompressed, + ss.wrapping_add(cs_base_off) + .wrapping_add(0x08) + .wrapping_add(4), + ); + let seven_key = !get_u32( + &self.decompressed, + cs1_addr.wrapping_add(cs1_size).wrapping_sub(0x10), + ); + let fk7 = header_checksum ^ fifth_cs ^ seven_key; + if let Err(reason) = self.decrypt_and_decompress_data(seven_addr, fk7, None) { + return Err(UnpackError::StageDecompressionFailed { + stage: DecompressionStage::Pe32SeventhStage, + reason, + }); + } + + // ---- EighthStage ---- + let seven_start_actual = get_u32(&self.decompressed, seven_addr); + if verbose { + println!("[5/9] Decrypting eighthStage (PE32)..."); + println!( + " sevenStart = 0x{:X}, sevenDsz = 0x{:X}", + seven_start_actual, seven_dsz + ); + } + // Locate the customDecryptor LFSR block (scan backward from middle, then + // forward as fallback). + let scan_start = seven_dsz / 2; + let custom_dec_off = layout::find_lfsr_block( + &self.decompressed, + seven_start_actual, + seven_dsz, + scan_start, + true, + ) + .or_else(|| { + layout::find_lfsr_block(&self.decompressed, seven_start_actual, seven_dsz, 0, false) + }) + .ok_or(UnpackError::Pe32CustomDecryptorNotFound)?; + let custom_dec_addr = seven_start_actual.wrapping_add(custom_dec_off); + self.decrypt_data6(custom_dec_addr); + let custom_ops = generate(&self.decompressed, custom_dec_addr).ok_or( + UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32CustomDecryptor), + )?; + + let seven_cs = self.calculate_checksum(seven_stage_cs_addr); + let eighth_addr = dp_base.wrapping_add(0xC0); + let eighth_dsz = get_u32(&self.decompressed, eighth_addr.wrapping_add(12)); + let eighth_src = get_u32(&self.decompressed, eighth_addr); + let eighth_ssz = get_u32(&self.decompressed, eighth_addr.wrapping_add(4)); + let eighth_backup: Vec = + self.decompressed[eighth_src as usize..(eighth_src + eighth_ssz) as usize].to_vec(); + let eighth_pair_bak: Vec = + self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize].to_vec(); + let data_len = self.decompressed.len() as u32; + + // Build the eighthStageKey candidate list (offsets relative to + // sevenStart) using gap heuristics + scan. + let mut candidates: Vec = Vec::new(); + let push_cand = |c: &mut Vec, off: u32| { + if !c.contains(&off) { + c.push(off); + } + }; + for &end_gap in &[0xD0u32, 0xC0, 0xE0, 0xB0, 0xA0, 0xF0, 0x100] { + if end_gap <= seven_dsz { + let off = seven_dsz - end_gap; + if off < seven_dsz { + let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); + if val != 0 && val != 0xCCCC_CCCC { + push_cand(&mut candidates, off); + } + } + } + } + for &gap in &[ + 0x70u32, 0xD0, 0x28, 0x50, 0x48, 0x30, 0x40, 0x58, 0x60, 0x20, 0x38, 0x80, 0x90, 0xA0, + 0xB0, + ] { + if gap <= custom_dec_off { + let off = custom_dec_off - gap; + if off + 4 <= seven_dsz && !candidates.contains(&off) { + let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); + if val != 0 && val != 0xCCCC_CCCC { + push_cand(&mut candidates, off); + } + } + } + } + let scan_lo = custom_dec_off.saturating_sub(0x100); + let mut off = scan_lo; + while off < custom_dec_off { + if !candidates.contains(&off) { + let val = get_u32(&self.decompressed, seven_start_actual.wrapping_add(off)); + let all_printable = (0..4u32).all(|i| { + let b = (val >> (i * 8)) & 0xFF; + (32..127).contains(&b) + }); + if val != 0 && val != 0xCCCC_CCCC && !all_printable { + push_cand(&mut candidates, off); + } + } + off = off.wrapping_add(4); + } + + let k1 = self.key_offsets[1]; + let k3 = self.key_offsets[3]; + let mut eighth_ok = false; + for ek_off in candidates { + self.decompressed[eighth_src as usize..(eighth_src + eighth_ssz) as usize] + .copy_from_slice(&eighth_backup); + self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize] + .copy_from_slice(&eighth_pair_bak); + let raw = get_u32(&self.decompressed, seven_start_actual.wrapping_add(ek_off)); + let test_key = advance_key(raw, 3); + let fk8 = header_checksum ^ fifth_cs ^ seven_cs ^ test_key; + let result = primitives::decrypt_and_decompress_data( + &mut self.decompressed, + eighth_addr, + fk8, + k1, + k3, + Some(&custom_ops), + ); + if result { + let est = get_u32(&self.decompressed, eighth_addr); + if 0x1000 < est && est < data_len { + eighth_ok = true; + break; + } + } + } + if !eighth_ok { + return Err(UnpackError::Pe32EighthKeyNotFound); + } + let eighth_start = get_u32(&self.decompressed, eighth_addr); + if verbose { + println!( + " eighthStart = 0x{:08X}, dsz = 0x{:X}", + eighth_start, eighth_dsz + ); + } + + // ---- Final processing offsets (anchored on the eighthStage config cluster) ---- + // + // The eighthStage holds a config cluster — importTable, fileCS, + // compressedInfo, zeroList — at fixed offsets from a cluster base + // (base+0x18 / +0x30 / +0x40 / +0x48) with the fileLFSR at +0x4B4. + // Classic builds stamp a 0x00007679 dword at that base; native DLLs and + // some older PE32 EXEs (ss_size=0xBE8) omit the stamp. + // Locate the cluster by stamp when present (validated by fileCS at + // base+0x30 pointing past info[3]); otherwise fall back to finding the + // fileCS slot by shape — (addr, size) with addr just past info[3] and a + // small 16-aligned size — and back-derive base = fileCS_off - 0x30. + // Hardcoded eighthStart-relative constants remain as a last-resort + // fallback for builds where neither discovery path fires. + let marker = { + let mut m: Option = None; + let hi = eighth_dsz.saturating_sub(0x4C); + let mut o = 0u32; + while o < hi { + if get_u32(&self.decompressed, eighth_start.wrapping_add(o)) == 0x7679 { + let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 0x30)); + if fc > info3 && (fc as usize) < self.decompressed.len() { + m = Some(o); + break; + } + } + o = o.wrapping_add(4); + } + if m.is_none() { + // fileCS-shaped slot: addr in (info3, info3+0x2000], size in + // 0x10..=0x200 and 16-aligned. Prefer the candidate whose addr + // is closest to (but past) info3 — matches every observed + // build (one PE32 EXE family dist ~0x1C0, another ~0x1A0). + let mut best: Option<(u32 /*dist*/, u32 /*off*/)> = None; + let mut o = 0u32; + let dlen = self.decompressed.len() as u32; + while o + 8 <= eighth_dsz.saturating_sub(0x4B4u32.saturating_sub(0x30)) { + let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o)); + let sz = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 4)); + if fc > info3 + && fc <= info3.wrapping_add(0x2000) + && fc < dlen + && (0x10..=0x200).contains(&sz) + && (sz & 0xF) == 0 + { + // Cluster base must leave room for the +0x4B4 LFSR slot + // (even if the exact LFSR is later adjusted by scan). + if o >= 0x30 { + let base = o - 0x30; + if base.wrapping_add(0x4C) <= eighth_dsz { + let dist = fc - info3; + match best { + None => best = Some((dist, base)), + Some((bd, _)) if dist < bd => best = Some((dist, base)), + _ => {} + } + } + } + } + o = o.wrapping_add(4); + } + if let Some((dist, base)) = best { + if verbose { + println!( + " pe32 cluster via fileCS (no 0x7679): base=+0x{:X} dist_info3=0x{:X}", + base, dist + ); + } + m = Some(base); + } + } + m + }; + let (off_import_table, off_file_cs, off_compressed_info, off_zero_list, off_file_lfsr) = + match marker { + Some(m) => (m + 0x18, m + 0x30, m + 0x40, m + 0x48, m + 0x4B4), + None => ( + 0x3C50u32.wrapping_add(ss_shift), + 0x3C68u32.wrapping_add(ss_shift), + 0x3C78u32.wrapping_add(ss_shift), + 0x3C80u32.wrapping_add(ss_shift), + 0x40ECu32.wrapping_add(ss_shift), + ), + }; + + // ---- File checksums (permanent decrypt) ---- + let file_cs_addr_ptr = eighth_start.wrapping_add(off_file_cs); + let mut file_cs_addr = get_u32(&self.decompressed, file_cs_addr_ptr); + let file_cs_size = get_u32(&self.decompressed, file_cs_addr_ptr.wrapping_add(4)); + if file_cs_size > 0 { + let file_cs_end = file_cs_addr.wrapping_add(file_cs_size); + while file_cs_addr < file_cs_end { + self.decrypt_data5(file_cs_addr, 16); + file_cs_addr = file_cs_addr.wrapping_add(16); + } + } else { + while get_u32(&self.decompressed, file_cs_addr.wrapping_add(4)) != 0 { + self.decrypt_data5(file_cs_addr, 16); + file_cs_addr = file_cs_addr.wrapping_add(16); + } + } + + // ---- File decryptor LFSR ---- + // + // When the marker-relative off_file_lfsr is in range, try that slot + // first (exact). If it is not a valid LFSR block, trial-and-validate + // candidates from off_zero_list forward — required for older PE32 EXEs + // without the 0x7679 stamp where the expected slot is empty and a loose + // decoded[0]+0xC3 nearest-hit picks the wrong decryptor. Fall back to + // the legacy loose scan only if no candidate trial-decompresses. Native + // DLLs have a smaller eighthStage where off_file_lfsr lands out of range + // and use the same trial-validate scan from just past the cluster (else + // branch). + let lfsr_off = if off_file_lfsr.wrapping_add(96) <= eighth_dsz { + let mut lfsr_off = off_file_lfsr; + let exact = layout::find_lfsr_block( + &self.decompressed, + eighth_start, + eighth_dsz, + off_file_lfsr, + false, + ); + if exact != Some(off_file_lfsr) { + // Prefer trial-and-validate (same as the DLL branch): a loose + // decoded[0]+0xC3 scan can land on coincidental LFSR-shaped + // blocks that decode to a wrong file_ops and scramble every + // compressed block. Observed on older PE32 EXEs without the + // 0x7679 cluster stamp: the expected slot is empty and the + // nearest loose hit is not the real decryptor. + let ci_slot = eighth_start.wrapping_add(off_compressed_info); + let mut scan = off_zero_list; + let mut chosen: Option = None; + let mut considered = 0u32; + while let Some(cand) = layout::find_lfsr_block( + &self.decompressed, + eighth_start, + eighth_dsz, + scan, + false, + ) { + considered = considered.wrapping_add(1); + if self.pe32_file_lfsr_validates(eighth_start.wrapping_add(cand), ci_slot) { + chosen = Some(cand); + break; + } + scan = cand + 1; + } + if let Some(c) = chosen { + if verbose { + println!( + " pe32 fileLFSR via trial-validate: +0x{:X} (expected +0x{:X}, considered {})", + c, off_file_lfsr, considered + ); + } + lfsr_off = c; + } else { + // No candidate trial-decompresses: fail loudly. The old + // "legacy loose scan" picked the nearest LFSR-shaped block + // by offset distance without any validation — that is + // exactly how a wrong file_ops got applied to every data + // block (uncompressed blocks never enter the decompressor), + // producing a plausible but fully wrong image (the PE32 + // .text scramble root cause). Trial-and-validate or error. + return Err(UnpackError::Pe32FileLfsrNotFound); + } + } + lfsr_off + } else { + // Native DLL: the marker-relative off_file_lfsr (EXE-tuned, marker + + // 0x4B4) overshoots the smaller DLL eighthStage, so the exact slot is + // unavailable. A plain forward scan returns the FIRST valid-opcode + // block, but the DLL eighthStage contains coincidental valid-opcode + // blocks that decode to trivial programs (e.g. a constant byte add) + // ahead of the real file decryptor. A wrong file_ops corrupts the + // per-block translate (applied before decompression), so every data + // block fails to decompress. Enumerate every candidate forward and + // keep the first whose decoded file_ops actually decompresses the + // first compressed data block — trial-and-validate, same idea as the + // D1/D2 fixes. Non-DLL (EXE) builds never reach this branch. + let ci_slot = eighth_start.wrapping_add(off_compressed_info); + let mut scan = off_zero_list.wrapping_add(8); + let mut chosen: Option = None; + while let Some(cand) = + layout::find_lfsr_block(&self.decompressed, eighth_start, eighth_dsz, scan, false) + { + if self.pe32_file_lfsr_validates(eighth_start.wrapping_add(cand), ci_slot) { + chosen = Some(cand); + break; + } + scan = cand + 1; + } + chosen.ok_or(UnpackError::Pe32FileLfsrNotFound)? + }; + let file_dec_addr = eighth_start.wrapping_add(lfsr_off); + self.decrypt_data6(file_dec_addr); + let file_ops = generate(&self.decompressed, file_dec_addr).ok_or( + UnpackError::BytecodeGenerationFailed(BytecodeStage::Pe32FileDecryptor), + )?; + + // ---- PE32 metadata: EP and data dirs from info[3] ---- + let test_val = get_u32(&self.decompressed, info3.wrapping_add(0x10)); + let metadata_ep: u32; + let mut metadata_dirs = [0u8; 128]; + if test_val > 0x10000 { + // Layout B + let s = info3.wrapping_add(0x10) as usize; + let backup_meta = self.decompressed[s..s + 0x290].to_vec(); + self.decrypt_data5(info3.wrapping_add(0x10), 0x290); + metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x20)); + let d = info3.wrapping_add(0x30) as usize; + metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]); + self.decompressed[s..s + 0x290].copy_from_slice(&backup_meta); + } else { + // Layout A + let s = info3.wrapping_add(0x40) as usize; + let backup_meta = self.decompressed[s..s + 144].to_vec(); + self.decrypt_data5(info3.wrapping_add(0x40), 144); + metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x40)); + let d = info3.wrapping_add(0x50) as usize; + metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]); + self.decompressed[s..s + 144].copy_from_slice(&backup_meta); + } + + // ---- Zero-out list (runs BEFORE decompression) ---- + let zero_list_addr = eighth_start.wrapping_add(off_zero_list); + let mut zero_ptr = get_u32(&self.decompressed, zero_list_addr); + loop { + self.decrypt_data5(zero_ptr, 16); + let src3 = get_u32(&self.decompressed, zero_ptr); + let s_sz3 = get_u32(&self.decompressed, zero_ptr.wrapping_add(4)); + zero_ptr = zero_ptr.wrapping_add(16); + if s_sz3 == 0 { + break; + } + if src3.wrapping_add(s_sz3) as usize > self.decompressed.len() { + break; + } + for b in &mut self.decompressed[src3 as usize..(src3 + s_sz3) as usize] { + *b = 0; + } + } + + // ---- File data decompression ---- + if verbose { + println!("[6/9] Loading and decompressing file data (PE32)..."); + } + let compress_data_offset = (!get_u32(self.file_data, 0x1080)).wrapping_add(0x1000); + let compressed_info_addr = eighth_start.wrapping_add(off_compressed_info); + let mut compressed_info = get_u32(&self.decompressed, compressed_info_addr); + // Pass 1 (sequential): position-keyed descriptor chain (decrypt_data5), + // terminated by a zero source-size record. + struct Blk { + src: u32, + ssz: u32, + dst: u32, + dsz: u32, + } + let mut blocks: Vec = Vec::new(); + loop { + self.decrypt_data5(compressed_info, 16); + let src2 = get_u32(&self.decompressed, compressed_info); + let s_sz2 = get_u32(&self.decompressed, compressed_info.wrapping_add(4)); + let dst2 = get_u32(&self.decompressed, compressed_info.wrapping_add(8)); + let d_sz2 = get_u32(&self.decompressed, compressed_info.wrapping_add(12)); + compressed_info = compressed_info.wrapping_add(16); + if s_sz2 == 0 { + break; + } + blocks.push(Blk { + src: src2, + ssz: s_sz2, + dst: dst2, + dsz: d_sz2, + }); + } + // Pass 2: independent per-block work over disjoint dst spans (see + // `parallel_for` for how the spans are carved safely). + { + let lut = OpsLut::new(&file_ops); + let clean = &self.file_data; + let ko = self.key_offsets; + let ks_snap = primitives::aes_schedule_snapshot(&self.decompressed, ko[2]) + .ok_or(UnpackError::InvalidAesKeySchedule { offset: ko[2] })?; + let tab_snap = primitives::huffman_table_snapshot(&self.decompressed, ko[0]) + .ok_or(UnpackError::InvalidHuffmanTable { offset: ko[0] })?; + let spans: Vec<(usize, usize)> = blocks + .iter() + .map(|b| { + let s = b.dst as usize; + (s, s + b.ssz.max(b.dsz) as usize) + }) + .collect(); + let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> { + let b = &blocks[i]; + let file_src = b.src.wrapping_add(compress_data_offset) as usize; + let rel = b.dst as usize - base; + let n = b.ssz as usize; + span[rel..rel + n].copy_from_slice(&clean[file_src..file_src + n]); + primitives::aes_decrypt_ks(&ks_snap, span, rel as u32, b.ssz); + lut.map_region(span, rel, n); + if b.ssz != b.dsz { + // decompress reports corruption (after partial writes) via + // its bool; surface it instead of shipping a garbage block. + if !primitives::decompress_tbl( + &tab_snap, span, rel as u32, rel as u32, b.ssz, b.dsz, + ) { + return Err(UnpackError::SectionDecompressionFailed { + pipeline: SectionPipeline::ExePe32, + block: i, + }); + } + } + Ok(()) + }; + super::super::super::parallel::parallel_for( + &mut self.decompressed, + &spans, + 1, + do_block, + )?; + } + + // ---- Section fixup ---- + self.decompressed[..0x1000].copy_from_slice(&self.file_data[..0x1000]); + let opt_hdr_size = get_u16(self.file_data, pe_off.wrapping_add(20)) as u32; + let sec_hdr_table = pe_off.wrapping_add(24).wrapping_add(opt_hdr_size); + let export_va = get_u32( + self.file_data, + pe_off + .wrapping_add(24) + .wrapping_add(opt_hdr_size) + .wrapping_sub(128), + ); + let export_size = get_u32( + self.file_data, + pe_off + .wrapping_add(24) + .wrapping_add(opt_hdr_size) + .wrapping_sub(124), + ); + let mut export_file_off: u32 = 0; + let mut text_off: u32 = 0; + let mut text_size: u32 = 0; + // Walk by NumberOfSections (PE has no zero-VS sentinel; a real + // VirtualSize==0 section would truncate these fixups early), stopping + // at the all-zero padding in case NumberOfSections is overstated. + let num_sections = get_u16(self.file_data, pe_off.wrapping_add(6)) as u32; + for i in 0..num_sections.min(96) { + let sec_hdr = sec_hdr_table.wrapping_add(i.wrapping_mul(40)); + if self.file_data[sec_hdr as usize..sec_hdr as usize + 8] + .iter() + .all(|&b| b == 0) + { + break; + } + let va = get_u32(self.file_data, sec_hdr.wrapping_add(12)); + let sz = get_u32(self.file_data, sec_hdr.wrapping_add(8)); + let f_off = get_u32(self.file_data, sec_hdr.wrapping_add(20)); + let name = section_name(self.file_data, sec_hdr); + if name.starts_with(".text") { + text_size = sz; + text_off = va; + } + if export_size != 0 + && export_va >= va + && export_va.wrapping_add(export_size) <= va.wrapping_add(sz) + { + export_file_off = export_va.wrapping_sub(va).wrapping_add(f_off); + } + write_u32(&mut self.decompressed, sec_hdr.wrapping_add(16), sz); + write_u32(&mut self.decompressed, sec_hdr.wrapping_add(20), va); + if name.starts_with(".idata") { + write_u32( + &mut self.decompressed, + sec_hdr.wrapping_add(36), + 0xC000_0040, + ); + } + } + if export_size != 0 && export_file_off != 0 { + let d = export_va as usize; + let s = export_file_off as usize; + let n = export_size as usize; + self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]); + } + + // ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ---- + // `select_dd8_formula_pe32` returns None when `.text` was not packer-dd8- + // encrypted (native DLLs leave it plaintext); applying dd8 there would + // scramble valid code, so skip it entirely in that case. + if text_size > 0 && text_off > 0 { + if let Some(big) = + layout::select_dd8_formula_pe32(&self.decompressed, text_off, text_size) + { + if verbose { + println!( + "[7/9] Decrypting .text (PE32 dd8, formula={})...", + if big { "0x8000*(page+1)" } else { "page+1" } + ); + } + let num_pages = text_size / 0x1000; + for page in 0..num_pages { + let pk = if big { + 0x8000u32.wrapping_mul(page.wrapping_add(1)) + } else { + page.wrapping_add(1) + }; + let pa = text_off.wrapping_add(page.wrapping_mul(0x1000)); + let mut k = pk; + let rk = k.rotate_right(15); + k = rk; + for bi in 1..256u32 { + let rk = k.rotate_right(15); + let ri = rk.wrapping_add(bi); + k = ri.wrapping_add(bi); + let tidx = + pa.wrapping_add(bi.wrapping_mul(16)).wrapping_add(ri & 0xF) as usize; + self.decompressed[tidx] ^= k as u8; + } + } + } else if verbose { + println!("[7/9] Skipping .text dd8 (already plaintext)..."); + } + } + + // ---- Fix data directories (PE32: data dirs at pe+0x78) ---- + let exe_pe = get_u32(&self.decompressed, 60); + for i in 0..128u32 { + self.decompressed[(exe_pe + 0x78 + i) as usize] = metadata_dirs[i as usize]; + } + // DLL-aware reloc / DllCharacteristics handling. An EXE's packer rebuilds + // the relocation table and clears DllCharacteristics, so the loader needs + // no relocations. A DLL, by contrast, is almost always mapped at a + // non-preferred base, so it MUST keep its base-relocation directory + // (restored above from metadata_dirs) and a valid DllCharacteristics + // (DYNAMIC_BASE) — zeroing them leaves the DLL unrelocatable and its + // imports pinned to the packer stub, so it fails to load (which looks + // like a missing/broken export table). + let is_dll = (get_u16(&self.decompressed, exe_pe.wrapping_add(22)) & 0x2000) != 0; + if !is_dll { + // EXE: clear BaseReloc (index 5 = pe+0xA0) and DllCharacteristics (pe+0x5E). + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xA0), 0); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xA4), 0); + write_u16(&mut self.decompressed, exe_pe.wrapping_add(0x5E), 0); + } else { + // DLL: keep the BaseReloc dir from metadata; ensure DYNAMIC_BASE. + let mut dll_chars = get_u16(&self.decompressed, exe_pe.wrapping_add(0x5E)); + if dll_chars == 0 { + dll_chars = 0x0040; // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE + } + write_u16( + &mut self.decompressed, + exe_pe.wrapping_add(0x5E), + dll_chars as u32, + ); + } + + // ---- TLS directory reconstruction (PE32: index 9 = pe+0xC0) ---- + let tls_dir_rva = get_u32(&self.decompressed, exe_pe.wrapping_add(0xC0)); + let tls_dir_sz = get_u32(&self.decompressed, exe_pe.wrapping_add(0xC4)); + if tls_dir_rva > 0 + && tls_dir_sz >= 24 + && (tls_dir_rva as usize + 24) <= self.decompressed.len() + { + let all_zero = (0..6u32) + .all(|i| get_u32(&self.decompressed, tls_dir_rva.wrapping_add(i * 4)) == 0); + if all_zero { + let image_base = get_u32(&self.decompressed, exe_pe.wrapping_add(52)); + // Prefer the module's real TLS directory, which survives in the + // loader stub's plaintext `.rdata`/`.tls`. Only when the stub + // cannot supply one does a placeholder get synthesized: it keeps + // the image loadable, but drops the initialized TLS template, + // `_tls_index` and the TLS callback array, so any module that + // actually uses `thread_local` faults once it runs. + if !self.restore_pe32_tls_from_stub(pe_off, tls_dir_rva, image_base) { + let mut tls_sec_va: u32 = 0; + let mut data_sec_va: u32 = 0; + let mut data_sec_sz: u32 = 0; + let sh = exe_pe + .wrapping_add(24) + .wrapping_add(get_u16(&self.decompressed, exe_pe.wrapping_add(20)) as u32); + let ns = get_u16(&self.decompressed, exe_pe.wrapping_add(6)) as u32; + for i in 0..ns { + let s = sh.wrapping_add(i * 40); + let nm = get_string_to_null(&self.decompressed, s); + let va = get_u32(&self.decompressed, s.wrapping_add(12)); + let sz = get_u32(&self.decompressed, s.wrapping_add(16)); + if nm.starts_with(".tls") { + tls_sec_va = va; + } + if nm.starts_with(".data") { + data_sec_va = va; + data_sec_sz = sz; + } + } + if tls_sec_va > 0 && data_sec_va > 0 { + let start_raw = image_base.wrapping_add(tls_sec_va); + let end_raw = start_raw; + let idx_addr = image_base + .wrapping_add(data_sec_va) + .wrapping_add(data_sec_sz) + .wrapping_sub(16); + let cb_addr = image_base + .wrapping_add(data_sec_va) + .wrapping_add(data_sec_sz) + .wrapping_sub(8); + let scratch = (data_sec_va + data_sec_sz - 16) as usize; + for b in &mut self.decompressed[scratch..scratch + 16] { + *b = 0; + } + write_u32(&mut self.decompressed, tls_dir_rva, start_raw); + write_u32(&mut self.decompressed, tls_dir_rva.wrapping_add(4), end_raw); + write_u32( + &mut self.decompressed, + tls_dir_rva.wrapping_add(8), + idx_addr, + ); + write_u32( + &mut self.decompressed, + tls_dir_rva.wrapping_add(12), + cb_addr, + ); + write_u32(&mut self.decompressed, tls_dir_rva.wrapping_add(16), 0); + write_u32( + &mut self.decompressed, + tls_dir_rva.wrapping_add(20), + 0x30_0000, + ); + } else { + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xC0), 0); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xC4), 0); + } + } + } + } + + // ---- Import table (PE32, 4-byte thunks) ---- + if verbose { + println!("[8/9] Decrypting import strings (PE32)..."); + } + let import_table_addr = eighth_start.wrapping_add(off_import_table); + let mut import_table_ptr = get_u32(&self.decompressed, import_table_addr); + let mut idt_size = get_u32(&self.decompressed, import_table_addr.wrapping_add(4)); + + let metadata_import_rva = get_u32(&metadata_dirs, 8); + let metadata_import_size = get_u32(&metadata_dirs, 12); + let dlen = self.decompressed.len() as u32; + + let mut eighth_import_valid = false; + if 0 < import_table_ptr && import_table_ptr < dlen && 0 < idt_size && idt_size < 0x10000 { + let test_name = if import_table_ptr + 20 <= dlen { + get_u32(&self.decompressed, import_table_ptr.wrapping_add(12)) + } else { + 0 + }; + let test_ilt = if import_table_ptr + 4 <= dlen { + get_u32(&self.decompressed, import_table_ptr) + } else { + 0 + }; + if 0x1000 < test_name && test_name < dlen && 0x1000 < test_ilt && test_ilt < dlen { + eighth_import_valid = true; + } + } + let mut metadata_import_valid = false; + if 0x1000 < metadata_import_rva && metadata_import_rva < dlen.wrapping_sub(20) { + let test_name2 = get_u32(&self.decompressed, metadata_import_rva.wrapping_add(12)); + let test_ilt2 = get_u32(&self.decompressed, metadata_import_rva); + if 0x1000 < test_name2 && test_name2 < dlen && 0x1000 < test_ilt2 && test_ilt2 < dlen { + metadata_import_valid = true; + } + } + if metadata_import_valid + && (!eighth_import_valid || metadata_import_rva != import_table_ptr) + { + import_table_ptr = metadata_import_rva; + idt_size = metadata_import_size; + } + + if 0 < import_table_ptr && import_table_ptr < dlen && 0 < idt_size && idt_size < 0x10000 { + let mut idt_pos = import_table_ptr; + let idt_end = import_table_ptr.wrapping_add(idt_size); + while idt_pos.wrapping_add(20) <= idt_end { + let ilt_rva = get_u32(&self.decompressed, idt_pos); + let name_rva = get_u32(&self.decompressed, idt_pos.wrapping_add(12)); + let iat_rva = get_u32(&self.decompressed, idt_pos.wrapping_add(16)); + if ilt_rva == 0 && name_rva == 0 && iat_rva == 0 { + break; + } + if 0 < name_rva && name_rva < dlen { + self.decrypt_data7(name_rva, name_rva as u8); + } + let thunk_base = if 0 < ilt_rva && ilt_rva < dlen { + ilt_rva + } else { + iat_rva + }; + if 0 < thunk_base && thunk_base < dlen.wrapping_sub(4) { + let mut thunk_pos = thunk_base; + while thunk_pos.wrapping_add(4) <= dlen { + let thunk_val = get_u32(&self.decompressed, thunk_pos); + if thunk_val == 0 { + break; + } + if thunk_val & 0x8000_0000 == 0 && thunk_val.wrapping_add(2) < dlen { + self.decrypt_data7(thunk_val.wrapping_add(2), thunk_val as u8); + write_u16(&mut self.decompressed, thunk_val, 0); + } + thunk_pos = thunk_pos.wrapping_add(4); + } + } + idt_pos = idt_pos.wrapping_add(20); + } + } + + // Update PE header: Import directory (index 1 = pe+0x80), clear IAT + // directory (index 12 = pe+0xD8). + write_u32( + &mut self.decompressed, + exe_pe.wrapping_add(0x80), + import_table_ptr, + ); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0x84), idt_size); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xD8), 0); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(0xDC), 0); + + // ---- EP (from metadata) ---- + if metadata_ep > 0 { + write_u32(&mut self.decompressed, exe_pe.wrapping_add(40), metadata_ep); + } else { + let real_ep = get_u32(self.file_data, pe_off.wrapping_add(40)); + write_u32(&mut self.decompressed, exe_pe.wrapping_add(40), real_ep); + } + + // ---- Output transforms ---- + if verbose { + println!("[9/9] Rebuilding PE file layout (PE32)..."); + } + let mut out = std::mem::take(&mut self.decompressed); + // kmiat import relocation is an EXE-only fixup: it discards the original + // import directory in favour of the loader-written IAT stub. A DLL keeps + // its real import table (restored above from metadata), so skip kmiat for + // DLLs (`!is_dll` guard). + let is_dll = (get_u16(&out, pe_off.wrapping_add(22)) & 0x2000) != 0; + if !is_dll && !layout::pe32_imports_already_match_idata_layout(&mut out, pe_off) { + layout::move_pe32_imports_to_kmiat(&mut out, pe_off); + } + let compact = layout::compact_memory_image_to_pe(&out, pe_off) + .ok_or(UnpackError::Pe32OutputLayoutInvalid)?; + Ok(compact) + } +} diff --git a/src/unpacker/integrity.rs b/senbei-pe/src/engine/integrity.rs similarity index 100% rename from src/unpacker/integrity.rs rename to senbei-pe/src/engine/integrity.rs diff --git a/senbei-pe/src/engine/layout.rs b/senbei-pe/src/engine/layout.rs new file mode 100644 index 0000000..a9ef00a --- /dev/null +++ b/senbei-pe/src/engine/layout.rs @@ -0,0 +1,14 @@ +//! Internal PE layout discovery and image reconstruction. + +mod dd8; +mod discovery; +mod image; + +pub(super) use dd8::{select_dd8_formula_pe32, select_dd8_shift}; +pub(super) use discovery::{ + discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos, find_tbl_pe32, + find_v_after_pad, find_v4_offset, get_string_to_null, section_name, trial_decrypt5_u32, +}; +pub(super) use image::{ + compact_memory_image_to_pe, move_pe32_imports_to_kmiat, pe32_imports_already_match_idata_layout, +}; diff --git a/senbei-pe/src/engine/layout/dd8.rs b/senbei-pe/src/engine/layout/dd8.rs new file mode 100644 index 0000000..fc5aca3 --- /dev/null +++ b/senbei-pe/src/engine/layout/dd8.rs @@ -0,0 +1,321 @@ +//! Validation-driven selection for per-page text transforms. + +/// PE32 `.text` dd8 key-formula selection with a skip decision. The packer keys +/// the per-page XOR either with `page+1` or `0x8000*(page+1)`; the formula is +/// not recorded. Replays the dd8 page pass on a scratch copy of sample pages +/// (25/50/75% of `.text`) under each formula and counts how many positions +/// decode to `0xCC` (int3 padding). +/// +/// Returns `Some(true)` for the `0x8000*(page+1)` formula, `Some(false)` for +/// `page+1`, or `None` when `.text` must NOT be dd8-decrypted at all. The packer +/// dd8-encrypts `.text` on EXEs (so unpacking must replay it) but leaves a native +/// DLL's `.text` plaintext; replaying dd8 there scrambles ~1 byte per 16-byte +/// block. The decision: dd8 only *restores* int3 padding when `.text` was +/// genuinely encrypted, so apply it only when the chosen formula's whole-page +/// 0xCC count rises *clearly* above the no-dd8 baseline; otherwise skip. +/// +/// "Clearly" matters: dd8 XORs 255 positions per page with pseudo-random bytes, +/// so on an already-plaintext `.text` it manufactures ~1 spurious `0xCC` per +/// sampled page for free (255/256 expected). A bare `best > baseline` test is +/// therefore biased towards *applying* dd8 on exactly the inputs that must skip +/// it — and a wrongly-applied dd8 is silent: it scrambles ~1 byte per 16 with no +/// error and nothing downstream (not even `integrity::check`, which only reads +/// 16 bytes at the entry point) notices. The [`MIN_DD8_NET_GAIN`] floor below is +/// the PE32 counterpart of the margin+floor `select_dd8_shift` already applies +/// on PE32+ for the same failure mode. +pub fn select_dd8_formula_pe32(data: &[u8], text_off: u32, text_size: u32) -> Option { + let num_pages_total = text_size / 0x1000; + let mut sample_pages: Vec = Vec::new(); + for frac in [0.25f64, 0.5, 0.75] { + let pg = (num_pages_total as f64 * frac) as u32; + if pg > 0 && pg < num_pages_total { + sample_pages.push(pg); + } + } + if sample_pages.is_empty() && num_pages_total > 1 { + sample_pages.push(num_pages_total / 2); + } + let score = |big: bool| -> i64 { + let mut total = 0i64; + for &sp in &sample_pages { + let pg_off = (text_off + sp * 0x1000) as usize; + if pg_off + 0x1000 > data.len() { + continue; + } + let mut buf = [0u8; 0x1000]; + buf.copy_from_slice(&data[pg_off..pg_off + 0x1000]); + let pk = if big { + 0x8000u32.wrapping_mul(sp.wrapping_add(1)) + } else { + sp.wrapping_add(1) + }; + let mut k = pk; + let rk = k.rotate_right(15); + k = rk; + for bi in 1..256u32 { + let rk = k.rotate_right(15); + let ri = rk.wrapping_add(bi); + k = ri.wrapping_add(bi); + let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize; + if tidx < buf.len() { + buf[tidx] ^= k as u8; + } + } + total += buf.iter().filter(|&&b| b == 0xCC).count() as i64; + } + total + }; + let s_small = score(false); + let s_big = score(true); + // Baseline: whole-page 0xCC over the same sample pages with NO dd8. dd8 only + // rewrites 255 bytes per page, so comparing the chosen formula's whole-page + // 0xCC against this baseline reveals whether dd8 *restores* int3 padding + // (count rises -> .text was packer-encrypted, apply) or merely scrambles + // already-plaintext code (count falls -> native-DLL .text left intact, skip). + let mut baseline: i64 = 0; + for &sp in &sample_pages { + let pg_off = (text_off + sp * 0x1000) as usize; + if pg_off + 0x1000 > data.len() { + continue; + } + baseline += data[pg_off..pg_off + 0x1000] + .iter() + .filter(|&&b| b == 0xCC) + .count() as i64; + } + let big = s_big > s_small; + let best = s_small.max(s_big); + // Minimum net 0xCC gain over the baseline before dd8 is applied. Noise on an + // already-plaintext `.text` is ~1 manufactured 0xCC per sampled page (3 pages + // -> ~3); every corpus build that genuinely needs dd8 gains +154 or more + // (observed +154 and +312), and the one native DLL that must skip scores -18. + // A floor of 32 sits ~10x above the noise and ~5x below the smallest true + // positive, so it changes no existing decision. + const MIN_DD8_NET_GAIN: i64 = 32; + let apply = best.saturating_sub(baseline) >= MIN_DD8_NET_GAIN; + if std::env::var("SEL_DIAG").is_ok() { + eprintln!( + "SEL pe32 dd8 s_small={} s_big={} baseline={} gain={} big={} apply={}", + s_small, + s_big, + baseline, + best - baseline, + big, + apply + ); + } + // When no interior pages could be sampled (tiny .text) we cannot measure the + // effect; preserve the historical behavior of applying dd8. + if sample_pages.is_empty() || apply { + Some(big) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// dd8 page-XOR shift selection. +// +// The packer scrambles ~1 byte per 16-byte block of .text via decrypt_data8, +// keyed by `page_idx << shift` (absolute page index = text_va >> 12). Observed +// shifts are 0 and 15. The shift is NOT stored in any header/config field: +// two otherwise-unrelated builds can carry byte-identical config-version stamps +// (0x40327253) yet require different shifts, so the only reliable discriminator +// is the .text content itself. +// +// Detection scoring formula: for each candidate shift, replay decrypt_data8 +// across a few sample pages (25/50/75% of .text) and count how many of the 255 +// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift +// hits int3 pads disproportionately often (~3-10x the baseline), so the +// highest-scoring shift wins. If neither shift clears 2x the baseline, .text +// is already plaintext → skip (return 99). +// +// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub +// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8 +// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub +// matched under BOTH shifts and the selector defaulted to 0 when the truth was +// 15. The 0xCC statistic samples hundreds of positions per page and is not +// fooled by a stub whose fixed bytes happen to survive. +// --------------------------------------------------------------------------- +pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 { + if text_size < 0x1000 { + return 0; + } + let text_off = text_va as usize; + let num_pages_total = text_size >> 12; + + // Sample pages at 25/50/75% of .text, falling back to the midpoint for tiny + // sections. + let mut sample_pages: Vec = Vec::new(); + for frac in [0.25f64, 0.5, 0.75] { + let pg = (num_pages_total as f64 * frac) as u32; + if pg > 0 && pg < num_pages_total { + sample_pages.push(pg); + } + } + if sample_pages.is_empty() && num_pages_total > 1 { + sample_pages.push(num_pages_total / 2); + } + if sample_pages.is_empty() { + return 0; + } + + let none_hits = score_dd8_baseline(data, text_off, &sample_pages); + let s0 = score_dd8_shift(data, text_off, text_va, &sample_pages, 0); + let s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15); + let mut best_score = none_hits; + let mut best_shift = 99u32; // 99 == skip dd8 + for (shift, hits) in [(0u32, s0), (15u32, s15)] { + if hits > best_score { + best_score = hits; + best_shift = shift; + } + } + // Require a clear 2x margin over the already-plaintext baseline AND an + // absolute floor. The 2x test alone + // trips on noise when the counts are tiny: an external-companion DLL whose + // .text is already plaintext scores s15=4 vs none=1 — a spurious 4x — and + // gets dd8 wrongly applied, corrupting ~1 byte per 16. Across the whole + // golden corpus every build that genuinely needs dd8 scores >= 10 (lowest + // observed scores at 10-12; up to 107), so a floor of 8 rejects the noise + // while keeping every golden's shift selection unchanged. + const MIN_DD8_HITS: u32 = 8; + if best_shift != 99 && (best_score < none_hits * 2 || best_score < MIN_DD8_HITS) { + best_shift = 99; + } + if std::env::var("SEL_DIAG").is_ok() { + eprintln!( + "SEL dd8 best_shift={} s0={} s15={} none_hits={} samples={:?}", + best_shift, s0, s15, none_hits, sample_pages + ); + } + best_shift +} + +// Baseline: count int3 pads already present at the first byte of each 16-byte +// block, i.e. the positions dd8 would target if its in-block offset were 0. +fn score_dd8_baseline(data: &[u8], text_off: usize, sample_pages: &[u32]) -> u32 { + let mut hits = 0u32; + for &sp in sample_pages { + let pg_off = text_off + (sp as usize) * 0x1000; + if pg_off + 0x1000 > data.len() { + continue; + } + for bi in 1..256usize { + if data[pg_off + bi * 16] == 0xCC { + hits += 1; + } + } + } + hits +} + +// Replay decrypt_data8 on each sample page under `shift` and count how many of +// the 255 mutated positions decode to 0xCC. +fn score_dd8_shift( + data: &[u8], + text_off: usize, + text_va: u32, + sample_pages: &[u32], + shift: u32, +) -> u32 { + let abs_base = text_va >> 12; + let mut hits = 0u32; + for &sp in sample_pages { + let pg_off = text_off + (sp as usize) * 0x1000; + if pg_off + 0x1000 > data.len() { + continue; + } + let abs_page = abs_base.wrapping_add(sp); + let mut key = abs_page << shift; + for bi in 0..256u32 { + let mixed = key.rotate_right(15).wrapping_add(bi); + key = mixed.wrapping_add(bi); + if bi == 0 { + continue; + } + let tidx = (bi.wrapping_mul(16).wrapping_add(mixed & 0xF)) as usize; + if tidx < 0x1000 { + let mutated = data[pg_off + tidx] ^ (key as u8); + if mutated == 0xCC { + hits += 1; + } + } + } + } + hits +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Seed the first `count` dd8-targeted positions of each sampled page with + /// the byte that decodes to `0xCC` under the `page+1` formula — i.e. an + /// encrypted `.text` whose plaintext is int3 padding. Positions whose key + /// byte would make the *ciphertext* itself `0xCC` are skipped so the + /// fixture contains no `0xCC` at all and every post-dd8 `0xCC` is a genuine + /// gain over a zero baseline. + fn seed_dd8_int3(data: &mut [u8], text_off: u32, pages: &[u32], count: u32) { + for &sp in pages { + let pg_off = (text_off + sp * 0x1000) as usize; + let mut k = sp.wrapping_add(1); + k = k.rotate_right(15); + let mut planted = 0u32; + for bi in 1..256u32 { + let ri = k.rotate_right(15).wrapping_add(bi); + k = ri.wrapping_add(bi); + if planted >= count { + continue; + } + let ct = 0xCCu8 ^ (k as u8); + if ct == 0xCC { + continue; + } + let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize; + data[pg_off + tidx] = ct; + planted += 1; + } + } + } + + /// Review regression: a near-plaintext `.text` must NOT be dd8-decrypted. + /// dd8 XORs 255 positions per page with pseudo-random bytes, so it + /// manufactures a few `0xCC` for free — under the old bare + /// `best > baseline` test any positive gain was enough to "apply" dd8 and + /// scramble ~1 byte per 16 of a native DLL's already-plaintext code, + /// silently (nothing downstream, including the integrity check, notices). + /// Here the gain is real but small; the floor must still reject it. + #[test] + fn pe32_dd8_skips_text_whose_gain_is_only_noise_sized() { + let text_off: u32 = 0x1000; + let text_size: u32 = 8 * 0x1000; + let mut data = vec![0u8; (text_off + text_size) as usize]; + seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 5); + assert!( + !data.contains(&0xCC), + "fixture must have a zero 0xCC baseline" + ); + assert_eq!( + select_dd8_formula_pe32(&data, text_off, text_size), + None, + "a gain this small is indistinguishable from dd8's own noise" + ); + } + + /// Control for the above: a `.text` whose dd8 pass restores a large amount + /// of int3 padding clears the floor and is decrypted. Same fixture shape, + /// only the amount of restored padding differs. + #[test] + fn pe32_dd8_applies_when_padding_is_restored() { + let text_off: u32 = 0x1000; + let text_size: u32 = 8 * 0x1000; + let mut data = vec![0u8; (text_off + text_size) as usize]; + seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 255); + assert_eq!( + select_dd8_formula_pe32(&data, text_off, text_size), + Some(false), + "encrypted .text must be decrypted with the page+1 formula" + ); + } +} diff --git a/senbei-pe/src/engine/layout/discovery.rs b/senbei-pe/src/engine/layout/discovery.rs new file mode 100644 index 0000000..830b48a --- /dev/null +++ b/senbei-pe/src/engine/layout/discovery.rs @@ -0,0 +1,507 @@ +//! Structural locators for protected PE stages. + +use senbei_crypto::primitives::{get_u32, lfsr_keystream}; + +/// Find the 4-byte v_val that follows the LAST occurrence of `48 EB 01 B9` +/// (REX.W jmp+1; mov ecx,imm32) plus any 0xCC padding. Used to locate +/// stage4's accum2 seed. Works across builds even when API-name anchors are +/// absent. +pub fn find_v_after_pad(data: &[u8], base: u32, len: u32) -> Option { + let start = base as usize; + let end = (base.saturating_add(len)) as usize; + if end > data.len() { + return None; + } + let sig = [0x48u8, 0xEB, 0x01, 0xB9]; + let slice = &data[start..end]; + // last occurrence + let mut last = None; + let mut i = 0usize; + while i + sig.len() <= slice.len() { + if slice[i..i + sig.len()] == sig { + last = Some(i); + } + i += 1; + } + let pos = last?; + // skip CCs after the `48 EB 01 B9` + let mut after = pos + sig.len(); + while after < slice.len() && slice[after] == 0xCC { + after += 1; + } + if after + 4 > slice.len() { + return None; + } + Some((start + after) as u32) +} + +/// Predict the 4 bytes that DecryptData5(va, size) would produce at va+0..va+4 +/// without mutating the buffer. The cipher's per-byte transform depends only +/// on the byte itself and the low 8 bits of (va+i), with no cross-byte state, +/// so each byte can be decrypted in isolation. Used to detect the EP/DD layout +/// offset before committing to the actual call. +pub fn trial_decrypt5_u32(data: &[u8], va: u32) -> u32 { + let mut out = [0u8; 4]; + for i in 0..4u32 { + let b3 = data[(va + i) as usize]; + let b = (va + i) as u8; + let b2 = b.wrapping_add(1); + let b4 = b3.rotate_left(2) ^ b2; + let b5 = b4.rotate_left(2) ^ b; + out[i as usize] = b5.rotate_left(2); + } + u32::from_le_bytes(out) +} + +/// Scan stage4/stage5 for the encrypted custom-decryptor bytecode block. The +/// raw byte at p+95 is used by decrypt_data6 as the iteration count. We trial- +/// decrypt that many bytes with the LFSR keystream and accept the first +/// position where the byte stream parses as a valid opcode sequence ending in +/// 195 (ret). +pub fn find_bytecode_offset(data: &[u8], base: u32, len: u32) -> Option { + let start = base as usize; + let end = (base.saturating_add(len)) as usize; + if end > data.len() { + return None; + } + let mut ks = [0u8; 256]; + lfsr_keystream(&mut ks); + // Scan forward from `start+16` on 16-byte boundaries relative to `start`. + // The bytecode block is positioned a fixed offset into stage4/stage5; the + // lowest parseable candidate is the real one (later ones are coincidental + // parses of trailing filler bytes that happen to map to valid opcodes). + // The enclosing buffer isn't necessarily 16-aligned to its absolute + // address in newer builds, so we anchor the stride to `start`. + let mut p = start + 16; + while p + 96 <= end { + let count = data[p + 95] as usize; + if count >= 8 && p + count <= end { + let mut buf = [0u8; 256]; + let take = count.min(256); + for i in 0..take { + buf[i] = data[p + i] ^ ks[i]; + } + if let Some(nops) = parse_bytecode_check(&buf[..take]) + && nops >= 4 + { + return Some(p as u32); + } + } + p += 16; + } + None +} + +/// Validate bytecode structure without allocating a `Vec` of ops. Returns +/// `Some(non_nop_op_count)` if the byte stream parses successfully as a valid +/// opcode sequence ending in 195 (ret), `None` otherwise. Allows non-trivial +/// bytecode filtering by op count. +pub fn parse_bytecode_check(buf: &[u8]) -> Option { + let mut i = 0usize; + let mut nops: usize = 0; + while i < buf.len() { + let b = buf[i]; + i += 1; + match b { + 4 | 44 | 52 => { + if i >= buf.len() { + return None; + } + i += 1; + nops += 1; + } + 144 => {} + 192 | 254 => { + if i >= buf.len() { + return None; + } + let mb = buf[i]; + i += 1; + let rm = mb & 7; + let mod_ = (mb >> 6) & 3; + let reg = (mb >> 3) & 7; + if mod_ != 3 || rm != 0 { + return None; + } + if reg > 1 { + return None; + } + if b == 192 { + if i >= buf.len() { + return None; + } + i += 1; + } + nops += 1; + } + 195 => return Some(nops), + _ => return None, + } + } + None +} + +/// Locate stage3's v4_val: the last non-zero dword in the buffer, anchored +/// by the `C3 CC CC CC` (ret + 3 int3) immediately before it. +pub fn find_v4_offset(data: &[u8], base: u32, len: u32) -> Option { + let start = base as usize; + let end = (base.saturating_add(len)) as usize; + if end > data.len() || end < start + 4 { + return None; + } + // walk backwards looking for the first non-zero byte + let mut i = end; + while i > start && data[i - 1] == 0 { + i -= 1; + } + if i < start + 4 { + return None; + } + // v_val occupies the 4 bytes ending at i (rounded up to dword boundary) + let v_end = i; + let v_start = ((v_end + 3) & !3).saturating_sub(4); + // require that the 4 bytes preceding v_val match `C3 CC CC CC` + if v_start < start + 4 || data[v_start - 4..v_start] != [0xC3, 0xCC, 0xCC, 0xCC] { + return None; + } + Some(v_start as u32) +} + +/// Scan a sub-buffer for an ASCII needle; return its absolute position. +pub fn find_str_pos(data: &[u8], base: u32, len: u32, needle: &[u8]) -> Option { + let start = base as usize; + let end = (base.saturating_add(len)) as usize; + if end > data.len() || needle.is_empty() { + return None; + } + data[start..end] + .windows(needle.len()) + .position(|w| w == needle) + .map(|rel| (start + rel) as u32) +} + +pub fn get_string_to_null(data: &[u8], offset: u32) -> String { + let start = offset as usize; + if start >= data.len() { + return String::new(); + } + // Bounded: an unterminated run must never walk off the end of the buffer + // (panic) or scan unboundedly into unrelated data. + let limit = start.saturating_add(4096).min(data.len()); + let mut i = start; + while i < limit && data[i] != 0 { + i += 1; + } + String::from_utf8_lossy(&data[start..i]).into_owned() +} + +/// Read a PE section-name field: exactly 8 bytes, NOT necessarily +/// NUL-terminated (a full-width name like `.textbss` has no NUL at all). +/// Returns the name with trailing NULs stripped. Using `get_string_to_null` +/// here would run past the field into the VirtualSize/VirtualAddress dwords. +pub fn section_name(data: &[u8], offset: u32) -> String { + let start = offset as usize; + let Some(field) = data.get(start..start + 8) else { + return String::new(); + }; + let end = field.iter().position(|&b| b == 0).unwrap_or(8); + String::from_utf8_lossy(&field[..end]).into_owned() +} + +// --------------------------------------------------------------------------- +// PE32 (32-bit) helpers +// --------------------------------------------------------------------------- + +/// PE32 shell-table locator. Walks the shell region (`info[6]`) for a dword +/// equal to `info[6]` followed by a plausible shell size, returning the table +/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer. +pub fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option { + let shell = info[6]; + if (data.len() as u64) < 0x100 { + return None; + } + let hi = (shell as u64) + .saturating_add(0x3000) + .min(data.len() as u64 - 0x100) as u32; + let mut off = shell; + while off < hi { + if off as usize + 8 <= data.len() { + let candidate = off.wrapping_sub(0x88); + if candidate >= shell && get_u32(data, off) == info[6] { + let shell_size_val = get_u32(data, off.wrapping_add(4)); + if shell_size_val > 0x1000 && shell_size_val < 0x100000 { + let v58_off = candidate.wrapping_add(0x58); + if (v58_off as usize + 4) <= data.len() { + let v58 = get_u32(data, v58_off); + if v58 > 0 && (v58 as usize) < data.len() { + return Some(candidate); + } + } + } + } + } + off = off.wrapping_add(4); + } + None +} + +/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region. +/// `start_off` is the byte offset to begin scanning at, `scan_backward` +/// controls direction. Returns the relative offset of the block. Includes full +/// opcode-walk validation of candidate blocks. +pub fn find_lfsr_block( + data: &[u8], + base: u32, + size: u32, + start_off: u32, + scan_backward: bool, +) -> Option { + if size < 96 { + return None; + } + let mut ks = [0u8; 128]; + lfsr_keystream(&mut ks); + let check = |scan_off: u32| -> bool { + let abs_off = base.wrapping_add(scan_off) as usize; + if abs_off + 96 > data.len() { + return false; + } + let sz = data[abs_off + 95] as usize; + if !(10..=95).contains(&sz) { + return false; + } + let mut decoded = [0u8; 95]; + for bi in 0..sz { + decoded[bi] = data[abs_off + bi] ^ ks[bi]; + } + // Full bytecode validation (shared with the stage4/5 locator): every + // opcode must decode with a valid ModR/M and the stream must REACH a + // RET (0xC3) as an opcode. The previous check only required a 0xC3 + // byte *anywhere* in the window and accepted a walk that ran off the + // end without hitting RET — a `0x04 0xC3` (ADD 0xC3) tail passed, so + // coincidental LFSR-shaped garbage was accepted as a decryptor block. + parse_bytecode_check(&decoded[..sz]).is_some() + }; + if scan_backward { + let hi = size - 96; + if hi >= start_off { + let mut scan_off = hi; + loop { + if check(scan_off) { + return Some(scan_off); + } + if scan_off == start_off { + break; + } + scan_off -= 1; + } + } + } else { + let hi = size - 95; + let mut scan_off = start_off; + while scan_off < hi { + if check(scan_off) { + return Some(scan_off); + } + scan_off += 1; + } + } + None +} + +/// Slots discovered in the eighthStage for the marker-less layout. +pub struct EighthSlots { + /// Absolute address of the file-data decryptor LFSR bytecode block. The + /// fileCS chain pointer is derived downstream as `file_lfsr - 0x58`. + pub file_lfsr: u32, + /// Absolute address of the compressedInfo (ptr,size) table pointer slot. + pub compressed_info_ptr: u32, +} + +/// Marker-independent eighthStage slot discovery (PE32+ branch). +/// +/// Newer Crackproof builds (e.g. some native/managed DLLs) omit the +/// `pm\0\0cm\0\0` and `00 00 00 40 01 00 00 00` markers that the older layout's +/// walk3/walk4/walk5 slot derivation relies on. Instead this discovers the +/// slots structurally: +/// * Scan the eighthStage for every LFSR (decrypt_data6) bytecode block. +/// * The file decryptor is the LFSR block whose `fileCS = lfsr - 0x58` holds +/// a pointer sitting just past `info[3]` (smallest positive distance). +/// * `compressedInfo` is the pointer slot whose 16-byte target, after a +/// trial `decrypt_data5`, parses as a plausible (src,sSize,dst,dSize) +/// descriptor. +/// +/// Returns `None` if no plausible file LFSR is found. `eighth_start`/`eighth_dsz` +/// bound the search region; `info3` is `info[3]`; `compress_data_offset` is +/// `(!u32(file_data,0x1080)) + 0x1000`; `file_data_len` is the protected file +/// length. +#[allow(clippy::too_many_arguments)] +pub fn discover_eighth_slots( + data: &[u8], + eighth_start: u32, + eighth_dsz: u32, + info3: u32, + compress_data_offset: u32, + file_data_len: u32, +) -> Option { + // Collect all LFSR candidates (forward scan). + // + // Advance by 1 after each hit, NOT by 96. A false-positive LFSR match can sit + // just before the real file-decryptor block (observed on an il2cpp game + // assembly build, 2026-07-13: junk at rel=0x31C1, real block at 0x3210). + // Stepping by the LFSR body size then skips the real block and discovery + // fails. Byte-stepping is cheap: eighthStage is only a few KB. + let mut all_lfsrs: Vec = Vec::new(); + let mut scan_off: u32 = 0; + while scan_off + 95 < eighth_dsz { + match find_lfsr_block(data, eighth_start, eighth_dsz, scan_off, false) { + Some(found) => { + all_lfsrs.push(found); + scan_off = found + 1; + } + None => break, + } + } + + // Pick the file LFSR: prefer the candidate whose fileCS pointer sits the + // smallest positive distance past info[3]. + let mut off_file_lfsr: Option = None; + let mut best_dist: Option = None; + for &lfsr_off in &all_lfsrs { + if lfsr_off < 0x58 { + continue; + } + let cs_off = lfsr_off - 0x58; + let cs_val = get_u32(data, eighth_start.wrapping_add(cs_off)); + if !(0x1000 < cs_val && (cs_val as usize) < data.len()) { + continue; + } + if cs_val < info3 { + continue; + } + let dist = cs_val - info3; + if best_dist.is_none_or(|b| dist < b) { + best_dist = Some(dist); + off_file_lfsr = Some(lfsr_off); + } + } + // Fallback: last LFSR with any in-image fileCS pointer. + if off_file_lfsr.is_none() { + for &lfsr_off in all_lfsrs.iter().rev() { + if lfsr_off < 0x58 { + continue; + } + let cs_val = get_u32(data, eighth_start.wrapping_add(lfsr_off - 0x58)); + if 0x1000 < cs_val && (cs_val as usize) < data.len() { + off_file_lfsr = Some(lfsr_off); + break; + } + } + } + let off_file_lfsr = off_file_lfsr?; + let off_file_cs = off_file_lfsr - 0x58; + + // Trial-decrypt to find compressedInfo: the pointer slot in the data area + // (between fileCS region start and the LFSR) whose target parses as a valid + // (src,sSize,dst,dSize) descriptor after a transient decrypt_data5. + let scan_from = off_file_lfsr.saturating_sub(0x400); + let mut off_compressed_info: Option = None; + let mut doff = scan_from; + while doff < off_file_lfsr { + if doff == off_file_cs { + doff += 4; + continue; + } + let ptr_val = get_u32(data, eighth_start.wrapping_add(doff)); + if !(0x1000 < ptr_val && (ptr_val as usize) < data.len().saturating_sub(16)) { + doff += 4; + continue; + } + // Predict decrypt_data5(ptr_val, 16) without mutating: each dword is + // position-keyed and independent, so trial_decrypt5_u32 per dword. + let src2 = trial_decrypt5_u32(data, ptr_val); + let s_sz2 = trial_decrypt5_u32(data, ptr_val + 4); + let dst2 = trial_decrypt5_u32(data, ptr_val + 8); + let d_sz2 = trial_decrypt5_u32(data, ptr_val + 12); + let src_file_off = src2.wrapping_add(compress_data_offset); + let valid = s_sz2 > 0 + && s_sz2 < 0x200000 + && (src_file_off as u64 + s_sz2 as u64) <= file_data_len as u64 + && dst2 >= 0x1000 + && (dst2 as u64 + d_sz2 as u64) <= data.len() as u64 + && d_sz2 >= s_sz2 + && d_sz2 < 0x200000; + if valid { + off_compressed_info = Some(doff); + break; + } + doff += 4; + } + let off_compressed_info = off_compressed_info?; + + Some(EighthSlots { + file_lfsr: eighth_start.wrapping_add(off_file_lfsr), + compressed_info_ptr: eighth_start.wrapping_add(off_compressed_info), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Task 4.1 regression: build a synthetic buffer whose valid bytecode block + /// sits PAST `len` but within `len*2`. Assert that the smaller window misses + /// it and the doubled window finds it. + #[test] + fn bytecode_locate_double_window_retry() { + // We place the block at offset (base + len + 16) which is inside + // the len*2 window but outside the len window. + let base: u32 = 0; + let len: u32 = 256; + // Block sits at base + len + 16 = 272, aligned to 16. + let block_pos: usize = (base + len + 16) as usize; // 272 + + // The buffer must be large enough for the block (block_pos + 96 bytes). + let buf_len = block_pos + 256; + let mut buf = vec![0u8; buf_len]; + + // Build a valid plaintext op stream: + // [4, 0, 4, 0, 4, 0, 4, 0, 195] (4 ADD-AL ops then RET) + // Padded to 10 bytes total; count >= 8. + let count: usize = 10; + let mut plain = [0u8; 256]; + plain[0] = 4; + plain[1] = 0; + plain[2] = 4; + plain[3] = 0; + plain[4] = 4; + plain[5] = 0; + plain[6] = 4; + plain[7] = 0; + plain[8] = 195; // ret + + // Compute the LFSR keystream and XOR the first `count` bytes to get the + // encrypted representation that the scanner would decrypt back. + let mut ks = [0u8; 256]; + lfsr_keystream(&mut ks); + for i in 0..count { + buf[block_pos + i] = plain[i] ^ ks[i]; + } + // Raw count byte at block_pos+95 (outside the XOR range since count=10 < 95). + buf[block_pos + 95] = count as u8; + + // Verify our construction: find_bytecode_offset with len should NOT find it. + assert_eq!( + find_bytecode_offset(&buf, base, len), + None, + "smaller window should not find the block" + ); + + // The doubled window should find it at block_pos. + assert_eq!( + find_bytecode_offset(&buf, base, len.saturating_mul(2)), + Some(block_pos as u32), + "doubled window should locate the block" + ); + } +} diff --git a/senbei-pe/src/engine/layout/image.rs b/senbei-pe/src/engine/layout/image.rs new file mode 100644 index 0000000..04065cd --- /dev/null +++ b/senbei-pe/src/engine/layout/image.rs @@ -0,0 +1,481 @@ +//! PE import reconstruction and memory-image compaction. + +use senbei_crypto::primitives::{get_u16, get_u32, write_u16, write_u32}; + +use super::super::MAX_IMAGE_SIZE; + +/// Read a NUL-terminated byte string starting at `off`, bounded to 512 bytes. +/// Returns the raw bytes up to the terminator (excluding it). +fn read_cstr_bounded(data: &[u8], off: u32) -> Vec { + let start = off as usize; + if start >= data.len() { + return Vec::new(); + } + let limit = (start + 512).min(data.len()); + let mut end = start; + while end < limit && data[end] != 0 { + end += 1; + } + data[start..end].to_vec() +} + +fn align_up_u32(value: u32, alignment: u32) -> u32 { + ((value.wrapping_add(alignment - 1)) / alignment).wrapping_mul(alignment) +} + +fn align_up_u64(value: u64, alignment: u64) -> u64 { + value.div_ceil(alignment) * alignment +} + +#[derive(Clone)] +enum ImportFunc { + Ordinal(u32), + Name(u16, Vec), +} + +struct ImportDesc { + time_date: u32, + fwd_chain: u32, + dll_name: Vec, + iat_rva: u32, + functions: Vec, +} + +/// Return true when PE32 imports already sit in the original `.idata` layout +/// (so no relocation to `.kmiat` is needed). May write the IAT data directory +/// (pe+0xD8). +pub fn pe32_imports_already_match_idata_layout(data: &mut [u8], pe_header: u32) -> bool { + let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; + let sec_table = pe_header.wrapping_add(24).wrapping_add(opt_hdr_size); + let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; + let import_rva = get_u32(data, pe_header.wrapping_add(0x80)); + let import_size = get_u32(data, pe_header.wrapping_add(0x84)); + let len = data.len() as u32; + if !(import_rva > 0 && import_size > 0) { + return false; + } + for idx in 0..num_sections { + let sec_off = sec_table.wrapping_add(idx * 40); + if (sec_off as usize + 40) > data.len() { + return false; + } + if &data[sec_off as usize..sec_off as usize + 6] != b".idata" { + continue; + } + let sec_va = get_u32(data, sec_off.wrapping_add(12)); + let sec_size = + get_u32(data, sec_off.wrapping_add(8)).max(get_u32(data, sec_off.wrapping_add(16))); + let sec_end = sec_va.wrapping_add(sec_size); + if !(sec_va <= import_rva + && import_rva < sec_end + && import_rva.wrapping_add(import_size) <= sec_end) + { + continue; + } + let first_oft = get_u32(data, import_rva); + let first_name = get_u32(data, import_rva.wrapping_add(12)); + let first_iat = get_u32(data, import_rva.wrapping_add(16)); + if !(sec_va <= first_oft + && first_oft < sec_end + && sec_va <= first_iat + && first_iat < sec_end) + { + return false; + } + if !(0x1000 < first_name && first_name < len) { + return false; + } + let dll_name = read_cstr_bounded(data, first_name); + let lower: Vec = dll_name.iter().map(|b| b.to_ascii_lowercase()).collect(); + if !lower.ends_with(b".dll") { + return false; + } + let mut iat_min = first_iat; + let mut iat_max = first_iat; + let mut idt_pos = import_rva; + while idt_pos.wrapping_add(20) <= len { + let oft_rva = get_u32(data, idt_pos); + let name_rva = get_u32(data, idt_pos.wrapping_add(12)); + let iat_rva = get_u32(data, idt_pos.wrapping_add(16)); + if oft_rva == 0 && name_rva == 0 && iat_rva == 0 { + break; + } + if !(sec_va <= oft_rva && oft_rva < sec_end && sec_va <= iat_rva && iat_rva < sec_end) { + return false; + } + let mut thunk = iat_rva; + while thunk.wrapping_add(4) <= sec_end { + let tv = get_u32(data, thunk); + thunk = thunk.wrapping_add(4); + if tv == 0 { + break; + } + } + iat_min = iat_min.min(iat_rva); + iat_max = iat_max.max(thunk); + idt_pos = idt_pos.wrapping_add(20); + } + if iat_max > iat_min { + write_u32(data, pe_header.wrapping_add(0xD8), iat_min); + write_u32(data, pe_header.wrapping_add(0xDC), iat_max - iat_min); + } + return true; + } + false +} + +/// Rebuild PE32 import metadata (descriptors, lookup tables, names) into the +/// last section as `.kmiat`, leaving the loader-written IAT in place. Mutates +/// `data` (may grow it). +pub fn move_pe32_imports_to_kmiat(data: &mut Vec, pe_header: u32) { + const SECTION_SIZE: u32 = 0x7000; + let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; + let opt_hdr = pe_header.wrapping_add(24); + let sec_table = opt_hdr.wrapping_add(opt_hdr_size); + let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; + if num_sections == 0 { + return; + } + let import_rva = get_u32(data, pe_header.wrapping_add(0x80)); + let import_size = get_u32(data, pe_header.wrapping_add(0x84)); + let len = data.len() as u32; + if !(0x1000 < import_rva && import_rva < len && import_size > 0 && import_size < SECTION_SIZE) { + return; + } + + let mut descriptors: Vec = Vec::new(); + let mut idt_pos = import_rva; + while idt_pos.wrapping_add(20) <= len { + let oft_rva = get_u32(data, idt_pos); + let time_date = get_u32(data, idt_pos.wrapping_add(4)); + let fwd_chain = get_u32(data, idt_pos.wrapping_add(8)); + let name_rva = get_u32(data, idt_pos.wrapping_add(12)); + let iat_rva = get_u32(data, idt_pos.wrapping_add(16)); + if oft_rva == 0 && name_rva == 0 && iat_rva == 0 { + break; + } + if !(0x1000 < name_rva && name_rva < len) { + break; + } + let dll_name = read_cstr_bounded(data, name_rva); + let thunk_rva = if 0x1000 < oft_rva && oft_rva < len { + oft_rva + } else { + iat_rva + }; + let mut functions: Vec = Vec::new(); + let mut thunk_pos = thunk_rva; + while 0x1000 < thunk_pos.wrapping_add(4) && thunk_pos.wrapping_add(4) <= len { + let thunk_val = get_u32(data, thunk_pos); + if thunk_val == 0 { + break; + } + if thunk_val & 0x8000_0000 != 0 { + functions.push(ImportFunc::Ordinal(thunk_val & 0xFFFF)); + } else { + let hint = if thunk_val.wrapping_add(2) <= len { + get_u16(data, thunk_val) + } else { + 0 + }; + let func_name = if thunk_val.wrapping_add(2) < len { + read_cstr_bounded(data, thunk_val.wrapping_add(2)) + } else { + Vec::new() + }; + functions.push(ImportFunc::Name(hint, func_name)); + } + thunk_pos = thunk_pos.wrapping_add(4); + } + descriptors.push(ImportDesc { + time_date, + fwd_chain, + dll_name, + iat_rva, + functions, + }); + idt_pos = idt_pos.wrapping_add(20); + } + if descriptors.is_empty() { + return; + } + + for desc in &mut descriptors { + let lower: Vec = desc + .dll_name + .iter() + .map(|b| b.to_ascii_lowercase()) + .collect(); + if lower.starts_with(b"api-ms-win-crt-") { + desc.dll_name = b"ucrtbase.dll".to_vec(); + } else { + desc.dll_name = lower; + } + } + descriptors.sort_by_key(|d| d.iat_rva); + + let last_sec = sec_table.wrapping_add((num_sections - 1) * 40); + let kmiat_rva = get_u32(data, last_sec.wrapping_add(12)); + // A zero last-section VA means a corrupt section table: building .kmiat at + // RVA 0 would zero the DOS/PE headers and emit a structurally broken image + // with no error. Bail and keep the original import table. + if kmiat_rva == 0 { + return; + } + // Grow the image when .kmiat overruns it, but cap the growth: a corrupt VA + // could otherwise request a multi-gigabyte allocation, which aborts the + // process (uncatchable). Use u64 math so a near-u32::MAX VA cannot wrap the + // end calculation the way the previous wrapping/plain-add mix could. + let kmiat_end = kmiat_rva as u64 + SECTION_SIZE as u64; + if kmiat_end > MAX_IMAGE_SIZE { + return; + } + if kmiat_end > data.len() as u64 { + data.resize(kmiat_end as usize, 0); + } + // Zero the .kmiat region. + for b in &mut data[kmiat_rva as usize..kmiat_end as usize] { + *b = 0; + } + + let idt_size = (descriptors.len() as u32 + 1) * 20; + let oft_start = kmiat_rva; + let mut idt_rva = oft_start; + for desc in &descriptors { + idt_rva = idt_rva.wrapping_add((desc.functions.len() as u32 + 1) * 4); + } + idt_rva = align_up_u32(idt_rva.wrapping_add(0x2C), 4); + + // Size check: compute the final name_pos and bail if it overruns .kmiat. + let mut name_pos_check = idt_rva.wrapping_add(idt_size); + for desc in &descriptors { + name_pos_check = name_pos_check.wrapping_add(desc.dll_name.len() as u32 + 1); + for func in &desc.functions { + if let ImportFunc::Name(_, fname) = func { + name_pos_check = name_pos_check.wrapping_add(2 + fname.len() as u32 + 1); + } + } + } + if name_pos_check > kmiat_rva.wrapping_add(SECTION_SIZE) { + // Section too small; keep existing import table untouched. + return; + } + + let mut oft_pos = oft_start; + let mut name_pos = idt_rva.wrapping_add(idt_size); + for (idx, desc) in descriptors.iter().enumerate() { + let idt_entry = idt_rva.wrapping_add(idx as u32 * 20); + let current_oft = oft_pos; + write_u32(data, idt_entry, current_oft); + write_u32(data, idt_entry.wrapping_add(4), desc.time_date); + write_u32(data, idt_entry.wrapping_add(8), desc.fwd_chain); + let dll_name_pos = name_pos; + write_u32(data, idt_entry.wrapping_add(12), dll_name_pos); + write_u32(data, idt_entry.wrapping_add(16), desc.iat_rva); + + let dnp = dll_name_pos as usize; + data[dnp..dnp + desc.dll_name.len()].copy_from_slice(&desc.dll_name); + data[dnp + desc.dll_name.len()] = 0; + name_pos = name_pos.wrapping_add(desc.dll_name.len() as u32 + 1); + + for func in &desc.functions { + match func { + ImportFunc::Ordinal(ord) => { + write_u32(data, oft_pos, 0x8000_0000 | ord); + } + ImportFunc::Name(hint, fname) => { + let hint_name_rva = name_pos; + write_u32(data, oft_pos, hint_name_rva); + write_u16(data, hint_name_rva, *hint as u32); + let fp = (hint_name_rva + 2) as usize; + data[fp..fp + fname.len()].copy_from_slice(fname); + data[fp + fname.len()] = 0; + name_pos = name_pos.wrapping_add(2 + fname.len() as u32 + 1); + } + } + oft_pos = oft_pos.wrapping_add(4); + } + write_u32(data, oft_pos, 0); + oft_pos = oft_pos.wrapping_add(4); + } + // Null-terminator IDT entry (20 zero bytes) after the last descriptor. + let term = idt_rva.wrapping_add(descriptors.len() as u32 * 20) as usize; + for b in &mut data[term..term + 20] { + *b = 0; + } + + let ls = last_sec as usize; + data[ls..ls + 8].copy_from_slice(b".kmiat\x00\x00"); + write_u32(data, last_sec.wrapping_add(8), SECTION_SIZE); + write_u32(data, last_sec.wrapping_add(16), SECTION_SIZE); + write_u32(data, last_sec.wrapping_add(36), 0xE000_0060); + write_u32(data, pe_header.wrapping_add(0x80), idt_rva); + write_u32(data, pe_header.wrapping_add(0x84), idt_size); + write_u32( + data, + pe_header.wrapping_add(80), + kmiat_rva.wrapping_add(SECTION_SIZE), + ); +} + +/// Convert the unpacked RVA-addressed image back to a compact PE file layout +/// (headers at 0x400, sections packed consecutively, FileAlignment 0x200). +/// Returns `None` if the accumulated output size wraps or exceeds +/// [`MAX_IMAGE_SIZE`]: the final allocation is sized from header-derived +/// section data, and an uncapped `vec![0; n]` from a corrupt header would abort +/// the process (which `catch_unpack` cannot trap). +pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option> { + const FILE_ALIGNMENT: u32 = 0x200; + const HEADER_SIZE: u32 = 0x400; + let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; + let opt_hdr = pe_header.wrapping_add(24); + let sec_table = opt_hdr.wrapping_add(opt_hdr_size); + let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; + + struct SecLayout { + sec_off: u32, + va: u32, + vsize: u32, + raw_ptr: u32, + raw_size: u32, + } + + let mut raw_cursor: u64 = HEADER_SIZE as u64; + let mut raw_layout: Vec = Vec::new(); + for idx in 0..num_sections { + let sec_off = sec_table.wrapping_add(idx * 40); + let vsize = get_u32(data, sec_off.wrapping_add(8)); + let va = get_u32(data, sec_off.wrapping_add(12)); + let sd_start = va as usize; + let sd_end = if (va.wrapping_add(vsize) as usize) <= data.len() { + va.wrapping_add(vsize) as usize + } else { + data.len() + }; + let section_data: &[u8] = if sd_start <= sd_end && sd_start <= data.len() { + &data[sd_start..sd_end] + } else { + &[] + }; + + let mut last_nonzero: i64 = -1; + for pos in (0..section_data.len()).rev() { + if section_data[pos] != 0 { + last_nonzero = pos as i64; + break; + } + } + let meaningful = if last_nonzero >= 0 { + (last_nonzero + 1) as u32 + } else { + 0 + }; + let mut raw_size = if meaningful != 0 { + align_up_u32(meaningful, FILE_ALIGNMENT) + } else { + 0 + }; + if vsize != 0 && raw_size == 0 { + raw_size = FILE_ALIGNMENT; + } + raw_size = raw_size.min(align_up_u32(section_data.len() as u32, FILE_ALIGNMENT)); + + let raw_ptr = if raw_size != 0 { raw_cursor as u32 } else { 0 }; + raw_layout.push(SecLayout { + sec_off, + va, + vsize, + raw_ptr, + raw_size, + }); + if raw_size != 0 { + // Accumulate in u64 and cap: section sizes are header-derived, and + // a corrupt table could otherwise wrap raw_cursor (small alloc, + // huge recorded raw_ptrs → OOB panic) or request an abort-sized + // allocation. + raw_cursor = align_up_u64(raw_cursor + raw_size as u64, FILE_ALIGNMENT as u64); + if raw_cursor > MAX_IMAGE_SIZE { + return None; + } + } + } + + let mut compact = vec![0u8; raw_cursor as usize]; + let hdr_copy = (HEADER_SIZE as usize).min(data.len()); + compact[..hdr_copy].copy_from_slice(&data[..hdr_copy]); + write_u32(&mut compact, opt_hdr.wrapping_add(36), FILE_ALIGNMENT); + write_u32(&mut compact, opt_hdr.wrapping_add(60), HEADER_SIZE); + + for sl in &raw_layout { + write_u32(&mut compact, sl.sec_off.wrapping_add(16), sl.raw_size); + write_u32(&mut compact, sl.sec_off.wrapping_add(20), sl.raw_ptr); + if sl.raw_size != 0 { + let sd_start = sl.va as usize; + let sd_end = if (sl.va.wrapping_add(sl.vsize) as usize) <= data.len() { + sl.va.wrapping_add(sl.vsize) as usize + } else { + data.len() + }; + let section_data: &[u8] = if sd_start <= sd_end { + &data[sd_start..sd_end] + } else { + &[] + }; + let copy_size = (sl.raw_size as usize).min(section_data.len()); + let rp = sl.raw_ptr as usize; + compact[rp..rp + copy_size].copy_from_slice(§ion_data[..copy_size]); + } + } + Some(compact) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Review regression: a zero last-section VA (corrupt section table) must + /// bail instead of building .kmiat at RVA 0 — the old code zeroed + /// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image + /// as a success. A near-2 GiB VA must likewise refuse to grow the image + /// past [`MAX_IMAGE_SIZE`]. + #[test] + fn kmiat_bogus_section_va_bails_without_wiping_headers() { + for last_sec_va in [0u32, 0x5000_0000] { + let pe: u32 = 0x80; + let mut data = vec![0xAAu8; 0x8000]; + // COFF header: 1 section, optional header size 0xE0 (PE32). + write_u16(&mut data, pe + 6, 1); + write_u16(&mut data, pe + 20, 0xE0); + // Import directory at pe+0x80: one descriptor + null terminator. + write_u32(&mut data, pe + 0x80, 0x1100); + write_u32(&mut data, pe + 0x84, 0x28); + write_u32(&mut data, 0x1100, 0x1200); // OFT rva + write_u32(&mut data, 0x1100 + 12, 0x1300); // name rva + write_u32(&mut data, 0x1100 + 16, 0x1400); // IAT rva + for b in &mut data[0x1100 + 20..0x1100 + 40] { + *b = 0; // null terminator descriptor + } + data[0x1300..0x1300 + 13].copy_from_slice(b"KERNEL32.dll\0"); + write_u32(&mut data, 0x1200, 0x1500); // thunk -> hint/name + write_u32(&mut data, 0x1204, 0); // thunk terminator + data[0x1500..0x1502].copy_from_slice(&0u16.to_le_bytes()); + data[0x1502..0x1502 + 12].copy_from_slice(b"ExitProcess\0"); + // Section table at pe+24+0xE0 = 0x178; VA field at +12. + write_u32(&mut data, 0x178 + 12, last_sec_va); + + let head_before: Vec = data[..0x400].to_vec(); + let len_before = data.len(); + move_pe32_imports_to_kmiat(&mut data, pe); + assert_eq!( + data.len(), + len_before, + "VA 0x{last_sec_va:08X}: image must not grow" + ); + assert_eq!( + &data[..0x400], + &head_before[..], + "VA 0x{last_sec_va:08X}: headers must be untouched" + ); + } + } +} diff --git a/src/unpacker/mod.rs b/senbei-pe/src/engine/mod.rs similarity index 90% rename from src/unpacker/mod.rs rename to senbei-pe/src/engine/mod.rs index 35447a2..ad0ec6c 100644 --- a/src/unpacker/mod.rs +++ b/senbei-pe/src/engine/mod.rs @@ -1,30 +1,28 @@ -//! Pure, panic-free Crackproof unpacker core. No file I/O lives here. +//! PE detection, unpacking, and structural validation. -mod bytecode; -mod crc32; pub mod dll; +mod error; pub mod exe; pub mod integrity; +mod layout; pub(crate) mod parallel; -pub(crate) mod primitives; -mod tables; +use senbei_crypto::primitives; use std::cell::RefCell; use std::sync::{Arc, Mutex}; pub use dll::{unpack_dll, unpack_dll_v}; -pub use exe::{ - BufferOperation, BytecodeStage, DecompressionFailure, DecompressionStage, DescriptorTable, - SectionPipeline, UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v, -}; +pub use error::*; +pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v}; pub use integrity::{IntegrityReport, check as check_integrity}; +pub use parallel::thread_cap; /// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer /// for. Guards against a corrupt/crafted header requesting a multi-gigabyte /// (or, as a sign-extended negative `i32`, multi-exabyte) allocation, which /// would abort the process — an abort that `catch_unpack` below cannot trap. /// Real protected binaries are far below this. -pub(crate) const MAX_IMAGE_SIZE: u64 = 1 << 30; // 1 GiB +pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE; #[derive(Clone)] pub(crate) struct PanicCapture(Arc>>); @@ -56,7 +54,6 @@ impl PanicCapture { Self(Arc::new(Mutex::new(None))) } - #[cfg(not(target_arch = "wasm32"))] fn record(&self, info: &std::panic::PanicHookInfo<'_>) { let location = info.location(); let details = PanicDetails { @@ -123,7 +120,6 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { } } -#[cfg(not(target_arch = "wasm32"))] fn install_panic_capture_hook() { static INSTALL: std::sync::Once = std::sync::Once::new(); INSTALL.call_once(|| { @@ -142,9 +138,6 @@ fn install_panic_capture_hook() { }); } -#[cfg(target_arch = "wasm32")] -fn install_panic_capture_hook() {} - pub(crate) fn current_panic_capture() -> Option { ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone()) } @@ -166,8 +159,6 @@ pub(crate) fn catch_unpack(f: F) -> Result, UnpackError> where F: FnOnce() -> Result, UnpackError>, { - // Hook capture is skipped on wasm: the prebuilt std cannot unwind there, - // so a panic traps immediately. The Web Worker boundary reports that trap. install_panic_capture_hook(); let capture = PanicCapture::new(); let r = with_panic_capture(Some(capture.clone()), || { @@ -213,11 +204,8 @@ fn key_table(input: &[u8]) -> Option<[u32; 8]> { if input.len() < 4128 { return None; } - // Validate PE signature. `checked_add`, not `+`: `usize` is 32-bit on - // wasm32, where an `e_lfanew` of 0xFFFF_FFFC..=0xFFFF_FFFF wraps the bound - // check, and the slice below then panics with start > end. `detect` runs on - // the folder-scan threads and (in the web app) on the main thread outside - // the disposable-worker isolation, so it must not panic on any input. + // Validate the PE signature with checked arithmetic so a crafted offset + // cannot wrap the bounds check on a narrower target. let e_lfanew = primitives::get_u32(input, 0x3C); let pe_start = e_lfanew as usize; if pe_start.checked_add(4).is_none_or(|end| end > input.len()) { @@ -349,7 +337,6 @@ pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec), Unp mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] #[test] fn caught_panic_reports_location_and_message() { let error = catch_unpack(|| -> Result, UnpackError> { @@ -366,12 +353,14 @@ mod tests { panic!("unexpected error: {error}"); }; assert_eq!(message, "test panic"); - assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs")); + assert!( + file.ends_with("senbei-pe/src/engine/mod.rs") + || file.ends_with("senbei-pe\\src\\engine\\mod.rs") + ); assert!(line > 0); assert!(column > 0); } - #[cfg(not(target_arch = "wasm32"))] #[test] fn worker_panic_keeps_the_worker_source_location() { let error = catch_unpack(|| -> Result, UnpackError> { @@ -396,7 +385,10 @@ mod tests { panic!("unexpected error: {error}"); }; assert_eq!(message, "worker panic"); - assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs")); + assert!( + file.ends_with("senbei-pe/src/engine/mod.rs") + || file.ends_with("senbei-pe\\src\\engine\\mod.rs") + ); assert!(line > 0); assert!(column > 0); } diff --git a/src/unpacker/parallel.rs b/senbei-pe/src/engine/parallel.rs similarity index 99% rename from src/unpacker/parallel.rs rename to senbei-pe/src/engine/parallel.rs index 5f20d3d..fad1132 100644 --- a/src/unpacker/parallel.rs +++ b/senbei-pe/src/engine/parallel.rs @@ -21,7 +21,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// Worker-thread cap. `SENBEI_THREADS` overrides it (`1` forces the sequential /// path); otherwise the host's available parallelism; otherwise 1. -pub(crate) fn thread_cap() -> usize { +pub fn thread_cap() -> usize { if let Ok(v) = std::env::var("SENBEI_THREADS") && let Ok(n) = v.trim().parse::() && n >= 1 diff --git a/senbei-pe/src/lib.rs b/senbei-pe/src/lib.rs new file mode 100644 index 0000000..5db4e9a --- /dev/null +++ b/senbei-pe/src/lib.rs @@ -0,0 +1,5 @@ +//! PE detection, unpacking, and structural validation. + +mod engine; + +pub use engine::*; diff --git a/src/unpacker/primitives.rs b/src/unpacker/primitives.rs deleted file mode 100644 index 886b159..0000000 --- a/src/unpacker/primitives.rs +++ /dev/null @@ -1,2394 +0,0 @@ -//! Shared crypto primitives and helper utilities. -//! -//! All functions here are `pub(crate)` so that both the EXE unpacker (`exe.rs`) -//! and the future DLL unpacker (`dll.rs`) can call them without duplication. -//! Each free function is self-contained: it takes the relevant byte buffer(s) -//! and parameters explicitly, with no coupling to the EXE `Unpacker` struct. - -use super::bytecode::{Op, OpsLut}; -use super::crc32; -use super::tables::{COLUMMIX1, COLUMMIX2, COLUMMIX3, COLUMMIX4, SBOX}; -use std::cell::RefCell; - -thread_local! { - /// Reusable scratch for `decompress`. A single unpack runs `decompress` - /// hundreds of times over small blocks; reusing one growable buffer avoids a - /// fresh allocation each call. Thread-local, so it stays correct (one buffer - /// per worker) under the parallel block fan-out. - static DECOMPRESS_SCRATCH: RefCell> = const { RefCell::new(Vec::new()) }; -} - -// --------------------------------------------------------------------------- -// Byte-order accessors -// --------------------------------------------------------------------------- - -pub(crate) fn get_u16(data: &[u8], offset: u32) -> u16 { - let i = offset as usize; - u16::from_le_bytes([data[i], data[i + 1]]) -} - -pub(crate) fn get_u32(data: &[u8], offset: u32) -> u32 { - let i = offset as usize; - u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) -} - -pub(crate) fn get_u64(data: &[u8], offset: u32) -> u64 { - let i = offset as usize; - u64::from_le_bytes([ - data[i], - data[i + 1], - data[i + 2], - data[i + 3], - data[i + 4], - data[i + 5], - data[i + 6], - data[i + 7], - ]) -} - -pub(crate) fn write_u16(data: &mut [u8], offset: u32, value: u32) { - let i = offset as usize; - let v = value as u16; - let b = v.to_le_bytes(); - data[i] = b[0]; - data[i + 1] = b[1]; -} - -pub(crate) fn write_u32(data: &mut [u8], offset: u32, value: u32) { - let i = offset as usize; - let b = value.to_le_bytes(); - data[i] = b[0]; - data[i + 1] = b[1]; - data[i + 2] = b[2]; - data[i + 3] = b[3]; -} - -// --------------------------------------------------------------------------- -// Checked accessors (return Err instead of panicking on OOB) -// --------------------------------------------------------------------------- - -#[allow(dead_code)] -pub(crate) fn try_u32(d: &[u8], off: usize) -> Result { - let end = off - .checked_add(4) - .ok_or(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::Read, - offset: off, - size: 4, - buffer_len: d.len(), - })?; - d.get(off..end) - .map(|s| u32::from_le_bytes(s.try_into().unwrap())) - .ok_or(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::Read, - offset: off, - size: 4, - buffer_len: d.len(), - }) -} - -#[allow(dead_code)] -pub(crate) fn try_i32(d: &[u8], off: usize) -> Result { - try_u32(d, off).map(|v| v as i32) -} - -/// Checked copy with distinct source and destination range errors. -pub(crate) fn try_copy_from_slice( - dst: &mut [u8], - dst_off: usize, - dst_len: usize, - src: &[u8], - src_off: usize, -) -> Result<(), super::UnpackError> { - let dst_end = - dst_off - .checked_add(dst_len) - .ok_or(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::CopyDestination, - offset: dst_off, - size: dst_len, - buffer_len: dst.len(), - })?; - let src_end = - src_off - .checked_add(dst_len) - .ok_or(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::CopySource, - offset: src_off, - size: dst_len, - buffer_len: src.len(), - })?; - if dst_end > dst.len() { - return Err(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::CopyDestination, - offset: dst_off, - size: dst_len, - buffer_len: dst.len(), - }); - } - if src_end > src.len() { - return Err(super::UnpackError::BufferRangeOutOfBounds { - operation: super::BufferOperation::CopySource, - offset: src_off, - size: dst_len, - buffer_len: src.len(), - }); - } - dst[dst_off..dst_end].copy_from_slice(&src[src_off..src_end]); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Locator helpers -// --------------------------------------------------------------------------- - -/// Find the 4-byte v_val that follows the LAST occurrence of `48 EB 01 B9` -/// (REX.W jmp+1; mov ecx,imm32) plus any 0xCC padding. Used to locate -/// stage4's accum2 seed. Works across builds even when API-name anchors are -/// absent. -pub(crate) fn find_v_after_pad(data: &[u8], base: u32, len: u32) -> Option { - let start = base as usize; - let end = (base.saturating_add(len)) as usize; - if end > data.len() { - return None; - } - let sig = [0x48u8, 0xEB, 0x01, 0xB9]; - let slice = &data[start..end]; - // last occurrence - let mut last = None; - let mut i = 0usize; - while i + sig.len() <= slice.len() { - if slice[i..i + sig.len()] == sig { - last = Some(i); - } - i += 1; - } - let pos = last?; - // skip CCs after the `48 EB 01 B9` - let mut after = pos + sig.len(); - while after < slice.len() && slice[after] == 0xCC { - after += 1; - } - if after + 4 > slice.len() { - return None; - } - Some((start + after) as u32) -} - -/// Predict the 4 bytes that DecryptData5(va, size) would produce at va+0..va+4 -/// without mutating the buffer. The cipher's per-byte transform depends only -/// on the byte itself and the low 8 bits of (va+i), with no cross-byte state, -/// so each byte can be decrypted in isolation. Used to detect the EP/DD layout -/// offset before committing to the actual call. -pub(crate) fn trial_decrypt5_u32(data: &[u8], va: u32) -> u32 { - let mut out = [0u8; 4]; - for i in 0..4u32 { - let b3 = data[(va + i) as usize]; - let b = (va + i) as u8; - let b2 = b.wrapping_add(1); - let b4 = b3.rotate_left(2) ^ b2; - let b5 = b4.rotate_left(2) ^ b; - out[i as usize] = b5.rotate_left(2); - } - u32::from_le_bytes(out) -} - -/// Reproduce the LFSR keystream that decrypt_data6 XORs in. Used to -/// trial-decrypt candidate bytecode positions without mutating the buffer. -pub(crate) fn lfsr_keystream(out: &mut [u8]) { - let mut state: u32 = 1; - for byte in out.iter_mut() { - let mut b: u8 = 0; - for k in 0..8u32 { - b |= ((state & 1) << k) as u8; - state <<= 1; - if state & 0x8000 != 0 { - state ^= 0x8003; - } - } - *byte = b; - } -} - -/// Scan stage4/stage5 for the encrypted custom-decryptor bytecode block. The -/// raw byte at p+95 is used by decrypt_data6 as the iteration count. We trial- -/// decrypt that many bytes with the LFSR keystream and accept the first -/// position where the byte stream parses as a valid opcode sequence ending in -/// 195 (ret). -pub(crate) fn find_bytecode_offset(data: &[u8], base: u32, len: u32) -> Option { - let start = base as usize; - let end = (base.saturating_add(len)) as usize; - if end > data.len() { - return None; - } - let mut ks = [0u8; 256]; - lfsr_keystream(&mut ks); - // Scan forward from `start+16` on 16-byte boundaries relative to `start`. - // The bytecode block is positioned a fixed offset into stage4/stage5; the - // lowest parseable candidate is the real one (later ones are coincidental - // parses of trailing filler bytes that happen to map to valid opcodes). - // The enclosing buffer isn't necessarily 16-aligned to its absolute - // address in newer builds, so we anchor the stride to `start`. - let mut p = start + 16; - while p + 96 <= end { - let count = data[p + 95] as usize; - if count >= 8 && p + count <= end { - let mut buf = [0u8; 256]; - let take = count.min(256); - for i in 0..take { - buf[i] = data[p + i] ^ ks[i]; - } - if let Some(nops) = parse_bytecode_check(&buf[..take]) - && nops >= 4 - { - return Some(p as u32); - } - } - p += 16; - } - None -} - -/// Validate bytecode structure without allocating a `Vec` of ops. Returns -/// `Some(non_nop_op_count)` if the byte stream parses successfully as a valid -/// opcode sequence ending in 195 (ret), `None` otherwise. Allows non-trivial -/// bytecode filtering by op count. -pub(crate) fn parse_bytecode_check(buf: &[u8]) -> Option { - let mut i = 0usize; - let mut nops: usize = 0; - while i < buf.len() { - let b = buf[i]; - i += 1; - match b { - 4 | 44 | 52 => { - if i >= buf.len() { - return None; - } - i += 1; - nops += 1; - } - 144 => {} - 192 | 254 => { - if i >= buf.len() { - return None; - } - let mb = buf[i]; - i += 1; - let rm = mb & 7; - let mod_ = (mb >> 6) & 3; - let reg = (mb >> 3) & 7; - if mod_ != 3 || rm != 0 { - return None; - } - if reg > 1 { - return None; - } - if b == 192 { - if i >= buf.len() { - return None; - } - i += 1; - } - nops += 1; - } - 195 => return Some(nops), - _ => return None, - } - } - None -} - -/// Locate stage3's v4_val: the last non-zero dword in the buffer, anchored -/// by the `C3 CC CC CC` (ret + 3 int3) immediately before it. -pub(crate) fn find_v4_offset(data: &[u8], base: u32, len: u32) -> Option { - let start = base as usize; - let end = (base.saturating_add(len)) as usize; - if end > data.len() || end < start + 4 { - return None; - } - // walk backwards looking for the first non-zero byte - let mut i = end; - while i > start && data[i - 1] == 0 { - i -= 1; - } - if i < start + 4 { - return None; - } - // v_val occupies the 4 bytes ending at i (rounded up to dword boundary) - let v_end = i; - let v_start = ((v_end + 3) & !3).saturating_sub(4); - // require that the 4 bytes preceding v_val match `C3 CC CC CC` - if v_start < start + 4 || data[v_start - 4..v_start] != [0xC3, 0xCC, 0xCC, 0xCC] { - return None; - } - Some(v_start as u32) -} - -/// Scan a sub-buffer for an ASCII needle; return its absolute position. -pub(crate) fn find_str_pos(data: &[u8], base: u32, len: u32, needle: &[u8]) -> Option { - let start = base as usize; - let end = (base.saturating_add(len)) as usize; - if end > data.len() || needle.is_empty() { - return None; - } - data[start..end] - .windows(needle.len()) - .position(|w| w == needle) - .map(|rel| (start + rel) as u32) -} - -pub(crate) fn get_string_to_null(data: &[u8], offset: u32) -> String { - let start = offset as usize; - if start >= data.len() { - return String::new(); - } - // Bounded: an unterminated run must never walk off the end of the buffer - // (panic) or scan unboundedly into unrelated data. - let limit = start.saturating_add(4096).min(data.len()); - let mut i = start; - while i < limit && data[i] != 0 { - i += 1; - } - String::from_utf8_lossy(&data[start..i]).into_owned() -} - -/// Read a PE section-name field: exactly 8 bytes, NOT necessarily -/// NUL-terminated (a full-width name like `.textbss` has no NUL at all). -/// Returns the name with trailing NULs stripped. Using `get_string_to_null` -/// here would run past the field into the VirtualSize/VirtualAddress dwords. -pub(crate) fn section_name(data: &[u8], offset: u32) -> String { - let start = offset as usize; - let Some(field) = data.get(start..start + 8) else { - return String::new(); - }; - let end = field.iter().position(|&b| b == 0).unwrap_or(8); - String::from_utf8_lossy(&field[..end]).into_owned() -} - -// --------------------------------------------------------------------------- -// AES primitives -// --------------------------------------------------------------------------- - -/// One AES-CBC-like round over a 16-byte block in `d` at `pos`, using the -/// expanded key schedule stored in `d` at `key_offset`. Works entirely within -/// the single `d` buffer (both ciphertext and key schedule live there). -pub(crate) fn aes_round(d: &mut [u8], pos: u32, key_offset: u32, round: u32) { - let cm1 = &COLUMMIX1; - let cm2 = &COLUMMIX2; - let cm3 = &COLUMMIX3; - let cm4 = &COLUMMIX4; - let sbox = &SBOX; - - let mut n0 = get_u32(d, pos).swap_bytes() ^ get_u32(d, key_offset); - let mut n1 = - get_u32(d, pos.wrapping_add(4)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(4)); - let mut n2 = - get_u32(d, pos.wrapping_add(8)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(8)); - let mut n3 = - get_u32(d, pos.wrapping_add(12)).swap_bytes() ^ get_u32(d, key_offset.wrapping_add(12)); - - let mut r = 1u32; - while r < round { - let off = key_offset.wrapping_add(r.wrapping_mul(16)); - let a = get_u32(cm2, ((n3 >> 16) & 0xFF) * 4) - ^ get_u32(cm3, ((n2 >> 8) & 0xFF) * 4) - ^ get_u32(cm1, ((n0 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n1 & 0xFF) * 4) - ^ get_u32(d, off); - let b = get_u32(cm2, ((n0 >> 16) & 0xFF) * 4) - ^ get_u32(cm1, ((n1 >> 24) & 0xFF) * 4) - ^ get_u32(cm3, ((n3 >> 8) & 0xFF) * 4) - ^ get_u32(cm4, (n2 & 0xFF) * 4) - ^ get_u32(d, off.wrapping_add(4)); - let c = get_u32(cm2, ((n1 >> 16) & 0xFF) * 4) - ^ get_u32(cm3, ((n0 >> 8) & 0xFF) * 4) - ^ get_u32(cm1, ((n2 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n3 & 0xFF) * 4) - ^ get_u32(d, off.wrapping_add(8)); - let e = get_u32(cm3, ((n1 >> 8) & 0xFF) * 4) - ^ get_u32(cm2, ((n2 >> 16) & 0xFF) * 4) - ^ get_u32(cm1, ((n3 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n0 & 0xFF) * 4) - ^ get_u32(d, off.wrapping_add(12)); - n0 = a; - n1 = b; - n2 = c; - n3 = e; - r = r.wrapping_add(1); - } - - let s0 = (get_u32(sbox, ((n0 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n3 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n2 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n1 & 0xFF) * 4) & 0x0000_00FF); - let s1 = (get_u32(sbox, ((n1 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n0 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n3 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n2 & 0xFF) * 4) & 0x0000_00FF); - let s2 = (get_u32(sbox, ((n2 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n1 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n0 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n3 & 0xFF) * 4) & 0x0000_00FF); - let s3 = (get_u32(sbox, ((n3 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n2 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n1 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n0 & 0xFF) * 4) & 0x0000_00FF); - - let last = key_offset.wrapping_add(round.wrapping_mul(16)); - n0 = s0 ^ get_u32(d, last); - n1 = s1 ^ get_u32(d, last.wrapping_add(4)); - n2 = s2 ^ get_u32(d, last.wrapping_add(8)); - n3 = s3 ^ get_u32(d, last.wrapping_add(12)); - - write_u32(d, pos, n0.swap_bytes()); - write_u32(d, pos.wrapping_add(4), n1.swap_bytes()); - write_u32(d, pos.wrapping_add(8), n2.swap_bytes()); - write_u32(d, pos.wrapping_add(12), n3.swap_bytes()); -} - -/// AES-CBC-like decryption over `size` bytes starting at `pos` in `d`. -/// The key schedule lives at `key_offset` within the same buffer `d`. -pub(crate) fn aes_decrypt(d: &mut [u8], pos: u32, size: u32, key_offset: u32) { - let mut prev = [0u8; 16]; - let mut cur = [0u8; 16]; - let round = get_u16(d, key_offset.wrapping_add(2)) as u32; - let blocks = size >> 4; - for i in 0..blocks { - let p = pos.wrapping_add(i.wrapping_mul(16)); - let pi = p as usize; - cur.copy_from_slice(&d[pi..pi + 16]); - aes_round(d, p, key_offset.wrapping_add(4), round); - for j in 0..16 { - d[pi + j] ^= prev[j]; - } - prev = cur; - } -} - -/// [`aes_decrypt`] variant reading the key schedule from a separate snapshot -/// slice instead of the data buffer. `ks` is a snapshot of `d[key_offset..]` -/// taken by [`aes_schedule_snapshot`] (round count at `ks[2]`, round keys from -/// `ks[4]`), so the schedule extent is exactly right by construction. Used by -/// the parallel block fan-out, where each worker owns a disjoint `&mut` span -/// of the image and cannot read the schedule out of the shared buffer. -pub(crate) fn aes_decrypt_ks(ks: &[u8], d: &mut [u8], pos: u32, size: u32) { - let mut prev = [0u8; 16]; - let mut cur = [0u8; 16]; - let round = u16::from_le_bytes([ks[2], ks[3]]) as u32; - let sched = &ks[4..]; - let blocks = size >> 4; - for i in 0..blocks { - let p = pos.wrapping_add(i.wrapping_mul(16)); - let pi = p as usize; - cur.copy_from_slice(&d[pi..pi + 16]); - aes_round_ks(sched, d, p, round); - for j in 0..16 { - d[pi + j] ^= prev[j]; - } - prev = cur; - } -} - -/// [`aes_round`] with the round keys in a separate slice (see -/// [`aes_decrypt_ks`]). Identical math; only the key source differs. -fn aes_round_ks(ks: &[u8], d: &mut [u8], pos: u32, round: u32) { - let cm1 = &COLUMMIX1; - let cm2 = &COLUMMIX2; - let cm3 = &COLUMMIX3; - let cm4 = &COLUMMIX4; - let sbox = &SBOX; - let k = |i: u32| get_u32(ks, i); - - let mut n0 = get_u32(d, pos).swap_bytes() ^ k(0); - let mut n1 = get_u32(d, pos.wrapping_add(4)).swap_bytes() ^ k(4); - let mut n2 = get_u32(d, pos.wrapping_add(8)).swap_bytes() ^ k(8); - let mut n3 = get_u32(d, pos.wrapping_add(12)).swap_bytes() ^ k(12); - - let mut r = 1u32; - while r < round { - let off = r.wrapping_mul(16); - let a = get_u32(cm2, ((n3 >> 16) & 0xFF) * 4) - ^ get_u32(cm3, ((n2 >> 8) & 0xFF) * 4) - ^ get_u32(cm1, ((n0 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n1 & 0xFF) * 4) - ^ k(off); - let b = get_u32(cm2, ((n0 >> 16) & 0xFF) * 4) - ^ get_u32(cm1, ((n1 >> 24) & 0xFF) * 4) - ^ get_u32(cm3, ((n3 >> 8) & 0xFF) * 4) - ^ get_u32(cm4, (n2 & 0xFF) * 4) - ^ k(off.wrapping_add(4)); - let c = get_u32(cm2, ((n1 >> 16) & 0xFF) * 4) - ^ get_u32(cm3, ((n0 >> 8) & 0xFF) * 4) - ^ get_u32(cm1, ((n2 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n3 & 0xFF) * 4) - ^ k(off.wrapping_add(8)); - let e = get_u32(cm3, ((n1 >> 8) & 0xFF) * 4) - ^ get_u32(cm2, ((n2 >> 16) & 0xFF) * 4) - ^ get_u32(cm1, ((n3 >> 24) & 0xFF) * 4) - ^ get_u32(cm4, (n0 & 0xFF) * 4) - ^ k(off.wrapping_add(12)); - n0 = a; - n1 = b; - n2 = c; - n3 = e; - r = r.wrapping_add(1); - } - - let s0 = (get_u32(sbox, ((n0 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n3 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n2 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n1 & 0xFF) * 4) & 0x0000_00FF); - let s1 = (get_u32(sbox, ((n1 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n0 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n3 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n2 & 0xFF) * 4) & 0x0000_00FF); - let s2 = (get_u32(sbox, ((n2 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n1 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n0 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n3 & 0xFF) * 4) & 0x0000_00FF); - let s3 = (get_u32(sbox, ((n3 >> 24) & 0xFF) * 4) & 0xFF00_0000) - | (get_u32(sbox, ((n2 >> 16) & 0xFF) * 4) & 0x00FF_0000) - | (get_u32(sbox, ((n1 >> 8) & 0xFF) * 4) & 0x0000_FF00) - | (get_u32(sbox, (n0 & 0xFF) * 4) & 0x0000_00FF); - - let last = round.wrapping_mul(16); - n0 = s0 ^ k(last); - n1 = s1 ^ k(last.wrapping_add(4)); - n2 = s2 ^ k(last.wrapping_add(8)); - n3 = s3 ^ k(last.wrapping_add(12)); - - write_u32(d, pos, n0.swap_bytes()); - write_u32(d, pos.wrapping_add(4), n1.swap_bytes()); - write_u32(d, pos.wrapping_add(8), n2.swap_bytes()); - write_u32(d, pos.wrapping_add(12), n3.swap_bytes()); -} - -/// Snapshot the AES key schedule at `key_offset` for [`aes_decrypt_ks`]: -/// `d[key_offset .. key_offset + 4 + (round+1)*16]` where `round` is read from -/// the schedule header. Returns `None` when the header is truncated or the -/// round count is implausible (corrupt input — the same bytes would otherwise -/// drive reads past the buffer). -pub(crate) fn aes_schedule_snapshot(d: &[u8], key_offset: u32) -> Option> { - let base = key_offset as usize; - let round = u16::from_le_bytes([*d.get(base + 2)?, *d.get(base + 3)?]) as usize; - if round > 64 { - return None; - } - let end = base.checked_add(4 + (round + 1) * 16)?; - if end > d.len() { - return None; - } - Some(d[base..end].to_vec()) -} - -// --------------------------------------------------------------------------- -// Checksum primitives -// --------------------------------------------------------------------------- - -/// CRC32-based checksum over a (offset, length) descriptor pair embedded in -/// `d` at `pos`. Returns `crc32(d[offset..offset+length]) ^ length`. -pub(crate) fn calculate_checksum(d: &[u8], pos: u32) -> u32 { - let offset = get_u32(d, pos); - let length = get_u32(d, pos.wrapping_add(4)); - crc32::compute(&d[offset as usize..(offset + length) as usize]) ^ length -} - -/// CRC32 chained checksum. The (offset, length) descriptor at `pos` is read -/// from `d`; the bytes themselves are read from the separate `clean` buffer -/// (the original file image). `start` is the initial CRC accumulator. -pub(crate) fn calculate_checksum2(d: &[u8], clean: &[u8], pos: u32, start: u32) -> u32 { - let offset = get_u32(d, pos); - let length = get_u32(d, pos.wrapping_add(4)); - crc32::append(start, &clean[offset as usize..(offset + length) as usize]) -} - -// --------------------------------------------------------------------------- -// Decompression (Huffman/LZ) -// --------------------------------------------------------------------------- - -/// Huffman/LZ decompression operating entirely within a single `d` buffer. -/// Reads `s_size` bytes from `src`, writes `d_size` bytes to `dest`. -/// The Huffman table lives at `key_offset` within `d`. -/// -/// Returns a structured reason when the stream cannot produce exactly -/// `d_size` bytes. -pub(crate) fn decompress_detailed( - d: &mut [u8], - src: u32, - mut dest: u32, - key_offset: u32, - s_size: u32, - d_size: u32, -) -> Result<(), super::DecompressionFailure> { - use super::DecompressionFailure; - - // Bound the scratch allocation: a corrupt descriptor could request a - // multi-gigabyte source size, and an allocation failure aborts the process - // (uncatchable). Real payloads are far below this. - if s_size as u64 > super::MAX_IMAGE_SIZE { - return Err(DecompressionFailure::SourceTooLarge { - size: s_size, - max: super::MAX_IMAGE_SIZE, - }); - } - DECOMPRESS_SCRATCH.with_borrow_mut(|buf| -> Result<(), DecompressionFailure> { - let mut bit_pos: i32 = 0; - let need = (s_size as usize).saturating_add(3); - if buf.len() < need { - buf.resize(need, 0); - } - let mut buf_off: u32 = 0; - let mut src_consumed: i32 = 0; - let mut pending: u32 = 0; - let mut written: u32 = 0; - let src_u = src as usize; - let s_size_u = s_size as usize; - // The bit-reader's final get_u32 may read up to 3 bytes past s_size; those - // must be zero. Reused scratch can hold stale bytes there, so zero them - // before copying the (exactly s_size) source over the head. - buf[s_size_u] = 0; - buf[s_size_u + 1] = 0; - buf[s_size_u + 2] = 0; - buf[..s_size_u].copy_from_slice(&d[src_u..src_u + s_size_u]); - - while (src_consumed as u32) < s_size && written < d_size { - let word = get_u32(&buf[..], buf_off) >> bit_pos; - let tab_addr = key_offset.wrapping_add((word & 0xFF).wrapping_mul(3)); - let mut tab = get_u16(d, tab_addr); - let bits: u8; - if (tab & 0x8000) != 0 { - tab &= 0x7FFF; - bits = d[tab_addr as usize + 2]; - } else { - let mut b2 = d[tab_addr as usize + 2]; - // A Huffman code longer than 32 bits cannot exist; a larger - // length byte comes from a corrupt table, and `1 << b2` would - // panic (debug) or wrap (release) on it. - if b2 >= 32 { - return Err(DecompressionFailure::InvalidCodeLength { bits: b2 }); - } - let mut mask: u32 = 1u32 << b2; - b2 = b2.wrapping_add(1); - let mut idx = (tab & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; - let mut t2 = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); - // A corrupt table can form a non-terminal cycle; cap the walk so it - // fails instead of spinning forever. - let mut depth = 0u32; - while (t2 & 0x8000) == 0 { - depth += 1; - if depth > 64 { - return Err(DecompressionFailure::HuffmanTraversalLimit); - } - mask <<= 1; - b2 = b2.wrapping_add(1); - idx = (t2 & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; - t2 = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); - } - tab = t2 & 0x7FFF; - bits = b2; - } - bit_pos += bits as i32; - let advance = bit_pos / 8; - buf_off = buf_off.wrapping_add(advance as u32); - src_consumed += advance; - bit_pos %= 8; - - let mode = (tab as u32) & 0x300; - let payload = (tab as u32) & 0xFF; - let step: u32; - match mode { - 0 => { - step = 1; - d[dest as usize] = payload as u8; - } - 0x100 => { - step = 0; - if pending >= 256 { - return Err(DecompressionFailure::PendingLengthOverflow { pending }); - } - pending = if pending == 0 { - payload - } else { - (pending << 8) | payload - }; - } - 0x200 => { - if pending == 0 { - pending = 1; - } - step = pending.wrapping_mul(payload); - if step.wrapping_add(written) > d_size { - return Err(DecompressionFailure::OutputOverflow { - written, - step, - expected: d_size, - }); - } - // Run-fill replicates the unit just written before `dest`. A - // corrupt stream can emit one of these before anything has been - // written, so guard against reading before the buffer start - // (an unsigned underflow would index astronomically far OOB). - match payload { - 1 => { - if dest < 1 { - return Err(DecompressionFailure::RunFillBeforeOutput { - width: payload, - destination: dest, - }); - } - let v = d[(dest as usize) - 1]; - for k in 0..pending { - d[(dest + k) as usize] = v; - } - } - 2 => { - if dest < 2 { - return Err(DecompressionFailure::RunFillBeforeOutput { - width: payload, - destination: dest, - }); - } - let v = get_u16(d, dest.wrapping_sub(2)); - for k in 0..pending { - write_u16(d, dest.wrapping_add(k.wrapping_mul(2)), v as u32); - } - } - 4 => { - if dest < 4 { - return Err(DecompressionFailure::RunFillBeforeOutput { - width: payload, - destination: dest, - }); - } - let v = get_u32(d, dest.wrapping_sub(4)); - for k in 0..pending { - write_u32(d, dest.wrapping_add(k.wrapping_mul(4)), v); - } - } - _ => { - // Only unit widths 1/2/4 exist. Any other payload comes - // from a corrupt stream: previously this wrote nothing - // yet still counted `step` bytes as written, leaving - // stale-buffer holes that later stages treated as - // plaintext. Report corruption instead. - return Err(DecompressionFailure::InvalidRunFillWidth { - width: payload, - }); - } - } - pending = 0; - } - _ => { - step = payload; - if written.wrapping_add(payload) > d_size - || pending.wrapping_add(payload) > written - { - let distance = pending.wrapping_add(payload); - if distance > written { - return Err(DecompressionFailure::InvalidBackReference { - distance, - written, - }); - } - return Err(DecompressionFailure::OutputOverflow { - written, - step: payload, - expected: d_size, - }); - } - let back = pending.wrapping_add(payload); - for k in 0..payload { - d[(dest + k) as usize] = d[(dest + k - back) as usize]; - } - pending = 0; - } - } - - dest = dest.wrapping_add(step); - written = written.wrapping_add(step); - if bits == 0 && step == 0 { - // Corrupt table: no input bits consumed and no output bytes - // written, so the loop condition can never advance — an - // infinite loop (and `catch_unpack` traps panics, not hangs). - // Every real symbol consumes ≥ 1 bit, so a valid stream can - // never hit this. - return Err(DecompressionFailure::NoProgress); - } - } - src_consumed += if bit_pos != 0 { 1 } else { 0 }; - if written != d_size { - return Err(DecompressionFailure::OutputSizeMismatch { - written, - expected: d_size, - consumed: src_consumed.max(0) as u32, - source_size: s_size, - }); - } - Ok(()) - }) -} - -/// Boolean compatibility wrapper used by candidate searches and block fan-out. -pub(crate) fn decompress( - d: &mut [u8], - src: u32, - dest: u32, - key_offset: u32, - s_size: u32, - d_size: u32, -) -> bool { - decompress_detailed(d, src, dest, key_offset, s_size, d_size).is_ok() -} - -/// Walk the Huffman table at `key_offset` and snapshot its bytes for -/// [`decompress_tbl`]. The table is a forest of 256 root entries (3 bytes -/// each); non-terminal entries point at a child index pair. Returns `None` -/// when the table is truncated or self-referential past the buffer (corrupt -/// input — the same bytes would otherwise drive reads out of bounds). -pub(crate) fn huffman_table_snapshot(d: &[u8], key_offset: u32) -> Option> { - let mut visited = vec![false; 0x1_0000usize]; - let mut stack: Vec = (0..256).collect(); - let mut max_idx: u32 = 255; - while let Some(idx) = stack.pop() { - if idx >= 0x1_0000 || visited[idx as usize] { - continue; - } - visited[idx as usize] = true; - let off = key_offset as usize + idx as usize * 3; - if off + 3 > d.len() { - return None; - } - let t = get_u16(d, key_offset.wrapping_add(idx.wrapping_mul(3))); - if (t & 0x8000) == 0 { - let child = (t & 0x7FFF) as u32; - max_idx = max_idx.max(child).max(child.wrapping_add(1)); - stack.push(child); - stack.push(child.wrapping_add(1)); - } - } - let end = key_offset as usize + (max_idx as usize + 1) * 3; - if end > d.len() { - return None; - } - Some(d[key_offset as usize..end].to_vec()) -} - -/// [`decompress`] variant reading the Huffman table from a separate snapshot -/// slice (see [`huffman_table_snapshot`]) instead of the data buffer. Used by -/// the parallel block fan-out, where each worker owns a disjoint `&mut` span -/// and cannot read the table out of the shared image. Table reads are bounds -/// checked against the snapshot — past-the-end means corrupt table, reported -/// as `false` rather than a panic. -pub(crate) fn decompress_tbl( - tab: &[u8], - d: &mut [u8], - src: u32, - mut dest: u32, - s_size: u32, - d_size: u32, -) -> bool { - if s_size as u64 > super::MAX_IMAGE_SIZE { - return false; - } - DECOMPRESS_SCRATCH.with_borrow_mut(|buf| { - // Table reads, bounds-checked against the snapshot. - let tab16 = |addr: usize| -> Option { - let b = tab.get(addr..addr + 3)?; - Some(u16::from_le_bytes([b[0], b[1]])) - }; - let tab8 = |addr: usize| -> Option { tab.get(addr + 2).copied() }; - - let mut bit_pos: i32 = 0; - let need = (s_size as usize).saturating_add(3); - if buf.len() < need { - buf.resize(need, 0); - } - let mut buf_off: u32 = 0; - let mut src_consumed: i32 = 0; - let mut pending: u32 = 0; - let mut written: u32 = 0; - let src_u = src as usize; - let s_size_u = s_size as usize; - buf[s_size_u] = 0; - buf[s_size_u + 1] = 0; - buf[s_size_u + 2] = 0; - buf[..s_size_u].copy_from_slice(&d[src_u..src_u + s_size_u]); - - while (src_consumed as u32) < s_size && written < d_size { - let word = get_u32(&buf[..], buf_off) >> bit_pos; - let tab_addr = ((word & 0xFF).wrapping_mul(3)) as usize; - let mut tab = match tab16(tab_addr) { - Some(t) => t, - None => { - return false; - } - }; - let bits: u8; - if (tab & 0x8000) != 0 { - tab &= 0x7FFF; - bits = match tab8(tab_addr) { - Some(b) => b, - None => return false, - }; - } else { - let mut b2 = match tab8(tab_addr) { - Some(b) => b, - None => return false, - }; - if b2 >= 32 { - return false; - } - let mut mask: u32 = 1u32 << b2; - b2 = b2.wrapping_add(1); - let mut idx = (tab & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; - let mut t2 = match tab16(idx as usize * 3) { - Some(t) => t, - None => { - return false; - } - }; - // A corrupt table can form a non-terminal cycle; cap the walk so it - // fails instead of spinning forever. - let mut depth = 0u32; - while (t2 & 0x8000) == 0 { - depth += 1; - if depth > 64 { - return false; - } - mask <<= 1; - b2 = b2.wrapping_add(1); - idx = (t2 & 0x7FFF) as u32 + if (word & mask) != 0 { 1 } else { 0 }; - t2 = match tab16(idx as usize * 3) { - Some(t) => t, - None => { - return false; - } - }; - } - tab = t2 & 0x7FFF; - bits = b2; - } - bit_pos += bits as i32; - let advance = bit_pos / 8; - buf_off = buf_off.wrapping_add(advance as u32); - src_consumed += advance; - bit_pos %= 8; - - let mode = (tab as u32) & 0x300; - let payload = (tab as u32) & 0xFF; - let step: u32; - match mode { - 0 => { - step = 1; - d[dest as usize] = payload as u8; - } - 0x100 => { - step = 0; - if pending >= 256 { - return false; - } - pending = if pending == 0 { - payload - } else { - (pending << 8) | payload - }; - } - 0x200 => { - if pending == 0 { - pending = 1; - } - step = pending.wrapping_mul(payload); - if step.wrapping_add(written) > d_size { - return false; - } - // Run-fill replicates the unit just written before `dest` - // (see `decompress` for the underflow rationale). - match payload { - 1 => { - if dest < 1 { - return false; - } - let v = d[(dest as usize) - 1]; - for k in 0..pending { - d[(dest + k) as usize] = v; - } - } - 2 => { - if dest < 2 { - return false; - } - let v = get_u16(d, dest.wrapping_sub(2)); - for k in 0..pending { - write_u16(d, dest.wrapping_add(k.wrapping_mul(2)), v as u32); - } - } - 4 => { - if dest < 4 { - return false; - } - let v = get_u32(d, dest.wrapping_sub(4)); - for k in 0..pending { - write_u32(d, dest.wrapping_add(k.wrapping_mul(4)), v); - } - } - _ => { - return false; - } - } - pending = 0; - } - _ => { - step = payload; - if written.wrapping_add(payload) > d_size - || pending.wrapping_add(payload) > written - { - return false; - } - let back = pending.wrapping_add(payload); - for k in 0..payload { - d[(dest + k) as usize] = d[(dest + k - back) as usize]; - } - pending = 0; - } - } - - dest = dest.wrapping_add(step); - written = written.wrapping_add(step); - if bits == 0 && step == 0 { - return false; - } - } - src_consumed += if bit_pos != 0 { 1 } else { 0 }; - let _ = src_consumed; - written == d_size - }) -} -// Decrypt primitives (free-function wrappers) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// PE32 (32-bit) helpers -// --------------------------------------------------------------------------- - -/// PE32 shell-table locator. Walks the shell region (`info[6]`) for a dword -/// equal to `info[6]` followed by a plausible shell size, returning the table -/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer. -pub(crate) fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option { - let shell = info[6]; - if (data.len() as u64) < 0x100 { - return None; - } - let hi = (shell as u64) - .saturating_add(0x3000) - .min(data.len() as u64 - 0x100) as u32; - let mut off = shell; - while off < hi { - if off as usize + 8 <= data.len() { - let candidate = off.wrapping_sub(0x88); - if candidate >= shell && get_u32(data, off) == info[6] { - let shell_size_val = get_u32(data, off.wrapping_add(4)); - if shell_size_val > 0x1000 && shell_size_val < 0x100000 { - let v58_off = candidate.wrapping_add(0x58); - if (v58_off as usize + 4) <= data.len() { - let v58 = get_u32(data, v58_off); - if v58 > 0 && (v58 as usize) < data.len() { - return Some(candidate); - } - } - } - } - } - off = off.wrapping_add(4); - } - None -} - -/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region. -/// `start_off` is the byte offset to begin scanning at, `scan_backward` -/// controls direction. Returns the relative offset of the block. Includes full -/// opcode-walk validation of candidate blocks. -pub(crate) fn find_lfsr_block( - data: &[u8], - base: u32, - size: u32, - start_off: u32, - scan_backward: bool, -) -> Option { - if size < 96 { - return None; - } - let mut ks = [0u8; 128]; - lfsr_keystream(&mut ks); - let check = |scan_off: u32| -> bool { - let abs_off = base.wrapping_add(scan_off) as usize; - if abs_off + 96 > data.len() { - return false; - } - let sz = data[abs_off + 95] as usize; - if !(10..=95).contains(&sz) { - return false; - } - let mut decoded = [0u8; 95]; - for bi in 0..sz { - decoded[bi] = data[abs_off + bi] ^ ks[bi]; - } - // Full bytecode validation (shared with the stage4/5 locator): every - // opcode must decode with a valid ModR/M and the stream must REACH a - // RET (0xC3) as an opcode. The previous check only required a 0xC3 - // byte *anywhere* in the window and accepted a walk that ran off the - // end without hitting RET — a `0x04 0xC3` (ADD 0xC3) tail passed, so - // coincidental LFSR-shaped garbage was accepted as a decryptor block. - parse_bytecode_check(&decoded[..sz]).is_some() - }; - if scan_backward { - let hi = size - 96; - if hi >= start_off { - let mut scan_off = hi; - loop { - if check(scan_off) { - return Some(scan_off); - } - if scan_off == start_off { - break; - } - scan_off -= 1; - } - } - } else { - let hi = size - 95; - let mut scan_off = start_off; - while scan_off < hi { - if check(scan_off) { - return Some(scan_off); - } - scan_off += 1; - } - } - None -} - -/// Slots discovered in the eighthStage for the marker-less layout. -pub(crate) struct EighthSlots { - /// Absolute address of the file-data decryptor LFSR bytecode block. The - /// fileCS chain pointer is derived downstream as `file_lfsr - 0x58`. - pub file_lfsr: u32, - /// Absolute address of the compressedInfo (ptr,size) table pointer slot. - pub compressed_info_ptr: u32, -} - -/// Marker-independent eighthStage slot discovery (PE32+ branch). -/// -/// Newer Crackproof builds (e.g. some native/managed DLLs) omit the -/// `pm\0\0cm\0\0` and `00 00 00 40 01 00 00 00` markers that the older layout's -/// walk3/walk4/walk5 slot derivation relies on. Instead this discovers the -/// slots structurally: -/// * Scan the eighthStage for every LFSR (decrypt_data6) bytecode block. -/// * The file decryptor is the LFSR block whose `fileCS = lfsr - 0x58` holds -/// a pointer sitting just past `info[3]` (smallest positive distance). -/// * `compressedInfo` is the pointer slot whose 16-byte target, after a -/// trial `decrypt_data5`, parses as a plausible (src,sSize,dst,dSize) -/// descriptor. -/// -/// Returns `None` if no plausible file LFSR is found. `eighth_start`/`eighth_dsz` -/// bound the search region; `info3` is `info[3]`; `compress_data_offset` is -/// `(!u32(file_data,0x1080)) + 0x1000`; `file_data_len` is the protected file -/// length. -#[allow(clippy::too_many_arguments)] -pub(crate) fn discover_eighth_slots( - data: &[u8], - eighth_start: u32, - eighth_dsz: u32, - info3: u32, - compress_data_offset: u32, - file_data_len: u32, -) -> Option { - // Collect all LFSR candidates (forward scan). - // - // Advance by 1 after each hit, NOT by 96. A false-positive LFSR match can sit - // just before the real file-decryptor block (observed on an il2cpp game - // assembly build, 2026-07-13: junk at rel=0x31C1, real block at 0x3210). - // Stepping by the LFSR body size then skips the real block and discovery - // fails. Byte-stepping is cheap: eighthStage is only a few KB. - let mut all_lfsrs: Vec = Vec::new(); - let mut scan_off: u32 = 0; - while scan_off + 95 < eighth_dsz { - match find_lfsr_block(data, eighth_start, eighth_dsz, scan_off, false) { - Some(found) => { - all_lfsrs.push(found); - scan_off = found + 1; - } - None => break, - } - } - - // Pick the file LFSR: prefer the candidate whose fileCS pointer sits the - // smallest positive distance past info[3]. - let mut off_file_lfsr: Option = None; - let mut best_dist: Option = None; - for &lfsr_off in &all_lfsrs { - if lfsr_off < 0x58 { - continue; - } - let cs_off = lfsr_off - 0x58; - let cs_val = get_u32(data, eighth_start.wrapping_add(cs_off)); - if !(0x1000 < cs_val && (cs_val as usize) < data.len()) { - continue; - } - if cs_val < info3 { - continue; - } - let dist = cs_val - info3; - if best_dist.is_none_or(|b| dist < b) { - best_dist = Some(dist); - off_file_lfsr = Some(lfsr_off); - } - } - // Fallback: last LFSR with any in-image fileCS pointer. - if off_file_lfsr.is_none() { - for &lfsr_off in all_lfsrs.iter().rev() { - if lfsr_off < 0x58 { - continue; - } - let cs_val = get_u32(data, eighth_start.wrapping_add(lfsr_off - 0x58)); - if 0x1000 < cs_val && (cs_val as usize) < data.len() { - off_file_lfsr = Some(lfsr_off); - break; - } - } - } - let off_file_lfsr = off_file_lfsr?; - let off_file_cs = off_file_lfsr - 0x58; - - // Trial-decrypt to find compressedInfo: the pointer slot in the data area - // (between fileCS region start and the LFSR) whose target parses as a valid - // (src,sSize,dst,dSize) descriptor after a transient decrypt_data5. - let scan_from = off_file_lfsr.saturating_sub(0x400); - let mut off_compressed_info: Option = None; - let mut doff = scan_from; - while doff < off_file_lfsr { - if doff == off_file_cs { - doff += 4; - continue; - } - let ptr_val = get_u32(data, eighth_start.wrapping_add(doff)); - if !(0x1000 < ptr_val && (ptr_val as usize) < data.len().saturating_sub(16)) { - doff += 4; - continue; - } - // Predict decrypt_data5(ptr_val, 16) without mutating: each dword is - // position-keyed and independent, so trial_decrypt5_u32 per dword. - let src2 = trial_decrypt5_u32(data, ptr_val); - let s_sz2 = trial_decrypt5_u32(data, ptr_val + 4); - let dst2 = trial_decrypt5_u32(data, ptr_val + 8); - let d_sz2 = trial_decrypt5_u32(data, ptr_val + 12); - let src_file_off = src2.wrapping_add(compress_data_offset); - let valid = s_sz2 > 0 - && s_sz2 < 0x200000 - && (src_file_off as u64 + s_sz2 as u64) <= file_data_len as u64 - && dst2 >= 0x1000 - && (dst2 as u64 + d_sz2 as u64) <= data.len() as u64 - && d_sz2 >= s_sz2 - && d_sz2 < 0x200000; - if valid { - off_compressed_info = Some(doff); - break; - } - doff += 4; - } - let off_compressed_info = off_compressed_info?; - - Some(EighthSlots { - file_lfsr: eighth_start.wrapping_add(off_file_lfsr), - compressed_info_ptr: eighth_start.wrapping_add(off_compressed_info), - }) -} - -/// PE32 `.text` dd8 key-formula selection with a skip decision. The packer keys -/// the per-page XOR either with `page+1` or `0x8000*(page+1)`; the formula is -/// not recorded. Replays the dd8 page pass on a scratch copy of sample pages -/// (25/50/75% of `.text`) under each formula and counts how many positions -/// decode to `0xCC` (int3 padding). -/// -/// Returns `Some(true)` for the `0x8000*(page+1)` formula, `Some(false)` for -/// `page+1`, or `None` when `.text` must NOT be dd8-decrypted at all. The packer -/// dd8-encrypts `.text` on EXEs (so unpacking must replay it) but leaves a native -/// DLL's `.text` plaintext; replaying dd8 there scrambles ~1 byte per 16-byte -/// block. The decision: dd8 only *restores* int3 padding when `.text` was -/// genuinely encrypted, so apply it only when the chosen formula's whole-page -/// 0xCC count rises *clearly* above the no-dd8 baseline; otherwise skip. -/// -/// "Clearly" matters: dd8 XORs 255 positions per page with pseudo-random bytes, -/// so on an already-plaintext `.text` it manufactures ~1 spurious `0xCC` per -/// sampled page for free (255/256 expected). A bare `best > baseline` test is -/// therefore biased towards *applying* dd8 on exactly the inputs that must skip -/// it — and a wrongly-applied dd8 is silent: it scrambles ~1 byte per 16 with no -/// error and nothing downstream (not even `integrity::check`, which only reads -/// 16 bytes at the entry point) notices. The [`MIN_DD8_NET_GAIN`] floor below is -/// the PE32 counterpart of the margin+floor `select_dd8_shift` already applies -/// on PE32+ for the same failure mode. -pub(crate) fn select_dd8_formula_pe32(data: &[u8], text_off: u32, text_size: u32) -> Option { - let num_pages_total = text_size / 0x1000; - let mut sample_pages: Vec = Vec::new(); - for frac in [0.25f64, 0.5, 0.75] { - let pg = (num_pages_total as f64 * frac) as u32; - if pg > 0 && pg < num_pages_total { - sample_pages.push(pg); - } - } - if sample_pages.is_empty() && num_pages_total > 1 { - sample_pages.push(num_pages_total / 2); - } - let score = |big: bool| -> i64 { - let mut total = 0i64; - for &sp in &sample_pages { - let pg_off = (text_off + sp * 0x1000) as usize; - if pg_off + 0x1000 > data.len() { - continue; - } - let mut buf = [0u8; 0x1000]; - buf.copy_from_slice(&data[pg_off..pg_off + 0x1000]); - let pk = if big { - 0x8000u32.wrapping_mul(sp.wrapping_add(1)) - } else { - sp.wrapping_add(1) - }; - let mut k = pk; - let rk = k.rotate_right(15); - k = rk; - for bi in 1..256u32 { - let rk = k.rotate_right(15); - let ri = rk.wrapping_add(bi); - k = ri.wrapping_add(bi); - let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize; - if tidx < buf.len() { - buf[tidx] ^= k as u8; - } - } - total += buf.iter().filter(|&&b| b == 0xCC).count() as i64; - } - total - }; - let s_small = score(false); - let s_big = score(true); - // Baseline: whole-page 0xCC over the same sample pages with NO dd8. dd8 only - // rewrites 255 bytes per page, so comparing the chosen formula's whole-page - // 0xCC against this baseline reveals whether dd8 *restores* int3 padding - // (count rises -> .text was packer-encrypted, apply) or merely scrambles - // already-plaintext code (count falls -> native-DLL .text left intact, skip). - let mut baseline: i64 = 0; - for &sp in &sample_pages { - let pg_off = (text_off + sp * 0x1000) as usize; - if pg_off + 0x1000 > data.len() { - continue; - } - baseline += data[pg_off..pg_off + 0x1000] - .iter() - .filter(|&&b| b == 0xCC) - .count() as i64; - } - let big = s_big > s_small; - let best = s_small.max(s_big); - // Minimum net 0xCC gain over the baseline before dd8 is applied. Noise on an - // already-plaintext `.text` is ~1 manufactured 0xCC per sampled page (3 pages - // -> ~3); every corpus build that genuinely needs dd8 gains +154 or more - // (observed +154 and +312), and the one native DLL that must skip scores -18. - // A floor of 32 sits ~10x above the noise and ~5x below the smallest true - // positive, so it changes no existing decision. - const MIN_DD8_NET_GAIN: i64 = 32; - let apply = best.saturating_sub(baseline) >= MIN_DD8_NET_GAIN; - if std::env::var("SEL_DIAG").is_ok() { - eprintln!( - "SEL pe32 dd8 s_small={} s_big={} baseline={} gain={} big={} apply={}", - s_small, - s_big, - baseline, - best - baseline, - big, - apply - ); - } - // When no interior pages could be sampled (tiny .text) we cannot measure the - // effect; preserve the historical behavior of applying dd8. - if sample_pages.is_empty() || apply { - Some(big) - } else { - None - } -} - -/// Read a NUL-terminated byte string starting at `off`, bounded to 512 bytes. -/// Returns the raw bytes up to the terminator (excluding it). -fn read_cstr_bounded(data: &[u8], off: u32) -> Vec { - let start = off as usize; - if start >= data.len() { - return Vec::new(); - } - let limit = (start + 512).min(data.len()); - let mut end = start; - while end < limit && data[end] != 0 { - end += 1; - } - data[start..end].to_vec() -} - -fn align_up_u32(value: u32, alignment: u32) -> u32 { - ((value.wrapping_add(alignment - 1)) / alignment).wrapping_mul(alignment) -} - -fn align_up_u64(value: u64, alignment: u64) -> u64 { - value.div_ceil(alignment) * alignment -} - -#[derive(Clone)] -enum ImportFunc { - Ordinal(u32), - Name(u16, Vec), -} - -struct ImportDesc { - time_date: u32, - fwd_chain: u32, - dll_name: Vec, - iat_rva: u32, - functions: Vec, -} - -/// Return true when PE32 imports already sit in the original `.idata` layout -/// (so no relocation to `.kmiat` is needed). May write the IAT data directory -/// (pe+0xD8). -pub(crate) fn pe32_imports_already_match_idata_layout(data: &mut [u8], pe_header: u32) -> bool { - let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; - let sec_table = pe_header.wrapping_add(24).wrapping_add(opt_hdr_size); - let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; - let import_rva = get_u32(data, pe_header.wrapping_add(0x80)); - let import_size = get_u32(data, pe_header.wrapping_add(0x84)); - let len = data.len() as u32; - if !(import_rva > 0 && import_size > 0) { - return false; - } - for idx in 0..num_sections { - let sec_off = sec_table.wrapping_add(idx * 40); - if (sec_off as usize + 40) > data.len() { - return false; - } - if &data[sec_off as usize..sec_off as usize + 6] != b".idata" { - continue; - } - let sec_va = get_u32(data, sec_off.wrapping_add(12)); - let sec_size = - get_u32(data, sec_off.wrapping_add(8)).max(get_u32(data, sec_off.wrapping_add(16))); - let sec_end = sec_va.wrapping_add(sec_size); - if !(sec_va <= import_rva - && import_rva < sec_end - && import_rva.wrapping_add(import_size) <= sec_end) - { - continue; - } - let first_oft = get_u32(data, import_rva); - let first_name = get_u32(data, import_rva.wrapping_add(12)); - let first_iat = get_u32(data, import_rva.wrapping_add(16)); - if !(sec_va <= first_oft - && first_oft < sec_end - && sec_va <= first_iat - && first_iat < sec_end) - { - return false; - } - if !(0x1000 < first_name && first_name < len) { - return false; - } - let dll_name = read_cstr_bounded(data, first_name); - let lower: Vec = dll_name.iter().map(|b| b.to_ascii_lowercase()).collect(); - if !lower.ends_with(b".dll") { - return false; - } - let mut iat_min = first_iat; - let mut iat_max = first_iat; - let mut idt_pos = import_rva; - while idt_pos.wrapping_add(20) <= len { - let oft_rva = get_u32(data, idt_pos); - let name_rva = get_u32(data, idt_pos.wrapping_add(12)); - let iat_rva = get_u32(data, idt_pos.wrapping_add(16)); - if oft_rva == 0 && name_rva == 0 && iat_rva == 0 { - break; - } - if !(sec_va <= oft_rva && oft_rva < sec_end && sec_va <= iat_rva && iat_rva < sec_end) { - return false; - } - let mut thunk = iat_rva; - while thunk.wrapping_add(4) <= sec_end { - let tv = get_u32(data, thunk); - thunk = thunk.wrapping_add(4); - if tv == 0 { - break; - } - } - iat_min = iat_min.min(iat_rva); - iat_max = iat_max.max(thunk); - idt_pos = idt_pos.wrapping_add(20); - } - if iat_max > iat_min { - write_u32(data, pe_header.wrapping_add(0xD8), iat_min); - write_u32(data, pe_header.wrapping_add(0xDC), iat_max - iat_min); - } - return true; - } - false -} - -/// Rebuild PE32 import metadata (descriptors, lookup tables, names) into the -/// last section as `.kmiat`, leaving the loader-written IAT in place. Mutates -/// `data` (may grow it). -pub(crate) fn move_pe32_imports_to_kmiat(data: &mut Vec, pe_header: u32) { - const SECTION_SIZE: u32 = 0x7000; - let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; - let opt_hdr = pe_header.wrapping_add(24); - let sec_table = opt_hdr.wrapping_add(opt_hdr_size); - let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; - if num_sections == 0 { - return; - } - let import_rva = get_u32(data, pe_header.wrapping_add(0x80)); - let import_size = get_u32(data, pe_header.wrapping_add(0x84)); - let len = data.len() as u32; - if !(0x1000 < import_rva && import_rva < len && import_size > 0 && import_size < SECTION_SIZE) { - return; - } - - let mut descriptors: Vec = Vec::new(); - let mut idt_pos = import_rva; - while idt_pos.wrapping_add(20) <= len { - let oft_rva = get_u32(data, idt_pos); - let time_date = get_u32(data, idt_pos.wrapping_add(4)); - let fwd_chain = get_u32(data, idt_pos.wrapping_add(8)); - let name_rva = get_u32(data, idt_pos.wrapping_add(12)); - let iat_rva = get_u32(data, idt_pos.wrapping_add(16)); - if oft_rva == 0 && name_rva == 0 && iat_rva == 0 { - break; - } - if !(0x1000 < name_rva && name_rva < len) { - break; - } - let dll_name = read_cstr_bounded(data, name_rva); - let thunk_rva = if 0x1000 < oft_rva && oft_rva < len { - oft_rva - } else { - iat_rva - }; - let mut functions: Vec = Vec::new(); - let mut thunk_pos = thunk_rva; - while 0x1000 < thunk_pos.wrapping_add(4) && thunk_pos.wrapping_add(4) <= len { - let thunk_val = get_u32(data, thunk_pos); - if thunk_val == 0 { - break; - } - if thunk_val & 0x8000_0000 != 0 { - functions.push(ImportFunc::Ordinal(thunk_val & 0xFFFF)); - } else { - let hint = if thunk_val.wrapping_add(2) <= len { - get_u16(data, thunk_val) - } else { - 0 - }; - let func_name = if thunk_val.wrapping_add(2) < len { - read_cstr_bounded(data, thunk_val.wrapping_add(2)) - } else { - Vec::new() - }; - functions.push(ImportFunc::Name(hint, func_name)); - } - thunk_pos = thunk_pos.wrapping_add(4); - } - descriptors.push(ImportDesc { - time_date, - fwd_chain, - dll_name, - iat_rva, - functions, - }); - idt_pos = idt_pos.wrapping_add(20); - } - if descriptors.is_empty() { - return; - } - - for desc in &mut descriptors { - let lower: Vec = desc - .dll_name - .iter() - .map(|b| b.to_ascii_lowercase()) - .collect(); - if lower.starts_with(b"api-ms-win-crt-") { - desc.dll_name = b"ucrtbase.dll".to_vec(); - } else { - desc.dll_name = lower; - } - } - descriptors.sort_by_key(|d| d.iat_rva); - - let last_sec = sec_table.wrapping_add((num_sections - 1) * 40); - let kmiat_rva = get_u32(data, last_sec.wrapping_add(12)); - // A zero last-section VA means a corrupt section table: building .kmiat at - // RVA 0 would zero the DOS/PE headers and emit a structurally broken image - // with no error. Bail and keep the original import table. - if kmiat_rva == 0 { - return; - } - // Grow the image when .kmiat overruns it, but cap the growth: a corrupt VA - // could otherwise request a multi-gigabyte allocation, which aborts the - // process (uncatchable). Use u64 math so a near-u32::MAX VA cannot wrap the - // end calculation the way the previous wrapping/plain-add mix could. - let kmiat_end = kmiat_rva as u64 + SECTION_SIZE as u64; - if kmiat_end > super::MAX_IMAGE_SIZE { - return; - } - if kmiat_end > data.len() as u64 { - data.resize(kmiat_end as usize, 0); - } - // Zero the .kmiat region. - for b in &mut data[kmiat_rva as usize..kmiat_end as usize] { - *b = 0; - } - - let idt_size = (descriptors.len() as u32 + 1) * 20; - let oft_start = kmiat_rva; - let mut idt_rva = oft_start; - for desc in &descriptors { - idt_rva = idt_rva.wrapping_add((desc.functions.len() as u32 + 1) * 4); - } - idt_rva = align_up_u32(idt_rva.wrapping_add(0x2C), 4); - - // Size check: compute the final name_pos and bail if it overruns .kmiat. - let mut name_pos_check = idt_rva.wrapping_add(idt_size); - for desc in &descriptors { - name_pos_check = name_pos_check.wrapping_add(desc.dll_name.len() as u32 + 1); - for func in &desc.functions { - if let ImportFunc::Name(_, fname) = func { - name_pos_check = name_pos_check.wrapping_add(2 + fname.len() as u32 + 1); - } - } - } - if name_pos_check > kmiat_rva.wrapping_add(SECTION_SIZE) { - // Section too small; keep existing import table untouched. - return; - } - - let mut oft_pos = oft_start; - let mut name_pos = idt_rva.wrapping_add(idt_size); - for (idx, desc) in descriptors.iter().enumerate() { - let idt_entry = idt_rva.wrapping_add(idx as u32 * 20); - let current_oft = oft_pos; - write_u32(data, idt_entry, current_oft); - write_u32(data, idt_entry.wrapping_add(4), desc.time_date); - write_u32(data, idt_entry.wrapping_add(8), desc.fwd_chain); - let dll_name_pos = name_pos; - write_u32(data, idt_entry.wrapping_add(12), dll_name_pos); - write_u32(data, idt_entry.wrapping_add(16), desc.iat_rva); - - let dnp = dll_name_pos as usize; - data[dnp..dnp + desc.dll_name.len()].copy_from_slice(&desc.dll_name); - data[dnp + desc.dll_name.len()] = 0; - name_pos = name_pos.wrapping_add(desc.dll_name.len() as u32 + 1); - - for func in &desc.functions { - match func { - ImportFunc::Ordinal(ord) => { - write_u32(data, oft_pos, 0x8000_0000 | ord); - } - ImportFunc::Name(hint, fname) => { - let hint_name_rva = name_pos; - write_u32(data, oft_pos, hint_name_rva); - write_u16(data, hint_name_rva, *hint as u32); - let fp = (hint_name_rva + 2) as usize; - data[fp..fp + fname.len()].copy_from_slice(fname); - data[fp + fname.len()] = 0; - name_pos = name_pos.wrapping_add(2 + fname.len() as u32 + 1); - } - } - oft_pos = oft_pos.wrapping_add(4); - } - write_u32(data, oft_pos, 0); - oft_pos = oft_pos.wrapping_add(4); - } - // Null-terminator IDT entry (20 zero bytes) after the last descriptor. - let term = idt_rva.wrapping_add(descriptors.len() as u32 * 20) as usize; - for b in &mut data[term..term + 20] { - *b = 0; - } - - let ls = last_sec as usize; - data[ls..ls + 8].copy_from_slice(b".kmiat\x00\x00"); - write_u32(data, last_sec.wrapping_add(8), SECTION_SIZE); - write_u32(data, last_sec.wrapping_add(16), SECTION_SIZE); - write_u32(data, last_sec.wrapping_add(36), 0xE000_0060); - write_u32(data, pe_header.wrapping_add(0x80), idt_rva); - write_u32(data, pe_header.wrapping_add(0x84), idt_size); - write_u32( - data, - pe_header.wrapping_add(80), - kmiat_rva.wrapping_add(SECTION_SIZE), - ); -} - -/// Convert the unpacked RVA-addressed image back to a compact PE file layout -/// (headers at 0x400, sections packed consecutively, FileAlignment 0x200). -/// Returns `None` if the accumulated output size wraps or exceeds -/// [`super::MAX_IMAGE_SIZE`]: the final allocation is sized from header-derived -/// section data, and an uncapped `vec![0; n]` from a corrupt header would abort -/// the process (which `catch_unpack` cannot trap). -pub(crate) fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option> { - const FILE_ALIGNMENT: u32 = 0x200; - const HEADER_SIZE: u32 = 0x400; - let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32; - let opt_hdr = pe_header.wrapping_add(24); - let sec_table = opt_hdr.wrapping_add(opt_hdr_size); - let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; - - struct SecLayout { - sec_off: u32, - va: u32, - vsize: u32, - raw_ptr: u32, - raw_size: u32, - } - - let mut raw_cursor: u64 = HEADER_SIZE as u64; - let mut raw_layout: Vec = Vec::new(); - for idx in 0..num_sections { - let sec_off = sec_table.wrapping_add(idx * 40); - let vsize = get_u32(data, sec_off.wrapping_add(8)); - let va = get_u32(data, sec_off.wrapping_add(12)); - let sd_start = va as usize; - let sd_end = if (va.wrapping_add(vsize) as usize) <= data.len() { - va.wrapping_add(vsize) as usize - } else { - data.len() - }; - let section_data: &[u8] = if sd_start <= sd_end && sd_start <= data.len() { - &data[sd_start..sd_end] - } else { - &[] - }; - - let mut last_nonzero: i64 = -1; - for pos in (0..section_data.len()).rev() { - if section_data[pos] != 0 { - last_nonzero = pos as i64; - break; - } - } - let meaningful = if last_nonzero >= 0 { - (last_nonzero + 1) as u32 - } else { - 0 - }; - let mut raw_size = if meaningful != 0 { - align_up_u32(meaningful, FILE_ALIGNMENT) - } else { - 0 - }; - if vsize != 0 && raw_size == 0 { - raw_size = FILE_ALIGNMENT; - } - raw_size = raw_size.min(align_up_u32(section_data.len() as u32, FILE_ALIGNMENT)); - - let raw_ptr = if raw_size != 0 { raw_cursor as u32 } else { 0 }; - raw_layout.push(SecLayout { - sec_off, - va, - vsize, - raw_ptr, - raw_size, - }); - if raw_size != 0 { - // Accumulate in u64 and cap: section sizes are header-derived, and - // a corrupt table could otherwise wrap raw_cursor (small alloc, - // huge recorded raw_ptrs → OOB panic) or request an abort-sized - // allocation. - raw_cursor = align_up_u64(raw_cursor + raw_size as u64, FILE_ALIGNMENT as u64); - if raw_cursor > super::MAX_IMAGE_SIZE { - return None; - } - } - } - - let mut compact = vec![0u8; raw_cursor as usize]; - let hdr_copy = (HEADER_SIZE as usize).min(data.len()); - compact[..hdr_copy].copy_from_slice(&data[..hdr_copy]); - write_u32(&mut compact, opt_hdr.wrapping_add(36), FILE_ALIGNMENT); - write_u32(&mut compact, opt_hdr.wrapping_add(60), HEADER_SIZE); - - for sl in &raw_layout { - write_u32(&mut compact, sl.sec_off.wrapping_add(16), sl.raw_size); - write_u32(&mut compact, sl.sec_off.wrapping_add(20), sl.raw_ptr); - if sl.raw_size != 0 { - let sd_start = sl.va as usize; - let sd_end = if (sl.va.wrapping_add(sl.vsize) as usize) <= data.len() { - sl.va.wrapping_add(sl.vsize) as usize - } else { - data.len() - }; - let section_data: &[u8] = if sd_start <= sd_end { - &data[sd_start..sd_end] - } else { - &[] - }; - let copy_size = (sl.raw_size as usize).min(section_data.len()); - let rp = sl.raw_ptr as usize; - compact[rp..rp + copy_size].copy_from_slice(§ion_data[..copy_size]); - } - } - Some(compact) -} - -/// decrypt_data3: XOR+rotate cipher. Reads/writes dwords in `d` starting at -/// the address stored at `d[pos]`, for `d[pos+4]>>2` words. `shift` is the -/// right-rotate amount (19 or 21 depending on caller). -pub(crate) fn decrypt_data3(d: &mut [u8], pos: u32, mut key: u32, shift: u32) { - let base_addr = get_u32(d, pos); - let length = get_u32(d, pos.wrapping_add(4)); - let words = length >> 2; - for i in 0..words { - let off = base_addr.wrapping_add(i.wrapping_mul(4)); - let v = get_u32(d, off) ^ key; - key = key.wrapping_add(i); - let rotated = v.rotate_right(shift); - write_u32(d, off, rotated.wrapping_sub(i)); - } -} - -/// decrypt_data1 (called `decrypt_data` in the original): decode the 8-dword -/// info header from `file_data` at offset 4096 and write results into `info`. -pub(crate) fn decrypt_data1(file_data: &[u8], info: &mut [u32; 8]) { - info[0] = get_u32(file_data, 4096); - let mut k = get_u32(file_data, 4096); - for i in 0..7u32 { - let off = i.wrapping_mul(4).wrapping_add(4); - let cell = get_u32(file_data, 4096u32.wrapping_add(off)); - info[(i + 1) as usize] = k ^ cell; - k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i)); - } -} - -/// decrypt_data6: LFSR XOR decryption of a bytecode block at `pos` in `d`. -/// The block length is read from `d[pos + 95]`. -pub(crate) fn decrypt_data6(d: &mut [u8], pos: u32) { - let len = d[(pos + 95) as usize] as usize; - // The keystream is exactly `lfsr_keystream`'s — generate it once (len is a - // byte, so 256 always covers it) instead of keeping a second copy of the - // LFSR that a future poly fix would have to update separately. - let mut ks = [0u8; 256]; - lfsr_keystream(&mut ks); - let pos = pos as usize; - for i in 0..len { - d[pos + i] ^= ks[i]; - } -} - -/// decrypt_data7: nibble-swap + key-rolling byte cipher applied to a -/// null-terminated string in `d` starting at `pos`. -pub(crate) fn decrypt_data7(d: &mut [u8], pos: u32, mut key: u8) { - let mut i: u32 = 0; - loop { - let idx = (pos + i) as usize; - if d[idx] == 0 { - break; - } - let mut b = d[idx]; - b = b.rotate_right(4); - b = b.wrapping_sub(key); - if b == 0 { - b = 0u8.wrapping_sub(key); - } - d[idx] = b; - key = key.wrapping_add(67); - i += 1; - } -} - -// --------------------------------------------------------------------------- -// Higher-level composite: AES + decrypt3 + optional bytecode + decompress -// --------------------------------------------------------------------------- - -/// Decrypt and optionally decompress a stage payload descriptor. -/// `pos` points to a (src, src_len, dest, dest_len) quad of dwords in `d`. -/// - AES-decrypts `src..src+src_len` using key at `key3_offset` -/// - XOR+rotate-decrypts with `decrypt_data3(pos, key, 19)` -/// - Applies optional custom `ops` bytecode per-byte -/// - If `src_len != dest_len`, Huffman/LZ-decompresses `src..` → `dest..` -/// -/// Returns the decompression success status (always `true` when no -/// decompression was needed). The PE32 eighth-stage key search relies on this. -pub(crate) fn decrypt_and_decompress_data_detailed( - d: &mut [u8], - pos: u32, - key: u32, - key1_offset: u32, - key3_offset: u32, - ops: Option<&[Op]>, -) -> Result<(), super::DecompressionFailure> { - let src = get_u32(d, pos); - let src_len = get_u32(d, pos.wrapping_add(4)); - aes_decrypt(d, src, src_len, key3_offset); - decrypt_data3(d, pos, key, 19); - if let Some(ops) = ops - && src_len != 0 - { - OpsLut::new(ops).map_region(d, src as usize, src_len as usize); - } - let dest = get_u32(d, pos.wrapping_add(8)); - let dest_len = get_u32(d, pos.wrapping_add(12)); - if src_len != dest_len { - return decompress_detailed(d, src, dest, key1_offset, src_len, dest_len); - } - Ok(()) -} - -/// Boolean compatibility wrapper used by key searches that trial candidates. -pub(crate) fn decrypt_and_decompress_data( - d: &mut [u8], - pos: u32, - key: u32, - key1_offset: u32, - key3_offset: u32, - ops: Option<&[Op]>, -) -> bool { - decrypt_and_decompress_data_detailed(d, pos, key, key1_offset, key3_offset, ops).is_ok() -} - -// --------------------------------------------------------------------------- -// dd8 page-XOR shift selection. -// -// The packer scrambles ~1 byte per 16-byte block of .text via decrypt_data8, -// keyed by `page_idx << shift` (absolute page index = text_va >> 12). Observed -// shifts are 0 and 15. The shift is NOT stored in any header/config field: -// two otherwise-unrelated builds can carry byte-identical config-version stamps -// (0x40327253) yet require different shifts, so the only reliable discriminator -// is the .text content itself. -// -// Detection scoring formula: for each candidate shift, replay decrypt_data8 -// across a few sample pages (25/50/75% of .text) and count how many of the 255 -// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift -// hits int3 pads disproportionately often (~3-10x the baseline), so the -// highest-scoring shift wins. If neither shift clears 2x the baseline, .text -// is already plaintext → skip (return 99). -// -// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub -// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8 -// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub -// matched under BOTH shifts and the selector defaulted to 0 when the truth was -// 15. The 0xCC statistic samples hundreds of positions per page and is not -// fooled by a stub whose fixed bytes happen to survive. -// --------------------------------------------------------------------------- -pub(crate) fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 { - if text_size < 0x1000 { - return 0; - } - let text_off = text_va as usize; - let num_pages_total = text_size >> 12; - - // Sample pages at 25/50/75% of .text, falling back to the midpoint for tiny - // sections. - let mut sample_pages: Vec = Vec::new(); - for frac in [0.25f64, 0.5, 0.75] { - let pg = (num_pages_total as f64 * frac) as u32; - if pg > 0 && pg < num_pages_total { - sample_pages.push(pg); - } - } - if sample_pages.is_empty() && num_pages_total > 1 { - sample_pages.push(num_pages_total / 2); - } - if sample_pages.is_empty() { - return 0; - } - - let none_hits = score_dd8_baseline(data, text_off, &sample_pages); - let s0 = score_dd8_shift(data, text_off, text_va, &sample_pages, 0); - let s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15); - let mut best_score = none_hits; - let mut best_shift = 99u32; // 99 == skip dd8 - for (shift, hits) in [(0u32, s0), (15u32, s15)] { - if hits > best_score { - best_score = hits; - best_shift = shift; - } - } - // Require a clear 2x margin over the already-plaintext baseline AND an - // absolute floor. The 2x test alone - // trips on noise when the counts are tiny: an external-companion DLL whose - // .text is already plaintext scores s15=4 vs none=1 — a spurious 4x — and - // gets dd8 wrongly applied, corrupting ~1 byte per 16. Across the whole - // golden corpus every build that genuinely needs dd8 scores >= 10 (lowest - // observed scores at 10-12; up to 107), so a floor of 8 rejects the noise - // while keeping every golden's shift selection unchanged. - const MIN_DD8_HITS: u32 = 8; - if best_shift != 99 && (best_score < none_hits * 2 || best_score < MIN_DD8_HITS) { - best_shift = 99; - } - if std::env::var("SEL_DIAG").is_ok() { - eprintln!( - "SEL dd8 best_shift={} s0={} s15={} none_hits={} samples={:?}", - best_shift, s0, s15, none_hits, sample_pages - ); - } - best_shift -} - -// Baseline: count int3 pads already present at the first byte of each 16-byte -// block, i.e. the positions dd8 would target if its in-block offset were 0. -fn score_dd8_baseline(data: &[u8], text_off: usize, sample_pages: &[u32]) -> u32 { - let mut hits = 0u32; - for &sp in sample_pages { - let pg_off = text_off + (sp as usize) * 0x1000; - if pg_off + 0x1000 > data.len() { - continue; - } - for bi in 1..256usize { - if data[pg_off + bi * 16] == 0xCC { - hits += 1; - } - } - } - hits -} - -// Replay decrypt_data8 on each sample page under `shift` and count how many of -// the 255 mutated positions decode to 0xCC. -fn score_dd8_shift( - data: &[u8], - text_off: usize, - text_va: u32, - sample_pages: &[u32], - shift: u32, -) -> u32 { - let abs_base = text_va >> 12; - let mut hits = 0u32; - for &sp in sample_pages { - let pg_off = text_off + (sp as usize) * 0x1000; - if pg_off + 0x1000 > data.len() { - continue; - } - let abs_page = abs_base.wrapping_add(sp); - let mut key = abs_page << shift; - for bi in 0..256u32 { - let mixed = key.rotate_right(15).wrapping_add(bi); - key = mixed.wrapping_add(bi); - if bi == 0 { - continue; - } - let tidx = (bi.wrapping_mul(16).wrapping_add(mixed & 0xF)) as usize; - if tidx < 0x1000 { - let mutated = data[pg_off + tidx] ^ (key as u8); - if mutated == 0xCC { - hits += 1; - } - } - } - } - hits -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn checked_copy_distinguishes_source_and_destination_ranges() { - let mut short_destination = [0u8; 2]; - let source = [1u8; 4]; - let error = try_copy_from_slice(&mut short_destination, 0, 3, &source, 0) - .expect_err("destination must be rejected"); - assert!(matches!( - error, - super::super::UnpackError::BufferRangeOutOfBounds { - operation: super::super::BufferOperation::CopyDestination, - offset: 0, - size: 3, - buffer_len: 2, - } - )); - - let mut destination = [0u8; 4]; - let short_source = [1u8; 2]; - let error = try_copy_from_slice(&mut destination, 0, 3, &short_source, 0) - .expect_err("source must be rejected"); - assert!(matches!( - error, - super::super::UnpackError::BufferRangeOutOfBounds { - operation: super::super::BufferOperation::CopySource, - offset: 0, - size: 3, - buffer_len: 2, - } - )); - } - - #[test] - fn aes_ks_variant_matches_single_buffer() { - // Random-ish key schedule at ko and data block; both variants must - // produce identical output. - let ko: usize = 0x40; - let mut d = vec![0u8; 0x400]; - let mut x: u32 = 0x12345678; - for b in d.iter_mut() { - x = x.wrapping_mul(1664525).wrapping_add(1013904223); - *b = (x >> 24) as u8; - } - d[ko + 2] = 10; // round count = 10 - d[ko + 3] = 0; - let snap = aes_schedule_snapshot(&d, ko as u32).expect("snapshot"); - - let mut a = d.clone(); - aes_decrypt(&mut a, 0x100, 0x80, ko as u32); - let mut b = d.clone(); - aes_decrypt_ks(&snap, &mut b, 0x100, 0x80); - if a != b { - let idx = (0..a.len()).find(|&i| a[i] != b[i]).unwrap(); - panic!( - "first diff at {idx:#x}: a={:02x} b={:02x}\n a[..]: {:02x?}\n b[..]: {:02x?}", - a[idx], - b[idx], - &a[idx..idx + 16], - &b[idx..idx + 16] - ); - } - } - - #[test] - fn dtbl_variant_matches_single_buffer() { - // Real table + real compressed block lifted from an actual unpack is - // covered by the golden suite; here we just check a trivial stream: - // build a table where every byte is a literal (mode 0, 8 bits), then - // a source stream of N bytes should expand to N identical bytes. - let ko: usize = 0x100; - let mut d = vec![0u8; 0x1000]; - for e in 0..256usize { - let off = ko + e * 3; - let sym = 0x8000u16 | (e as u16 & 0xFF); // terminal, mode 0, payload=e - d[off] = (sym & 0xFF) as u8; - d[off + 1] = (sym >> 8) as u8; - d[off + 2] = 8; // 8 bits per symbol - } - // Source: 16 bytes 0x00..0x0F at src. - let src = 0x600u32; - for i in 0..16u32 { - d[(src + i) as usize] = i as u8; - } - let snap = huffman_table_snapshot(&d, ko as u32).expect("table snapshot"); - - let mut a = vec![0u8; 0x1000]; - a[..d.len()].copy_from_slice(&d); - assert!(decompress(&mut a, src, 0x800, ko as u32, 16, 16)); - let mut b = d.clone(); - assert!(decompress_tbl(&snap, &mut b, src, 0x800, 16, 16)); - assert_eq!(&a[0x800..0x810], &b[0x800..0x810]); - assert_eq!(&b[0x800..0x810], &(0u8..16).collect::>()[..]); - } - - /// Task 4.1 regression: build a synthetic buffer whose valid bytecode block - /// sits PAST `len` but within `len*2`. Assert that the smaller window misses - /// it and the doubled window finds it. - #[test] - fn bytecode_locate_double_window_retry() { - // We place the block at offset (base + len + 16) which is inside - // the len*2 window but outside the len window. - let base: u32 = 0; - let len: u32 = 256; - // Block sits at base + len + 16 = 272, aligned to 16. - let block_pos: usize = (base + len + 16) as usize; // 272 - - // The buffer must be large enough for the block (block_pos + 96 bytes). - let buf_len = block_pos + 256; - let mut buf = vec![0u8; buf_len]; - - // Build a valid plaintext op stream: - // [4, 0, 4, 0, 4, 0, 4, 0, 195] (4 ADD-AL ops then RET) - // Padded to 10 bytes total; count >= 8. - let count: usize = 10; - let mut plain = [0u8; 256]; - plain[0] = 4; - plain[1] = 0; - plain[2] = 4; - plain[3] = 0; - plain[4] = 4; - plain[5] = 0; - plain[6] = 4; - plain[7] = 0; - plain[8] = 195; // ret - - // Compute the LFSR keystream and XOR the first `count` bytes to get the - // encrypted representation that the scanner would decrypt back. - let mut ks = [0u8; 256]; - lfsr_keystream(&mut ks); - for i in 0..count { - buf[block_pos + i] = plain[i] ^ ks[i]; - } - // Raw count byte at block_pos+95 (outside the XOR range since count=10 < 95). - buf[block_pos + 95] = count as u8; - - // Verify our construction: find_bytecode_offset with len should NOT find it. - assert_eq!( - find_bytecode_offset(&buf, base, len), - None, - "smaller window should not find the block" - ); - - // The doubled window should find it at block_pos. - assert_eq!( - find_bytecode_offset(&buf, base, len.saturating_mul(2)), - Some(block_pos as u32), - "doubled window should locate the block" - ); - } - - /// Review regression: a run-fill token with a unit width other than 1/2/4 - /// comes from a corrupt stream and must report failure — previously it - /// wrote nothing yet still counted the bytes as written, leaving stale - /// holes that later stages treated as plaintext. - #[test] - fn decompress_rejects_unknown_run_fill_width() { - // Huffman table at key_offset 0, entry 0: terminal symbol with - // mode 0x200 (run-fill), payload 3 (invalid width), code length 8. - let mut d = vec![0u8; 0x100]; - let sym: u16 = 0x8000 | 0x203; - d[0..2].copy_from_slice(&sym.to_le_bytes()); - d[2] = 8; - // All-zero source -> symbol index 0 -> the invalid run-fill. - assert_eq!( - decompress_detailed(&mut d, 0x40, 0x80, 0, 4, 3), - Err(super::super::DecompressionFailure::InvalidRunFillWidth { width: 3 }) - ); - } - - /// Control for the above: a width-1 run-fill is legal and succeeds. - #[test] - fn decompress_accepts_width1_run_fill() { - let mut d = vec![0u8; 0x100]; - d[0x7F] = 0x5A; // unit to replicate - let sym: u16 = 0x8000 | 0x201; - d[0..2].copy_from_slice(&sym.to_le_bytes()); - d[2] = 8; - assert!(decompress(&mut d, 0x40, 0x80, 0, 4, 3)); - assert_eq!(&d[0x80..0x83], &[0x5A, 0x5A, 0x5A]); - } - - /// Seed the first `count` dd8-targeted positions of each sampled page with - /// the byte that decodes to `0xCC` under the `page+1` formula — i.e. an - /// encrypted `.text` whose plaintext is int3 padding. Positions whose key - /// byte would make the *ciphertext* itself `0xCC` are skipped so the - /// fixture contains no `0xCC` at all and every post-dd8 `0xCC` is a genuine - /// gain over a zero baseline. - fn seed_dd8_int3(data: &mut [u8], text_off: u32, pages: &[u32], count: u32) { - for &sp in pages { - let pg_off = (text_off + sp * 0x1000) as usize; - let mut k = sp.wrapping_add(1); - k = k.rotate_right(15); - let mut planted = 0u32; - for bi in 1..256u32 { - let ri = k.rotate_right(15).wrapping_add(bi); - k = ri.wrapping_add(bi); - if planted >= count { - continue; - } - let ct = 0xCCu8 ^ (k as u8); - if ct == 0xCC { - continue; - } - let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize; - data[pg_off + tidx] = ct; - planted += 1; - } - } - } - - /// Review regression: a near-plaintext `.text` must NOT be dd8-decrypted. - /// dd8 XORs 255 positions per page with pseudo-random bytes, so it - /// manufactures a few `0xCC` for free — under the old bare - /// `best > baseline` test any positive gain was enough to "apply" dd8 and - /// scramble ~1 byte per 16 of a native DLL's already-plaintext code, - /// silently (nothing downstream, including the integrity check, notices). - /// Here the gain is real but small; the floor must still reject it. - #[test] - fn pe32_dd8_skips_text_whose_gain_is_only_noise_sized() { - let text_off: u32 = 0x1000; - let text_size: u32 = 8 * 0x1000; - let mut data = vec![0u8; (text_off + text_size) as usize]; - seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 5); - assert!( - !data.contains(&0xCC), - "fixture must have a zero 0xCC baseline" - ); - assert_eq!( - select_dd8_formula_pe32(&data, text_off, text_size), - None, - "a gain this small is indistinguishable from dd8's own noise" - ); - } - - /// Control for the above: a `.text` whose dd8 pass restores a large amount - /// of int3 padding clears the floor and is decrypted. Same fixture shape, - /// only the amount of restored padding differs. - #[test] - fn pe32_dd8_applies_when_padding_is_restored() { - let text_off: u32 = 0x1000; - let text_size: u32 = 8 * 0x1000; - let mut data = vec![0u8; (text_off + text_size) as usize]; - seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 255); - assert_eq!( - select_dd8_formula_pe32(&data, text_off, text_size), - Some(false), - "encrypted .text must be decrypted with the page+1 formula" - ); - } - - /// Review regression: a zero last-section VA (corrupt section table) must - /// bail instead of building .kmiat at RVA 0 — the old code zeroed - /// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image - /// as a success. A near-2 GiB VA must likewise refuse to grow the image - /// past [`super::MAX_IMAGE_SIZE`]. - #[test] - fn kmiat_bogus_section_va_bails_without_wiping_headers() { - for last_sec_va in [0u32, 0x5000_0000] { - let pe: u32 = 0x80; - let mut data = vec![0xAAu8; 0x8000]; - // COFF header: 1 section, optional header size 0xE0 (PE32). - write_u16(&mut data, pe + 6, 1); - write_u16(&mut data, pe + 20, 0xE0); - // Import directory at pe+0x80: one descriptor + null terminator. - write_u32(&mut data, pe + 0x80, 0x1100); - write_u32(&mut data, pe + 0x84, 0x28); - write_u32(&mut data, 0x1100, 0x1200); // OFT rva - write_u32(&mut data, 0x1100 + 12, 0x1300); // name rva - write_u32(&mut data, 0x1100 + 16, 0x1400); // IAT rva - for b in &mut data[0x1100 + 20..0x1100 + 40] { - *b = 0; // null terminator descriptor - } - data[0x1300..0x1300 + 13].copy_from_slice(b"KERNEL32.dll\0"); - write_u32(&mut data, 0x1200, 0x1500); // thunk -> hint/name - write_u32(&mut data, 0x1204, 0); // thunk terminator - data[0x1500..0x1502].copy_from_slice(&0u16.to_le_bytes()); - data[0x1502..0x1502 + 12].copy_from_slice(b"ExitProcess\0"); - // Section table at pe+24+0xE0 = 0x178; VA field at +12. - write_u32(&mut data, 0x178 + 12, last_sec_va); - - let head_before: Vec = data[..0x400].to_vec(); - let len_before = data.len(); - move_pe32_imports_to_kmiat(&mut data, pe); - assert_eq!( - data.len(), - len_before, - "VA 0x{last_sec_va:08X}: image must not grow" - ); - assert_eq!( - &data[..0x400], - &head_before[..], - "VA 0x{last_sec_va:08X}: headers must be untouched" - ); - } - } -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index b598c3f..0000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Shared test fixtures. -#![allow(dead_code)] - -use std::path::PathBuf; - -/// Path to `senbei/samples` — the user-managed corpus dropped in by hand. -/// Git-ignored except its README; tests here run against whatever is present. -pub fn samples_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples") -} diff --git a/tests/job.rs b/tests/job.rs deleted file mode 100644 index 8fb8a9c..0000000 --- a/tests/job.rs +++ /dev/null @@ -1,31 +0,0 @@ -use senbei::job::{default_out_root_for_file, out_name}; -use std::path::Path; - -#[test] -fn out_name_inserts_unpack_before_last_dot() { - assert_eq!(out_name(Path::new("foo.exe")), Path::new("foo.unpack.exe")); - assert_eq!( - out_name(Path::new("a/b/bar.dll")), - Path::new("a/b/bar.unpack.dll") - ); - assert_eq!(out_name(Path::new("x.y.dll")), Path::new("x.y.unpack.dll")); -} - -#[test] -fn out_name_no_dot_appends_unpack() { - assert_eq!(out_name(Path::new("nodot")), Path::new("nodot.unpack")); -} - -#[test] -fn default_out_root_for_file_is_parent_unpack() { - assert_eq!( - default_out_root_for_file(Path::new("a/b/foo.exe")), - Path::new("a/b/unpack") - ); -} - -#[test] -fn default_out_root_for_file_cwd_when_no_parent() { - let p = default_out_root_for_file(Path::new("foo.exe")); - assert_eq!(p, Path::new(".").join("unpack")); -} diff --git a/tests/logfile.rs b/tests/logfile.rs deleted file mode 100644 index c8250ea..0000000 --- a/tests/logfile.rs +++ /dev/null @@ -1,47 +0,0 @@ -use senbei::logfile::{Log, local_stamp_compact, local_stamp_display}; - -#[test] -fn local_stamp_compact_matches_shape() { - let s = local_stamp_compact(); - // YYYYMMDD-HHMMSS → 15 chars, digit groups around dash - assert_eq!(s.len(), 15, "got {s}"); - assert_eq!(&s[8..9], "-"); - assert!(s.as_bytes().iter().enumerate().all(|(i, b)| { - if i == 8 { - *b == b'-' - } else { - b.is_ascii_digit() - } - })); -} - -#[test] -fn local_stamp_display_matches_shape() { - let s = local_stamp_display(); - // YYYY-MM-DD HH:MM:SS → 19 chars - assert_eq!(s.len(), 19, "got {s}"); - assert_eq!(&s[4..5], "-"); - assert_eq!(&s[7..8], "-"); - assert_eq!(&s[10..11], " "); - assert_eq!(&s[13..14], ":"); - assert_eq!(&s[16..17], ":"); -} - -#[test] -fn log_writes_timestamped_file_in_target_dir() { - let td = tempfile::tempdir().unwrap(); - let log = Log::create(td.path()).unwrap(); - log.step("hello"); - let path = log.path().to_path_buf(); - drop(log); - assert!(path.starts_with(td.path())); - let name = path.file_name().unwrap().to_string_lossy(); - assert!( - name.starts_with("senbei-") && name.ends_with(".log"), - "unexpected log name: {name}" - ); - // senbei-YYYYMMDD-HHMMSS.log - let core = name.trim_start_matches("senbei-").trim_end_matches(".log"); - assert_eq!(core.len(), 15, "stamp in name: {name}"); - assert!(std::fs::read_to_string(&path).unwrap().contains("hello")); -} diff --git a/tests/run_log.rs b/tests/run_log.rs deleted file mode 100644 index 3a909e7..0000000 --- a/tests/run_log.rs +++ /dev/null @@ -1,80 +0,0 @@ -use senbei::job; -use std::path::Path; - -fn list_logs(dir: &Path) -> Vec { - std::fs::read_dir(dir) - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .map(|n| n.starts_with("senbei-") && n.ends_with(".log")) - .unwrap_or(false) - }) - .collect() -} - -#[test] -fn run_file_no_log_creates_no_logfile() { - let td = tempfile::tempdir().unwrap(); - let input = td.path().join("not_crackproof.bin"); - std::fs::write(&input, b"not a pe").unwrap(); - let out = td.path().join("out"); - let s = job::run_file_v(&input, Some(&out), 2, false, true).unwrap(); - assert_eq!(s.errors, 1); - // With no_log, no senbei-*.log under out (even if the dir was created). - assert!(list_logs(&out).is_empty()); -} - -#[test] -fn run_file_writes_log_under_out_with_header_footer() { - let td = tempfile::tempdir().unwrap(); - let input = td.path().join("not_crackproof.bin"); - std::fs::write(&input, b"not a pe").unwrap(); - let out = td.path().join("out"); - let s = job::run_file_v(&input, Some(&out), 2, false, false).unwrap(); - assert_eq!(s.errors, 1); - let logs = list_logs(&out); - assert_eq!(logs.len(), 1, "expected one log under out, got {logs:?}"); - let text = std::fs::read_to_string(&logs[0]).unwrap(); - assert!(text.contains("Senbei "), "header version: {text}"); - assert!(text.contains("started "), "{text}"); - assert!(text.contains("input "), "{text}"); - assert!(text.contains("out "), "{text}"); - assert!(text.contains("ERR "), "{text}"); - assert!(text.contains("done in "), "{text}"); - assert!(text.contains("summary:"), "{text}"); -} - -#[test] -fn run_file_default_out_root_is_parent_unpack() { - let td = tempfile::tempdir().unwrap(); - let input = td.path().join("not_crackproof.bin"); - std::fs::write(&input, b"not a pe").unwrap(); - let _ = job::run_file_v(&input, None, 2, false, false).unwrap(); - let unpack = td.path().join("unpack"); - assert!(unpack.is_dir()); - assert_eq!(list_logs(&unpack).len(), 1); - // log must NOT be next to input's parent root without unpack - assert!(list_logs(td.path()).is_empty()); -} - -#[test] -fn run_folder_log_lives_under_out_not_root() { - let td = tempfile::tempdir().unwrap(); - // empty tree: 0 candidates still creates log under unpack - let s = job::run_folder_v(td.path(), None, 2, false, false).unwrap(); - assert_eq!(s.unpacked, 0); - let unpack = td.path().join("unpack"); - assert!(unpack.is_dir()); - assert_eq!(list_logs(&unpack).len(), 1); - assert!( - list_logs(td.path()).is_empty(), - "log must not sit on input root" - ); - let text = std::fs::read_to_string(&list_logs(&unpack)[0]).unwrap(); - assert!(text.contains("done in ")); - assert!(text.contains("summary:")); -} diff --git a/tests/samples.rs b/tests/samples.rs deleted file mode 100644 index 782c413..0000000 --- a/tests/samples.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Corpus test over the user-managed `senbei/samples` folder. -//! -//! Drop real Crackproof `*.exe` / `*.dll` inputs in there (and/or il2cpp -//! `*.dat` metadata blobs), optionally alongside a byte-exact golden named -//! `.golden.`. Each input is processed and classified: -//! -//! - golden present, bytes identical -> pass (silent) -//! - golden present, bytes differ -> FAIL (the test fails) -//! - no golden -> WARNING (printed; needs a manual check) -//! -//! Inputs go through [`senbei::job::unpack_bytes`], the same routing the CLI -//! uses, **not** `unpack_auto` directly. That matters: `unpack_auto` alone -//! cannot reach the external-companion layout, whose stub is meaningless -//! without its `._` payload — a corpus wired to `unpack_auto` silently -//! covers none of the splice / export-overlay / TLS-restore code, nor the -//! marker-less "new layout" those builds use. A `._` sibling in the -//! samples folder is picked up automatically, exactly as it is on disk. -//! -//! An input whose bytes carry the il2cpp metadata magic is routed through -//! [`senbei::metadata::deobfuscate`] instead, giving the method-token remap -//! real-world coverage (its unit tests only build synthetic layouts). -//! -//! The folder is git-ignored (see `senbei/samples/README.md`), so the set of -//! samples is whatever happens to be on the machine. An empty/absent folder is -//! a no-op pass. - -mod common; -use common::samples_dir; -use std::path::Path; -use walkdir::WalkDir; - -/// An input is a `.exe`/`.dll`/`.dat` whose name doesn't carry the `.golden.` -/// marker — those are goldens, not inputs. External companions (`._`) -/// have extension `_` and are therefore never inputs in their own right; they -/// are consumed by their base module. -fn is_input(path: &Path) -> bool { - let Some(ext) = path.extension().and_then(|e| e.to_str()) else { - return false; - }; - let ext = ext.to_ascii_lowercase(); - if ext != "exe" && ext != "dll" && ext != "dat" { - return false; - } - // Reject goldens like `foo.golden.exe`. - !path - .file_name() - .and_then(|n| n.to_str()) - .map(|n| n.to_ascii_lowercase().contains(".golden.")) - .unwrap_or(false) -} - -/// Golden path for an input: `.golden.` next to it. -fn golden_for(input: &Path) -> std::path::PathBuf { - let ext = input.extension().and_then(|e| e.to_str()).unwrap_or(""); - let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - input.with_file_name(format!("{stem}.golden.{ext}")) -} - -/// External-companion path for an input: `._` next to it, -/// matching what the CLI looks for on disk. -fn companion_for(input: &Path) -> Option { - let name = input.file_name()?; - let mut n = name.to_os_string(); - n.push("._"); - let p = input.with_file_name(n); - p.is_file().then_some(p) -} - -#[test] -fn samples_unpack_against_goldens() { - let dir = samples_dir(); - // An absent/empty corpus fails only when explicitly required — a green - // run that unpacked nothing hides every unpack regression, but on public - // CI there is no corpus at all (binaries are never committed), so the - // gate is opt-in via SENBEI_REQUIRE_SAMPLES rather than implied by CI. - // Locally the corpus is the user-managed samples/ folder (see - // samples/README.md). - let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some(); - if !dir.is_dir() { - assert!( - !require, - "samples: {} does not exist — corpus required (CI)", - dir.display() - ); - eprintln!("samples: {} does not exist, nothing to test", dir.display()); - return; - } - - let mut inputs: Vec<_> = WalkDir::new(&dir) - .follow_links(false) - .into_iter() - .filter_entry(|entry| { - entry.depth() == 0 - || !entry - .file_name() - .to_str() - .is_some_and(|name| name.eq_ignore_ascii_case("unpack")) - }) - .map(|entry| entry.unwrap_or_else(|e| panic!("walk {}: {e}", dir.display()))) - .filter(|entry| entry.file_type().is_file() && is_input(entry.path())) - .map(|entry| entry.into_path()) - .collect(); - inputs.sort(); - - if inputs.is_empty() { - assert!( - !require, - "samples: no .exe/.dll inputs in {} — corpus required (CI)", - dir.display() - ); - eprintln!("samples: no .exe/.dll inputs in {}", dir.display()); - return; - } - - let mut passed = 0usize; - let mut warnings: Vec = Vec::new(); - let mut failures: Vec = Vec::new(); - - for input in &inputs { - let name = input - .strip_prefix(&dir) - .unwrap_or(input) - .to_string_lossy() - .to_string(); - let bytes = match std::fs::read(input) { - Ok(b) => b, - Err(e) => { - failures.push(format!("{name}: read error: {e}")); - continue; - } - }; - - let got = if senbei::metadata::is_metadata(&bytes) { - // il2cpp metadata: method-token de-obfuscation, no PE pipeline and - // no integrity check (the output is not a PE image). - match senbei::metadata::deobfuscate(&bytes) { - Ok((out, _report)) => out, - Err(e) => { - failures.push(format!("{name}: de-obfuscation failed: {e}")); - continue; - } - } - } else { - // Splice in the external companion when one sits next to the input, - // then run the CLI's routing (which also overlays the stub's export - // table and TLS directory for spliced inputs). - let companion = match companion_for(input) { - Some(p) => match std::fs::read(&p) { - Ok(b) => Some(b), - Err(e) => { - failures.push(format!("{name}: companion read error: {e}")); - continue; - } - }, - None => None, - }; - let image = match senbei::job::unpack_bytes(&bytes, companion.as_deref()) { - Ok(img) => img, - Err(e) => { - failures.push(format!("{name}: unpack failed: {e:?}")); - continue; - } - }; - // The static integrity check is a second, golden-independent gate: - // it catches an output that is structurally plausible but would - // crash at runtime (0xC0000005) even when a stale golden still - // byte-matches. (Goldens are byte comparisons only — "matches - // golden" ≠ runs.) - if !image.integrity.ok() { - failures.push(format!( - "{name}: integrity check failed: {}", - image.integrity.issues.join("; ") - )); - continue; - } - image.bytes - }; - - let golden = golden_for(input); - if !golden.exists() { - warnings.push(format!( - "{name}: unpacked OK ({} bytes) but no golden ({}) — MANUAL CHECK", - got.len(), - golden.file_name().unwrap().to_string_lossy() - )); - continue; - } - - let want = match std::fs::read(&golden) { - Ok(b) => b, - Err(e) => { - failures.push(format!("{name}: golden read error: {e}")); - continue; - } - }; - - if got.len() != want.len() { - failures.push(format!( - "{name}: length differs: got {} want {}", - got.len(), - want.len() - )); - continue; - } - if let Some((i, (a, b))) = got.iter().zip(&want).enumerate().find(|(_, (a, b))| a != b) { - failures.push(format!( - "{name}: first diff at 0x{i:X}: got {a:02X} want {b:02X}" - )); - continue; - } - passed += 1; - } - - eprintln!( - "samples: {} input(s) — {} pass, {} warning(s), {} failure(s)", - inputs.len(), - passed, - warnings.len(), - failures.len() - ); - for w in &warnings { - eprintln!(" WARN {w}"); - } - for f in &failures { - eprintln!(" FAIL {f}"); - } - - assert!(failures.is_empty(), "{} sample(s) failed", failures.len()); -} diff --git a/web/Cargo.lock b/web/Cargo.lock deleted file mode 100644 index 5d0237d..0000000 --- a/web/Cargo.lock +++ /dev/null @@ -1,441 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "indicatif" -version = "0.18.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "senbei" -version = "1.0.0" -dependencies = [ - "anyhow", - "indicatif", - "libc", - "owo-colors", - "thiserror", - "walkdir", - "windows", -] - -[[package]] -name = "senbei-web" -version = "1.0.0" -dependencies = [ - "console_error_panic_hook", - "senbei", - "wasm-bindgen", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] diff --git a/web/Cargo.toml b/web/Cargo.toml deleted file mode 100644 index 1ce74ec..0000000 --- a/web/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "senbei-web" -version = "1.0.0" -edition = "2024" -description = "WebAssembly browser frontend for senbei" -license = "AGPL-3.0-only" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -senbei = { path = ".." } -wasm-bindgen = "0.2" -console_error_panic_hook = "0.1" - -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 diff --git a/web/LICENSE b/web/LICENSE deleted file mode 100644 index fe6b903..0000000 --- a/web/LICENSE +++ /dev/null @@ -1,662 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. - diff --git a/web/README.md b/web/README.md deleted file mode 100644 index c284174..0000000 --- a/web/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Senbei web - -Senbei running in the browser: the unpacker core compiled to WebAssembly, -wrapped in a small static page. Everything is client-side — files are read -into the page, unpacked locally, and offered back as downloads. Nothing is -uploaded; there is no server component. - -## Features - -- A legal notice is shown as a blocking dialog on page open; the tool is - unusable until it is acknowledged. -- Dropped files land in a file list, not unpacked immediately: review the - batch, remove mistakes, then press **Unpack**. A module and its `._` - companion can be dropped in any order (or in separate drops) — companions - auto-pair by name (`Foo.dll._` → `Foo.dll`) and show as a badge on the - module's row; removing a module removes its companion too. -- Rows show state at a glance: black while staged, an animated blue bar - while unpacking, green on success (with a download button) and red on - failure. -- Drop one or more protected `.exe` / `.dll` modules → get `.unpack.*` - downloads. -- Drop an il2cpp `global-metadata.dat` → de-obfuscated - `global-metadata.unpack.dat` (only when tokens actually change). -- Each output passes the same static integrity check as the CLI; suspect - outputs are flagged with the specific defects found. - -## Architecture notes - -- Every unpack runs in a **disposable Web Worker** (fresh wasm instance per - file): the UI stays responsive on 100 MB+ modules, and a wasm trap is - isolated to that worker. -- **Why workers matter for correctness:** the DLL-first routing probe relies - on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be - caught in WebAssembly — the probe traps the whole call. When a DLL unpack - traps, the app retries once in a new worker with the forced-EXE pipeline - (`unpack_file_force_exe`), reproducing the CLI's dll-first/exe-fallback - outcome. Spliced companion inputs skip the probe entirely (they are always - EXE-shell layout), exactly like the CLI. -- Rust panic messages are forwarded to the browser console - (`console_error_panic_hook`) — check devtools when reporting an issue. - -## Building - -Requires a Rust toolchain (`rust-toolchain.toml` in the repo root pins one, -including the `wasm32-unknown-unknown` target) and -[wasm-pack](https://rustwasm.github.io/wasm-pack/installer/). - -```cmd -cd web -wasm-pack build --target web --release -``` - -This produces `web/pkg/` (git-ignored). Then serve the `web/` directory with -any static file server and open `index.html`: - -```cmd -python -m http.server -d web 8000 -:: -> http://localhost:8000 -``` - -(Opening `index.html` via `file://` won't work — ES modules require HTTP.) - -## Layout - -``` -web/ -├── Cargo.toml senbei-web cdylib crate (depends on the senbei lib) -├── src/lib.rs #[wasm_bindgen] bindings: detect / unpack_file / -│ unpack_file_force_exe / deobfuscate_metadata -├── index.html the page -├── app.js dropzone, file list, worker orchestration, downloads -├── worker.js one-shot unpack worker (fresh wasm instance per file) -├── style.css -└── pkg/ wasm-pack output (git-ignored) -``` diff --git a/web/app.js b/web/app.js deleted file mode 100644 index a5c9df6..0000000 --- a/web/app.js +++ /dev/null @@ -1,392 +0,0 @@ -import init, { detect, deobfuscate_metadata } from './pkg/senbei_web.js'; - -const dropzone = document.getElementById('dropzone'); -const picker = document.getElementById('picker'); -const fileList = document.getElementById('files'); -const actions = document.getElementById('actions'); -const unpackBtn = document.getElementById('unpack-btn'); -const clearBtn = document.getElementById('clear-btn'); -const legalOverlay = document.getElementById('legal-overlay'); -const legalAccept = document.getElementById('legal-accept'); -const legalLink = document.getElementById('legal-link'); - -await init(); - -// --- Legal gate: the page is unusable until the notice is acknowledged. --- -legalAccept.addEventListener('click', () => legalOverlay.remove()); -legalLink.addEventListener('click', (e) => { - e.preventDefault(); - if (!document.getElementById('legal-overlay')) { - document.body.appendChild(legalOverlay); - } -}); - -// --- File list: one row per module. Companions (`X._`) never get their own --- -// --- row once their base module `X` is present — they show as a badge on --- -// --- the base row. Rows are black while staged, show an animated blue --- -// --- progress bar while unpacking, and turn green (success) or red --- -// --- (failure) at the end; success rows gain a download button. --- - -// --- Row DOM is updated INCREMENTALLY: rows are created once and patched --- -// --- in place. Rebuilding the list on every change would restart the --- -// --- entrance animation of every row and reset the unpack shimmer. --- - -/** name -> { - * file: File, - * kind: string|undefined, // detect() result; undefined for `._` files - * state: 'staged'|'working'|'ok'|'err', - * bytes: Uint8Array|null, // unpacked output (state 'ok') - * note: string, // status line (kind, suspect issues, error) - * suspect: boolean, - * } */ -const files = new Map(); - -/** name -> row
  • element (companions merged into their base have none) */ -const rowEls = new Map(); - -const KIND_LABEL = { - exe: 'protected EXE', - 'native-dll': 'protected native DLL', - 'managed-dll': 'protected managed DLL', - metadata: 'il2cpp metadata', -}; - -const COMPANION_SVG = - ''; - -const DOWNLOAD_SVG = - ''; - -dropzone.addEventListener('click', () => picker.click()); -dropzone.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') picker.click(); -}); -picker.addEventListener('change', () => { - stageFiles(picker.files); - picker.value = ''; -}); -dropzone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropzone.classList.add('over'); -}); -dropzone.addEventListener('dragleave', () => dropzone.classList.remove('over')); -dropzone.addEventListener('drop', (e) => { - e.preventDefault(); - dropzone.classList.remove('over'); - stageFiles(e.dataTransfer.files); -}); - -async function stageFiles(list) { - // Snapshot synchronously: `picker.files` and `dataTransfer.files` are LIVE - // lists — clearing the picker or returning from the drop event empties - // them, so an await before this point silently drops every file after the - // first. - const snapshot = [...list]; - for (const file of snapshot) { - // Detection only needs the file header (key table at offset 4096 plus - // the PE header fields); read a small slice, not the whole file. - const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer()); - // Companions are ciphertext fragments; detect() only makes sense on the - // base module, so skip it for `._` files. - const kind = file.name.endsWith('._') ? undefined : detect(head); - const old = files.get(file.name); - files.set(file.name, { - file, - kind, - state: 'staged', - bytes: null, - note: '', - suspect: false, - }); // same name re-dropped: replace - // A re-dropped file restarts as staged; drop any stale row/output. - if (old) removeRow(file.name, true); - } - render(); -} - -/** Insert `.unpack` before the final extension: `app.exe` -> `app.unpack.exe`. */ -function outName(name) { - const dot = name.lastIndexOf('.'); - return dot > 0 ? `${name.slice(0, dot)}.unpack${name.slice(dot)}` : `${name}.unpack`; -} - -function statusText(name, entry) { - if (name.endsWith('._')) { - return `companion — needs ${name.slice(0, -2)}`; - } - switch (entry.state) { - case 'staged': - return entry.kind === undefined - ? 'not recognized — will be skipped' - : KIND_LABEL[entry.kind] ?? entry.kind; - case 'working': - return 'unpacking…'; - case 'ok': - case 'err': - return entry.note; - } -} - -function buildRow(name) { - const li = document.createElement('li'); - li.className = 'file staged'; - li.dataset.name = name; - - const bar = document.createElement('div'); - bar.className = 'bar'; - li.appendChild(bar); - - const row = document.createElement('div'); - row.className = 'row'; - - const label = document.createElement('span'); - label.className = 'name'; - label.textContent = name; - row.appendChild(label); - - const badge = document.createElement('span'); - badge.className = 'badge companion'; - badge.innerHTML = COMPANION_SVG; - badge.hidden = true; - row.appendChild(badge); - - const status = document.createElement('span'); - status.className = 'status'; - row.appendChild(status); - - const dl = document.createElement('a'); - dl.className = 'dl'; - dl.innerHTML = DOWNLOAD_SVG; - dl.hidden = true; - row.appendChild(dl); - - const rm = document.createElement('button'); - rm.type = 'button'; - rm.className = 'remove'; - rm.textContent = '×'; - rm.title = `Remove ${name}`; - rm.addEventListener('click', () => { - // A companion belongs to its base module: removing the base removes the - // companion too. - files.delete(name); - if (!name.endsWith('._')) files.delete(`${name}._`); - render(); - }); - row.appendChild(rm); - - li.appendChild(row); - return li; -} - -function updateRow(li, name, entry) { - const isCompanion = name.endsWith('._'); - li.className = - `file ${entry.state}` + (entry.suspect && entry.state === 'ok' ? ' suspect' : ''); - - const badge = li.querySelector('.badge'); - const hasCompanion = isCompanion || files.has(`${name}._`); - badge.hidden = !hasCompanion; - if (hasCompanion) { - badge.title = isCompanion - ? 'external companion (._)' - : `companion loaded: ${name}._`; - } - - li.querySelector('.status').textContent = statusText(name, entry); - - const dl = li.querySelector('.dl'); - const downloadable = entry.state === 'ok' && entry.bytes; - dl.hidden = !downloadable; - if (downloadable) { - if (dl._bytesFor !== entry.bytes) { - if (dl.href) URL.revokeObjectURL(dl.href); - dl.href = URL.createObjectURL( - new Blob([entry.bytes], { type: 'application/octet-stream' }), - ); - dl._bytesFor = entry.bytes; - } - dl.download = outName(name); - dl.title = `Download ${outName(name)}`; - } -} - -function removeRow(name, instant) { - const li = rowEls.get(name); - if (!li) return; - rowEls.delete(name); - // Release the download blob. Object URLs are roots: without this an unpacked - // 100 MB image stays resident for the life of the page every time a row is - // removed or the list is cleared. - const dl = li.querySelector('.dl'); - if (dl?.href) { - URL.revokeObjectURL(dl.href); - dl.removeAttribute('href'); - dl._bytesFor = null; - } - if (instant) { - li.remove(); - return; - } - // Fade AND collapse: without the height/margin transition the rows below - // would hold position during the fade and then snap up on removal. The - // end state must be inline too — an inline start value would otherwise - // beat the stylesheet's `.leaving { max-height: 0 }`. - li.style.maxHeight = `${li.offsetHeight}px`; - void li.offsetHeight; // reflow: give the transition a concrete start value - li.classList.add('leaving'); - li.style.maxHeight = '0px'; - setTimeout(() => li.remove(), 230); -} - -function render() { - // Create/update rows in Map order; companions whose base is staged merge - // into the base row (no row of their own). - const wanted = []; - for (const [name] of files) { - if (name.endsWith('._') && files.has(name.slice(0, -2))) continue; - wanted.push(name); - } - const wantedSet = new Set(wanted); - - // Removals first: departed rows are marked leaving (and dropped from - // rowEls) BEFORE the ordering loop, so the loop treats them as transparent - // and never reorders siblings around them (that would snap, not slide). - for (const name of [...rowEls.keys()]) { - if (!wantedSet.has(name)) removeRow(name, false); - } - - // In-place ordering: only rows that are out of position are moved, so - // running animations (entrance, shimmer) are never restarted by a render. - // Rows mid-leave-animation (no longer in rowEls) are skipped and keep - // their spot — reordering siblings around them would make them snap - // instead of sliding with the collapse. - let cursor = fileList.firstChild; - for (const name of wanted) { - let li = rowEls.get(name); - if (!li) { - li = buildRow(name); - rowEls.set(name, li); - } - updateRow(li, name, files.get(name)); - while (cursor && !rowEls.has(cursor.dataset.name)) { - cursor = cursor.nextSibling; - } - if (li === cursor) { - cursor = cursor.nextSibling; - } else { - fileList.insertBefore(li, cursor); - } - } - - const unpackable = [...files].some( - ([name, e]) => - !name.endsWith('._') && e.kind !== undefined && e.state === 'staged', - ); - unpackBtn.disabled = !unpackable; - actions.hidden = files.size === 0; -} - -clearBtn.addEventListener('click', () => { - files.clear(); - render(); -}); - -/** - * Run one unpack in a disposable Web Worker (fresh wasm instance per call — - * see worker.js). Buffers are transferred, so the inputs are neutered on the - * main thread afterwards; callers re-read from the File for a retry. - */ -function runUnpack(inputBytes, compBytes, forceExe) { - return new Promise((resolve) => { - const w = new Worker('worker.js', { type: 'module' }); - w.onmessage = (e) => { - w.terminate(); - resolve(e.data); - }; - w.onerror = (e) => { - w.terminate(); - resolve({ ok: false, trap: true, message: e.message || 'worker error' }); - }; - const transfer = [inputBytes.buffer, ...(compBytes ? [compBytes.buffer] : [])]; - w.postMessage({ input: inputBytes, companion: compBytes ?? null, forceExe }, transfer); - }); -} - -async function unpackModule(name, entry) { - const compEntry = files.get(`${name}._`); - const read = (f) => f.arrayBuffer().then((b) => new Uint8Array(b)); - - let input = await read(entry.file); - let comp = compEntry ? await read(compEntry.file) : undefined; - let r = await runUnpack(input, comp, false); - - if (!r.ok && r.trap && entry.kind !== 'exe') { - // The DLL-routing probe trapped (panics can't be caught in wasm). Retry - // once with the forced-EXE pipeline in a fresh worker — this mirrors the - // CLI's dll-first/exe-fallback outcome for EXE-shell-layout DLLs. - input = await read(entry.file); - comp = compEntry ? await read(compEntry.file) : undefined; - r = await runUnpack(input, comp, true); - } - - if (!r.ok) { - entry.state = 'err'; - entry.note = r.trap - ? 'unpack failed (internal trap) — this Crackproof layout may be unsupported' - : r.message; - return; - } - entry.state = 'ok'; - entry.bytes = r.bytes; - entry.suspect = r.suspect; - entry.note = - (r.companion ? 'spliced from ._ companion; ' : '') + - `kind: ${r.kind}` + - (r.suspect ? ` — SUSPECT: ${r.issues.join('; ')}` : ''); -} - -unpackBtn.addEventListener('click', async () => { - unpackBtn.disabled = true; - clearBtn.disabled = true; - try { - for (const [name, entry] of files) { - if (name.endsWith('._') || entry.state !== 'staged') continue; - - if (entry.kind === undefined) { - entry.state = 'err'; - entry.note = 'not recognized as Crackproof-protected — skipped'; - render(); - continue; - } - - entry.state = 'working'; - render(); - try { - if (entry.kind === 'metadata') { - const bytes = new Uint8Array(await entry.file.arrayBuffer()); - const r = deobfuscate_metadata(bytes); - if (r.remapped === 0) { - entry.state = 'err'; - entry.note = `metadata already clean (v${r.version}, ${r.methods} methods) — nothing to do`; - } else { - entry.state = 'ok'; - entry.bytes = r.bytes; - entry.note = `${r.remapped}/${r.methods} method tokens remapped across ${r.modules} modules`; - } - } else { - await unpackModule(name, entry); - } - } catch (e) { - entry.state = 'err'; - entry.note = e instanceof Error ? e.message : String(e); - } - render(); - } - } finally { - clearBtn.disabled = false; - render(); - } -}); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 59e9c2c..0000000 --- a/web/index.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - -Senbei — static Crackproof unpacker - - - - - -
    -
    -

    Senbei web

    - - - -
    -

    - Static unpacker for Crackproof-protected PE files, running entirely in - your browser. No file ever leaves your device. -

    - -
    -

    Drop files here or click to browse

    -

    - Protected .exe / .dll modules, optional - ._ companions, or an il2cpp - global-metadata.dat. -

    - -
    - -
      - - - -
      -

      Legal notice · - Senbei is free software under the AGPL-3.0 license.

      -
      -
      - - - diff --git a/web/src/lib.rs b/web/src/lib.rs deleted file mode 100644 index 4b98583..0000000 --- a/web/src/lib.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! WebAssembly bindings for the senbei unpacker core. -//! -//! Everything here is I/O-free: the browser hands in file bytes and gets -//! unpacked file bytes back. No network, no filesystem, no uploads. - -use wasm_bindgen::prelude::*; - -/// Install a panic hook that forwards Rust panic messages to the browser -/// console (and to the JS error), instead of a bare `unreachable` trap. -#[wasm_bindgen(start)] -pub fn init_panic_hook() { - console_error_panic_hook::set_once(); -} - -/// Result of unpacking one protected module. -#[wasm_bindgen] -pub struct UnpackResult { - kind: String, - bytes: Vec, - suspect: bool, - issues: Vec, - companion: bool, -} - -#[wasm_bindgen] -impl UnpackResult { - /// Detected module kind: `"exe"`, `"native-dll"`, or `"managed-dll"`. - #[wasm_bindgen(getter)] - pub fn kind(&self) -> String { - self.kind.clone() - } - - /// The unpacked image bytes. - #[wasm_bindgen(getter)] - pub fn bytes(&self) -> Vec { - self.bytes.clone() - } - - /// True when the static integrity check flagged the output as likely - /// broken at runtime. The bytes are still the best available. - #[wasm_bindgen(getter)] - pub fn suspect(&self) -> bool { - self.suspect - } - - /// Human-readable integrity defects (empty when the check is clean). - #[wasm_bindgen(getter)] - pub fn issues(&self) -> Vec { - self.issues.clone() - } - - /// True when the input was spliced from an external-companion (`._`) - /// payload. - #[wasm_bindgen(getter)] - pub fn companion(&self) -> bool { - self.companion - } -} - -/// Result of de-obfuscating an il2cpp `global-metadata.dat`. -#[wasm_bindgen] -pub struct MetadataResult { - bytes: Vec, - version: u32, - methods: usize, - remapped: usize, - modules: usize, -} - -#[wasm_bindgen] -impl MetadataResult { - /// The (possibly rewritten) metadata bytes. - #[wasm_bindgen(getter)] - pub fn bytes(&self) -> Vec { - self.bytes.clone() - } - - #[wasm_bindgen(getter)] - pub fn version(&self) -> u32 { - self.version - } - - /// Total method-definition entries in the metadata. - #[wasm_bindgen(getter)] - pub fn methods(&self) -> usize { - self.methods - } - - /// Method tokens actually rewritten (0 means the input was already - /// de-obfuscated and the bytes are unchanged). - #[wasm_bindgen(getter)] - pub fn remapped(&self) -> usize { - self.remapped - } - - /// Modules (images) owning at least one method. - #[wasm_bindgen(getter)] - pub fn modules(&self) -> usize { - self.modules - } -} - -fn kind_str(kind: senbei::unpacker::Kind) -> &'static str { - match kind { - senbei::unpacker::Kind::Exe => "exe", - senbei::unpacker::Kind::NativeDll => "native-dll", - senbei::unpacker::Kind::ManagedDll => "managed-dll", - } -} - -/// Classify a file's bytes without unpacking. -/// -/// Returns `"exe"`, `"native-dll"`, `"managed-dll"`, `"metadata"` (an il2cpp -/// `global-metadata.dat`), or `undefined` for anything unrecognized. -#[wasm_bindgen] -pub fn detect(input: &[u8]) -> Option { - if senbei::metadata::is_metadata(input) { - return Some("metadata".to_string()); - } - senbei::unpacker::detect(input).map(|d| kind_str(d.kind).to_string()) -} - -/// Unpack a protected module. -/// -/// `input` is the protected `.exe`/`.dll`; `companion` is the optional -/// `._` external-companion payload (pass `null`/`undefined` when there -/// is none). Throws a string error when the input is not a supported -/// Crackproof file or is corrupt. -#[wasm_bindgen] -pub fn unpack_file( - input: &[u8], - companion: Option>, -) -> Result { - let r = senbei::job::unpack_bytes(input, companion.as_deref()) - .map_err(|e| JsError::new(&e.to_string()))?; - Ok(UnpackResult { - kind: kind_str(r.kind).to_string(), - bytes: r.bytes, - suspect: !r.integrity.ok(), - issues: r.integrity.issues, - companion: r.companion, - }) -} - -/// De-obfuscate the method tokens of an il2cpp `global-metadata.dat`. -/// -/// The transform is idempotent: an already-clean metadata comes back -/// byte-identical with `remapped == 0`. Throws a string error for non-metadata -/// input, an unsupported format version, or a malformed layout. -#[wasm_bindgen] -pub fn deobfuscate_metadata(data: &[u8]) -> Result { - let (bytes, report) = - senbei::metadata::deobfuscate(data).map_err(|e| JsError::new(&e.to_string()))?; - Ok(MetadataResult { - bytes, - version: report.version, - methods: report.methods, - remapped: report.remapped, - modules: report.modules, - }) -} - -/// Unpack a protected module, forcing the EXE pipeline (no DLL-pipeline -/// probe). See [`senbei::job::unpack_bytes_force_exe`] for why the web app -/// needs this recovery path. -#[wasm_bindgen] -pub fn unpack_file_force_exe( - input: &[u8], - companion: Option>, -) -> Result { - let r = senbei::job::unpack_bytes_force_exe(input, companion.as_deref()) - .map_err(|e| JsError::new(&e.to_string()))?; - Ok(UnpackResult { - kind: kind_str(r.kind).to_string(), - bytes: r.bytes, - suspect: !r.integrity.ok(), - issues: r.integrity.issues, - companion: r.companion, - }) -} diff --git a/web/style.css b/web/style.css deleted file mode 100644 index 8a2da81..0000000 --- a/web/style.css +++ /dev/null @@ -1,369 +0,0 @@ -:root { - color-scheme: dark; - --bg: #14161a; - --panel: #1d2026; - --black: #0c0e11; - --border: #2e323b; - --text: #e4e7ec; - --dim: #9aa3b0; - --accent: #e8b64c; - --blue: #3b82f6; - --blue-deep: #1d4ed8; - --ok: #1e6b34; - --ok-bright: #6fcf7c; - --err: #7a2828; - --err-bright: #e06c6c; - --warn: #e8b64c; -} - -* { box-sizing: border-box; } - -/* display rules below (inline-flex etc.) would otherwise beat the hidden - attribute's UA display:none — the badge/download icons must stay hidden. */ -[hidden] { display: none !important; } - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 16px/1.55 system-ui, "Segoe UI", sans-serif; -} - -main { - max-width: 720px; - margin: 0 auto; - padding: 2.5rem 1.25rem 3rem; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; -} - -h1 { margin-bottom: 0.25rem; } - -.tag { - font-size: 0.45em; - vertical-align: super; - color: var(--accent); - letter-spacing: 0.08em; -} - -.github-link { - color: var(--dim); - transition: color 0.2s, transform 0.2s; -} - -.github-link:hover { - color: var(--text); - transform: scale(1.12); -} - -.lede { color: var(--dim); } -.lede strong { color: var(--text); } - -/* --- legal modal --- */ - -#legal-overlay { - position: fixed; - inset: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - padding: 1.25rem; - background: rgba(10, 11, 13, 0.82); - backdrop-filter: blur(3px); - animation: fadeIn 0.25s ease-out; -} - -.legal-box { - max-width: 560px; - max-height: 85vh; - overflow-y: auto; - padding: 1.75rem 2rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 12px; - animation: popIn 0.3s cubic-bezier(0.2, 1.4, 0.4, 1); -} - -.legal-box h2 { margin-top: 0; } -.legal-box p { color: var(--dim); font-size: 0.95rem; } -.legal-box strong { color: var(--text); } - -button { - font: inherit; - padding: 0.55rem 1.2rem; - border: 1px solid var(--accent); - border-radius: 8px; - background: var(--accent); - color: #14161a; - font-weight: 600; - cursor: pointer; - transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s; -} - -button:not(:disabled):hover { - transform: translateY(-1px); - box-shadow: 0 3px 12px rgba(232, 182, 76, 0.25); -} - -button:not(:disabled):active { transform: translateY(0); } - -button:disabled { - opacity: 0.45; - cursor: default; -} - -button.secondary { - background: transparent; - color: var(--dim); - border-color: var(--border); -} - -button.secondary:not(:disabled):hover { - box-shadow: none; - color: var(--text); -} - -#legal-accept { width: 100%; margin-top: 0.5rem; } - -/* --- dropzone --- */ - -#dropzone { - margin: 1.5rem 0; - padding: 2.25rem 1.5rem; - text-align: center; - background: var(--panel); - border: 2px dashed var(--border); - border-radius: 12px; - cursor: pointer; - transition: border-color 0.2s, background 0.2s, transform 0.2s; -} - -#dropzone:hover, #dropzone:focus-visible { - border-color: var(--accent); - outline: none; -} - -#dropzone.over { - border-color: var(--accent); - background: #232730; - transform: scale(1.01); - animation: pulse 1s ease-in-out infinite; -} - -#dropzone p { margin: 0.25rem 0; } -.hint { color: var(--dim); font-size: 0.9rem; } -code { - background: var(--bg); - padding: 0.1em 0.35em; - border-radius: 4px; - font-size: 0.9em; -} - -/* --- file list --- */ - -#files { - list-style: none; - margin: 0 0 0.75rem; - padding: 0; -} - -.file { - position: relative; - margin-bottom: 0.45rem; - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - background: var(--black); /* staged */ - transition: background-color 0.5s ease; - animation: slideIn 0.25s ease-out; -} - -.file.leaving { - opacity: 0; - transform: translateX(12px); - margin-bottom: 0; - border-width: 0; - transition: - opacity 0.13s ease-in, - transform 0.13s ease-in, - max-height 0.17s ease-in 0.03s, - margin-bottom 0.17s ease-in 0.03s, - border-width 0.17s ease-in 0.03s; -} - -.file .bar { - position: absolute; - inset: 0; - opacity: 0; - transition: opacity 0.3s; -} - -.file.working { background: var(--black); } - -/* The gradient's left and right edge colors match, so the 200%-sized image - tiles seamlessly and the position loop has no visible restart. */ -.file.working .bar { - opacity: 1; - background: linear-gradient( - 100deg, - var(--blue-deep) 0%, - var(--blue) 25%, - #7fb3ff 50%, - var(--blue) 75%, - var(--blue-deep) 100% - ); - background-size: 200% 100%; - animation: shimmer 1.6s linear infinite; -} - -.file.ok { background: var(--ok); } -.file.ok.suspect { background: #6b5a1e; } -.file.err { background: var(--err); } - -.file .row { - position: relative; - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.55rem 0.85rem; -} - -.file .name { - overflow-wrap: anywhere; - font-weight: 600; -} - -.file.working .name { color: #fff; } -.file.ok .name, .file.err .name { color: #fff; } - -.badge.companion { - flex: none; - display: inline-flex; - align-items: center; - padding: 0.15rem 0.35rem; - border-radius: 5px; - background: rgba(59, 130, 246, 0.18); - color: #7fb3ff; -} - -.file.ok .badge.companion, -.file.err .badge.companion, -.file.working .badge.companion { - background: rgba(255, 255, 255, 0.15); - color: #fff; -} - -.file .status { - flex: 1; - text-align: right; - font-size: 0.85rem; - color: var(--dim); - overflow-wrap: anywhere; -} - -.file.ok .status { color: #cfe9d5; } -.file.ok.suspect .status { color: #f0e3b2; } -.file.err .status { color: #f0c8c8; } -.file.working .status { color: #dbe7ff; } - -.file .dl { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.9rem; - height: 1.9rem; - border-radius: 6px; - background: rgba(255, 255, 255, 0.16); - color: #fff; - transition: background 0.15s, transform 0.15s; - animation: popIn 0.3s cubic-bezier(0.2, 1.4, 0.4, 1); -} - -.file .dl:hover { - background: rgba(255, 255, 255, 0.32); - transform: scale(1.1); -} - -.file .remove { - flex: none; - padding: 0.1rem 0.55rem; - background: transparent; - border: 1px solid var(--border); - border-radius: 6px; - color: var(--dim); - font-weight: 400; -} - -.file .remove:hover { - color: var(--err-bright); - border-color: var(--err-bright); - box-shadow: none; - transform: none; -} - -.file.ok .remove, -.file.err .remove, -.file.working .remove { - border-color: rgba(255, 255, 255, 0.3); - color: rgba(255, 255, 255, 0.75); -} - -.file.ok .remove:hover, -.file.err .remove:hover { - color: #fff; - border-color: #fff; -} - -.actions { - display: flex; - gap: 0.6rem; - animation: fadeIn 0.25s ease-out; -} - -footer { - margin-top: 2rem; - color: var(--dim); - font-size: 0.85rem; -} -footer a { color: var(--dim); } - -/* --- animations --- */ - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes popIn { - from { opacity: 0; transform: scale(0.85); } - to { opacity: 1; transform: scale(1); } -} - -@keyframes slideIn { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes shimmer { - from { background-position: 0 0; } - to { background-position: -200% 0; } -} - -@keyframes pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(232, 182, 76, 0.25); } - 50% { box-shadow: 0 0 0 6px rgba(232, 182, 76, 0); } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} diff --git a/web/worker.js b/web/worker.js deleted file mode 100644 index 439d0a6..0000000 --- a/web/worker.js +++ /dev/null @@ -1,41 +0,0 @@ -// One-shot unpack worker: each unpack runs in a fresh worker with its own -// wasm instance. Two reasons: -// -// 1. UI stays responsive — unpacking a 100 MB+ module blocks for seconds. -// 2. Trap isolation — the senbei DLL-first routing probe relies on -// catch_unwind to reject EXE-shell-layout DLLs, and panics cannot be -// caught in WebAssembly: the probe traps the whole call. A trap kills -// this worker's message handler, which the main thread observes and -// retries with the forced-EXE pipeline in a NEW worker (the trapped -// instance is never reused). That reproduces the CLI's -// dll-first/exe-fallback routing without a catchable panic. - -import init, { unpack_file, unpack_file_force_exe } from './pkg/senbei_web.js'; - -let ready = null; - -self.onmessage = async (e) => { - const { input, companion, forceExe } = e.data; - try { - ready ??= init(); - await ready; - const r = forceExe - ? unpack_file_force_exe(input, companion ?? undefined) - : unpack_file(input, companion ?? undefined); - const bytes = r.bytes; - self.postMessage( - { - ok: true, - kind: r.kind, - suspect: r.suspect, - issues: r.issues, - companion: r.companion, - bytes, - }, - [bytes.buffer], - ); - } catch (err) { - const trap = err instanceof WebAssembly.RuntimeError; - self.postMessage({ ok: false, trap, message: String(err?.message ?? err) }); - } -};