commit 21cd151e15304cf5e3dd8dccc65d71b4ce2f4dd0 Author: 綾瀬桃桃 Date: Sun Aug 9 00:08:31 2026 +0800 First public commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..10d65d1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Default: detect text, store LF, check out LF. +* text=auto eol=lf + +# Explicit text types (defense in depth over the auto detector). +*.rs text eol=lf diff=rust +*.toml text eol=lf diff=toml +*.lock text eol=lf -diff +*.md text eol=lf diff=markdown linguist-detectable +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.js text eol=lf +*.css text eol=lf diff=css +*.html text eol=lf diff=html +*.svg text eol=lf +*.txt text eol=lf +.gitignore text eol=lf +.gitattributes text eol=lf +LICENSE text eol=lf + +# Shell scripts must stay LF even on Windows checkouts. +*.sh text eol=lf + +# Windows-native scripts. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binaries — never subject to line-ending conversion. +*.exe binary +*.dll binary +*.sys binary +*.pdb binary +*.wasm binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary +*.zip binary +*.7z binary +*.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 new file mode 100644 index 0000000..2bddcf0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..613aacb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..65d0959 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +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 new file mode 100644 index 0000000..ce1c71c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000..7285ac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +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). + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### senbei test corpus ### +# The samples folder holds local-only Crackproof binaries; only its README is +# tracked. Ignore the folder's contents (not the folder itself, or git won't +# 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 new file mode 100644 index 0000000..55d7781 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# 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 new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8fb6378 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,494 @@ +# 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 = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[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 = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[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 = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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", + "tempfile", + "thiserror", + "walkdir", + "windows", +] + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eba1196 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "senbei" +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] +anyhow = "1" +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" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fe6b903 --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + 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/README.md b/README.md new file mode 100644 index 0000000..3c2215b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# 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 new file mode 100644 index 0000000..7dd6799 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,137 @@ +# 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::Corrupt`). 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. + +## 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::Corrupt`, with the default panic hook transiently suppressed. +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 new file mode 100644 index 0000000..0dbaad8 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,116 @@ +# 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 new file mode 100644 index 0000000..9bfcc3f --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,126 @@ +# 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 new file mode 100644 index 0000000..f45a9a0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"] diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..f6f0de3 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,84 @@ +# 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 directly in this folder: + +- `*.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. + +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/src/job.rs b/src/job.rs new file mode 100644 index 0000000..5d3d22d --- /dev/null +++ b/src/job.rs @@ -0,0 +1,1051 @@ +use crate::unpacker; +use std::path::{Path, PathBuf}; + +/// Crackproof header key table lives at this fixed file offset. For the +/// external-companion layout, the companion payload aligns to the stub here. +const HEADER_OFF: usize = 4096; + +/// Build the unpacker input for `input`, transparently handling the +/// **external-companion** layout used by some il2cpp games. +/// +/// In that layout a protected module is split into a thin on-disk loader stub +/// (`Foo.dll`, whose code sections are stripped to one page) plus an encrypted +/// `Foo.dll._` companion holding the real payload. The companion is byte-for-byte +/// the stub's payload region starting at the Crackproof header (offset 4096), so +/// `stub[..4096] ++ companion` reconstructs the ordinary embedded-payload file +/// the existing pipelines already unpack. The runtime loader does exactly this: +/// it maps `Foo.dll._` and feeds it through the standard Crackproof unpack. +/// +/// The splice fires only when a sibling `._` exists *and* its first 32 +/// bytes equal the stub's header at offset 4096 — a precise signal that the +/// companion is this stub's payload. Otherwise the file is returned untouched, +/// so normal (embedded-payload) inputs are unaffected. +fn read_unpacker_input(input: &Path) -> std::io::Result { + let stub = std::fs::read(input)?; + + // Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._). + let companion = match input.file_name() { + Some(name) => { + let mut n = name.to_os_string(); + n.push("._"); + input.with_file_name(n) + } + None => { + return Ok(UnpackerInput { + bytes: stub, + stub: None, + }); + } + }; + if !companion.is_file() { + return Ok(UnpackerInput { + bytes: stub, + stub: None, + }); + } + let comp = std::fs::read(&companion)?; + match splice_companion(&stub, &comp) { + // A splice fired: keep the stub so its plaintext export table can be + // overlaid onto the unpacked image (the companion does not carry it). + Some(spliced) => Ok(UnpackerInput { + bytes: spliced, + stub: Some(stub), + }), + None => Ok(UnpackerInput { + bytes: stub, + stub: None, + }), + } +} + +/// The bytes fed to the unpacker, plus the original loader stub when the input +/// was reconstructed from an external companion. The stub is retained because +/// the crackproof loader rebuilds the PE export table at runtime from data kept +/// in the stub — that table is *not* present in the encrypted companion, so the +/// unpacked image needs it overlaid from the stub afterwards +/// (see [`overlay_exports_from_stub`]). +struct UnpackerInput { + bytes: Vec, + stub: Option>, +} + +/// Overlay the PE export table from the loader `stub` onto the unpacked image +/// `out`, for the external-companion layout. +/// +/// In that layout the encrypted companion carries the real `.text`/`il2cpp` +/// payload but **not** a usable export directory: the crackproof loader rebuilds +/// exports at runtime from the plaintext copy retained in the stub's `.rdata`. +/// Statically, the spliced input therefore decrypts to a garbage export +/// directory (`NumberOfFunctions` etc. are ciphertext), which makes downstream +/// tools (IL2CppDumper, IDA) choke when they parse it. The fix does what the +/// loader does: copy the export-directory region byte-for-byte from the stub to +/// the same RVA in the unpacked image. +/// +/// No-op (leaves `out` untouched) if there is no export directory, or if the +/// region cannot be mapped in either image — so a malformed stub can never +/// corrupt an otherwise-good unpack. +fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) { + let (export_rva, export_size) = match pe_export_dir(out) { + Some(v) if v.1 != 0 => v, + _ => return, + }; + let dst = match rva_to_file_off(out, export_rva) { + Some(o) => o, + None => return, + }; + let src = match rva_to_file_off(stub, export_rva) { + Some(o) => o, + None => return, + }; + let n = export_size as usize; + if dst + n <= out.len() && src + n <= stub.len() { + out[dst..dst + n].copy_from_slice(&stub[src..src + n]); + } +} + +/// Restore the TLS directory from the loader `stub` onto the unpacked image +/// `out`, for the external-companion layout. +/// +/// Crackproof strips the whole `IMAGE_TLS_DIRECTORY` from the encrypted payload +/// — the data-directory entry, the directory struct, the raw-data template, and +/// the base relocations for the struct's four 64-bit pointer fields — and +/// re-installs TLS itself from data kept in the stub when it loads the module. +/// A statically-unpacked DLL is loaded by the ordinary Windows loader instead, +/// which needs a valid TLS directory or it never allocates a TLS slot for the +/// module nor writes `_tls_index`. The module's C++ `thread_local` accesses then +/// read a garbage TLS slot — observed as a `0xC0000005` deep in IL2CPP type +/// resolution (a TypeDef token used as a raw `s_TypeInfoTable` index). +/// +/// The stub retains the full plaintext `.rdata` (only `.text`/`il2cpp` are +/// stripped to one page), so the directory struct and its raw-data template are +/// copied back byte-for-byte at their RVAs, the data-directory entry is taken +/// from the stub header (the unpacked image's was overwritten with the zeroed +/// saved-header blob), and four DIR64 relocations are appended to `.reloc`. +/// +/// No-op if the stub declares no TLS directory or if any required region cannot +/// be mapped/relocated — so it can never corrupt an otherwise-good unpack. +fn restore_tls_from_stub(out: &mut [u8], stub: &[u8]) { + let pe = match read_u32(out, 0x3C) { + Some(v) => v as usize, + None => return, + }; + if out.get(pe..pe + 4) != Some(&b"PE\0\0"[..]) { + return; + } + // This restore is PE32+-only: it copies a 40-byte IMAGE_TLS_DIRECTORY64, + // converts fields with a 64-bit image base, and appends DIR64 relocs. A + // PE32 module needs the 24-byte struct / DIR32 handling (the unpacker core + // does that itself — see `restore_pe32_tls_from_stub`), so bail rather than + // read the data directories at the wrong (PE32+) offset and write garbage. + if read_u16(out, pe + 24) != Some(0x20B) { + return; + } + // TLS is data-directory index 9 (PE32+ directories at optional header +112). + let tls_dd = match pe.checked_add(24 + 112 + 9 * 8) { + Some(v) => v, + None => return, + }; + // The genuine entry survives in the stub header; the unpacked image's copy + // was clobbered by the (zeroed-TLS) saved-header blob. + let (tls_rva, tls_size) = match (read_u32(stub, tls_dd), read_u32(stub, tls_dd + 4)) { + (Some(r), Some(s)) if r != 0 && s != 0 => (r, s), + _ => return, // module has no TLS — nothing to restore + }; + // Image base (PE32+, optional header +24) converts the struct's absolute VAs + // back to RVAs for the raw-data template overlay. + let image_base = match read_u64(out, pe + 24 + 24) { + Some(v) => v, + None => return, + }; + + // 1) Overlay the IMAGE_TLS_DIRECTORY struct from the stub at its RVA. + let dst = match rva_to_file_off(out, tls_rva) { + Some(o) => o, + None => return, + }; + let src = match rva_to_file_off(stub, tls_rva) { + Some(o) => o, + None => return, + }; + let n = tls_size as usize; + if dst.checked_add(n).is_none_or(|e| e > out.len()) + || src.checked_add(n).is_none_or(|e| e > stub.len()) + { + return; + } + out[dst..dst + n].copy_from_slice(&stub[src..src + n]); + + // 2) Restore the data-directory entry so the loader processes TLS at all. + write_u32_at(out, tls_dd, tls_rva); + write_u32_at(out, tls_dd + 4, tls_size); + + // 3) Overlay the raw-data template [StartAddressOfRawData, EndAddressOfRawData). + if let (Some(start_va), Some(end_va)) = (read_u64(out, dst), read_u64(out, dst + 8)) + && end_va > start_va + && start_va >= image_base + { + let tpl_rva = (start_va - image_base) as u32; + let tpl_len = (end_va - start_va) as usize; + if let (Some(td), Some(ts)) = ( + rva_to_file_off(out, tpl_rva), + rva_to_file_off(stub, tpl_rva), + ) && td.checked_add(tpl_len).is_some_and(|e| e <= out.len()) + && ts.checked_add(tpl_len).is_some_and(|e| e <= stub.len()) + { + out[td..td + tpl_len].copy_from_slice(&stub[ts..ts + tpl_len]); + } + } + + // 4) Append DIR64 relocations for the struct's four 64-bit pointer fields + // (Start/End/Index/CallBacks at +0/+8/+0x10/+0x18). Without them the + // loader would leave preferred-base VAs in a rebased image. + add_tls_relocs(out, pe, tls_rva); +} + +/// Append a single base-relocation block covering the four 64-bit pointer fields +/// of the TLS directory struct at `tls_rva`. The block is written immediately +/// after the existing relocation table (which must be free space and in bounds) +/// and the BaseReloc directory size is grown to include it. No-op if the table +/// is absent, the fields straddle a relocation page, or the slot is not free. +fn add_tls_relocs(out: &mut [u8], pe: usize, tls_rva: u32) { + let reloc_dd = pe + 24 + 112 + 5 * 8; // BaseReloc = directory index 5 + let (reloc_rva, reloc_size) = match (read_u32(out, reloc_dd), read_u32(out, reloc_dd + 4)) { + (Some(r), Some(s)) if r != 0 => (r, s), + _ => return, + }; + // All four fields (last at +0x18) must share one 0x1000 relocation page. + let page = tls_rva & !0xFFF; + if (tls_rva.wrapping_add(0x18)) & !0xFFF != page { + return; + } + const BLOCK: usize = 8 + 4 * 2; // header + four DIR64 entries + let at = match rva_to_file_off(out, reloc_rva.wrapping_add(reloc_size)) { + Some(o) => o, + None => return, + }; + if at.checked_add(BLOCK).is_none_or(|e| e > out.len()) { + return; + } + if out[at..at + BLOCK].iter().any(|&b| b != 0) { + return; // refuse to clobber existing data + } + write_u32_at(out, at, page); + write_u32_at(out, at + 4, BLOCK as u32); + for (i, off) in [0u32, 8, 0x10, 0x18].iter().enumerate() { + let entry = (10u16 << 12) | (((tls_rva.wrapping_add(*off)) & 0xFFF) as u16); + let p = at + 8 + i * 2; + out[p..p + 2].copy_from_slice(&entry.to_le_bytes()); + } + write_u32_at(out, reloc_dd + 4, reloc_size.wrapping_add(BLOCK as u32)); +} + +/// Read the Export data-directory (RVA, size) from a PE image, or `None` if the +/// headers are too short/invalid to parse. +fn pe_export_dir(buf: &[u8]) -> Option<(u32, u32)> { + let pe = read_u32(buf, 0x3C)? as usize; + if buf.get(pe..pe + 4)? != b"PE\0\0" { + return None; + } + // Optional header at pe+24; data directories start at +96 on PE32 (0x10B) + // and +112 on PE32+ (0x20B); Export is index 0. + let dd_base = match read_u16(buf, pe + 24)? { + 0x20B => 112, + 0x10B => 96, + _ => return None, + }; + let dd = pe.checked_add(24 + dd_base)?; + Some((read_u32(buf, dd)?, read_u32(buf, dd + 4)?)) +} + +/// Map an RVA to a file offset using the PE section table. Returns `None` if no +/// section contains the RVA or the headers cannot be parsed. +fn rva_to_file_off(buf: &[u8], rva: u32) -> Option { + let pe = read_u32(buf, 0x3C)? as usize; + if buf.get(pe..pe + 4)? != b"PE\0\0" { + return None; + } + let nsec = read_u16(buf, pe + 6)? as usize; + let opt_size = read_u16(buf, pe + 20)? as usize; + let sh = pe.checked_add(24)?.checked_add(opt_size)?; + for i in 0..nsec { + let o = sh.checked_add(i.checked_mul(40)?)?; + let vsz = read_u32(buf, o + 8)?; + let va = read_u32(buf, o + 12)?; + let raw = read_u32(buf, o + 20)?; + if rva >= va && rva < va.wrapping_add(vsz.max(1)) { + return Some((rva - va).wrapping_add(raw) as usize); + } + } + None +} + +fn read_u32(buf: &[u8], off: usize) -> Option { + let b = buf.get(off..off + 4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) +} + +fn read_u16(buf: &[u8], off: usize) -> Option { + let b = buf.get(off..off + 2)?; + Some(u16::from_le_bytes([b[0], b[1]])) +} + +fn read_u64(buf: &[u8], off: usize) -> Option { + let b = buf.get(off..off + 8)?; + Some(u64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) +} + +/// Write a little-endian `u32` at `off`, silently doing nothing if out of bounds. +fn write_u32_at(buf: &mut [u8], off: usize, val: u32) { + if let Some(slot) = buf.get_mut(off..off + 4) { + slot.copy_from_slice(&val.to_le_bytes()); + } +} + +/// Splice a stub and its external-companion payload into the embedded-payload +/// form the pipelines expect, or `None` if `comp` is not this stub's payload. +/// +/// The companion is byte-for-byte the stub's payload region from the Crackproof +/// header (offset 4096) onward, so the result is `stub[..4096] ++ comp`. The +/// splice fires only when the first 32 bytes of `comp` equal the stub's header +/// at offset 4096 — a 32-byte match on the key-table/magic region that confirms +/// the pairing and leaves ordinary (non-companion) inputs untouched. +fn splice_companion(stub: &[u8], comp: &[u8]) -> Option> { + let hdr_end = HEADER_OFF + 32; + if stub.len() >= hdr_end && comp.len() >= 32 && stub[HEADER_OFF..hdr_end] == comp[..32] { + let mut spliced = Vec::with_capacity(HEADER_OFF + comp.len()); + spliced.extend_from_slice(&stub[..HEADER_OFF]); + spliced.extend_from_slice(comp); + return Some(spliced); + } + None +} + +/// Summary of a folder-mode run. +pub struct Summary { + pub unpacked: usize, + pub skipped: usize, + pub errors: usize, + /// Files that unpacked without error but failed the static integrity check + /// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to + /// `unpacked` (a suspect file is still written). + pub suspect: usize, + /// il2cpp `global-metadata.dat` files de-obfuscated (method tokens remapped). + pub metadata: usize, + /// Wall-clock duration of the folder run in milliseconds. + pub duration_ms: u128, +} + +/// Default output root for a folder unpack: `/unpack`. +pub fn default_out_root_for_folder(root: &Path) -> PathBuf { + root.join("unpack") +} + +/// Default output root for a single-file unpack: `/unpack` (or `./unpack` +/// when the input has no parent directory). +pub fn default_out_root_for_file(input: &Path) -> PathBuf { + let parent = input + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + parent.join("unpack") +} + +/// Unpack all Crackproof-protected files under `root`, writing results into +/// a mirrored subtree under `out_dir` (or `root/unpack` if None). +/// +/// Each file is processed independently: a panic or error in one file is +/// isolated and counted as an error; the loop continues. +pub fn run_folder(root: &Path, out_dir: Option<&Path>, quiet: bool) -> anyhow::Result { + run_folder_v(root, out_dir, if quiet { 1 } else { 0 }, false, false) +} + +/// Like [`run_folder`], but prints detailed `[N/9]` step progress (and a final +/// `Write to ` line) for each EXE when `verbose` is true. +/// +/// `quiet` is a level: `>= 1` suppresses per-file UI lines and the progress bar. +/// When `no_log` is true, no `senbei-*.log` is created under the out root. +pub fn run_folder_v( + root: &Path, + out_dir: Option<&Path>, + quiet: u8, + verbose: bool, + no_log: bool, +) -> anyhow::Result { + run_folder_opts( + root, + out_dir, + quiet, + verbose, + no_log, + crate::scan::scan_all_env(), + ) +} + +/// Like [`run_folder_v`], but with the scan pre-filter explicitly controlled. +/// +/// When `scan_all` is true every regular file under `root` is opened and +/// content-probed, instead of skipping ones the free directory metadata already +/// rules out (too small to hold a Crackproof key table, or a bulk-asset +/// extension). See [`crate::scan::find_targets_opts`] — exhaustive scanning is +/// dramatically slower on asset-heavy game trees and finds the same targets. +pub fn run_folder_opts( + root: &Path, + out_dir: Option<&Path>, + quiet: u8, + verbose: bool, + no_log: bool, + scan_all: bool, +) -> anyhow::Result { + let t0 = std::time::Instant::now(); + let out_root = out_dir + .map(Path::to_path_buf) + .unwrap_or_else(|| default_out_root_for_folder(root)); + std::fs::create_dir_all(&out_root)?; + let log = if no_log { + None + } else { + let log = crate::logfile::Log::create(&out_root)?; + log.step(&format!("Senbei {}", env!("CARGO_PKG_VERSION"))); + log.step(&format!( + "started {}", + crate::logfile::local_stamp_display() + )); + log.step(&format!("input {}", root.display())); + log.step(&format!("out {}", out_root.display())); + Some(log) + }; + // Single merged directory walk: returns Crackproof unpack candidates and + // il2cpp metadata blobs from one traversal (see + // [`crate::scan::find_targets_opts`]). Files the free directory metadata + // already rules out are never opened — on asset-heavy trees the per-file + // open+read latency, not the traversal, is the whole cost. + let (candidates, metas, scan_stats) = crate::scan::find_targets_opts(root, scan_all); + // Files the scan could not classify are potential missed targets, not + // clean skips: an unreadable directory or a locked il2cpp game assembly must + // fail the run (exit 1) rather than report "0 errors" over a partial scan. + let scan_failed = scan_stats.walk_errors + scan_stats.probe_errors; + if scan_failed > 0 && quiet == 0 { + eprintln!( + "warning: {} file(s) could not be read during the scan and may be missed targets", + scan_failed + ); + } + if let Some(log) = &log { + if scan_stats.walk_errors > 0 { + log.step(&format!( + "scan: {} directory entry(s) unreadable", + scan_stats.walk_errors + )); + } + if scan_stats.probe_errors > 0 { + log.step(&format!( + "scan: {} file(s) failed content probe (unreadable or detector panic)", + scan_stats.probe_errors + )); + } + } + let suppress_file_lines = quiet >= 1; + // Quiet wins over verbose: step progress only when quiet == 0 (spec: verbose + // lines only when quiet == 0; quiet ≥ 2 must stay fully silent even with -v). + let verbose_steps = verbose && quiet == 0; + // Verbose mode prints multi-line `[N/9]` step output per file straight to + // stdout; an active progress bar would be clobbered by it, so hide the bar + // (its per-file ok/err lines still print) when verbose is on. + let bar = crate::ui::progress(candidates.len() as u64, quiet >= 1 || verbose); + let mut s = Summary { + unpacked: 0, + skipped: scan_stats.skipped, + errors: scan_failed, + suspect: 0, + metadata: 0, + duration_ms: 0, + }; + + // Silence the default panic hook's stderr spew during per-file processing. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); // suppress "thread panicked" messages + + for input in &candidates { + let rel = rel_in_tree(root, input); + let dest = out_root.join(out_name(&rel)); + + // Wrap in catch_unwind so a single bad file never aborts the folder run. + let input_owned = input.clone(); + let dest_owned = dest.clone(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unpack_one_v(&input_owned, &dest_owned, verbose_steps) + })); + + match result { + Ok(Ok((kind, report))) => { + s.unpacked += 1; + crate::ui::ok(&bar, suppress_file_lines, &rel, kind, &dest); + if let Some(log) = &log { + log.step(&format!("OK {rel:?} -> {dest:?} ({kind:?})")); + } + if !report.ok() { + s.suspect += 1; + crate::ui::suspect(&bar, suppress_file_lines, &rel, &report); + if let Some(log) = &log { + log.step(&format!("SUSPECT {rel:?}: {}", report.issues.join("; "))); + } + } + } + Ok(Err(e)) => { + s.errors += 1; + crate::ui::err(&bar, suppress_file_lines, &rel, &e); + if let Some(log) = &log { + log.step(&format!("ERR {rel:?}: {e:#}")); + } + } + Err(panic) => { + s.errors += 1; + let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic)); + crate::ui::err(&bar, suppress_file_lines, &rel, &e); + if let Some(log) = &log { + log.step(&format!( + "ERR {rel:?}: panic during unpack: {}", + panic_payload(&panic) + )); + } + } + } + bar.inc(1); + } + + // 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 + // Crackproof module unpack above — the metadata blob is not itself a + // Crackproof file. + for meta in metas { + let rel = rel_in_tree(root, &meta); + let dest = out_root.join(out_name(&rel)); + let meta_owned = meta.clone(); + let dest_owned = dest.clone(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + deobfuscate_metadata_to(&meta_owned, &dest_owned, verbose_steps) + })); + match result { + Ok(Ok(report)) if report.remapped > 0 => { + s.metadata += 1; + crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest); + if let Some(log) = &log { + log.step(&format!( + "META {rel:?} -> {dest:?}: v{} remapped {} method tokens", + report.version, report.remapped + )); + } + } + // Recognised metadata that needed no change (not -GMD-obfuscated): + // leave it untouched and don't write a redundant copy. + Ok(Ok(report)) => { + if let Some(log) = &log { + log.step(&format!( + "META {rel:?}: v{} already de-obfuscated", + report.version + )); + } + } + Ok(Err(e)) => { + // A metadata version we don't handle is NOT a run failure: the + // game is simply not -GMD-obfuscated in a layout we know, the + // file is left untouched, and the PE unpacks around it may be + // fully successful. Count it as skipped (with a visible note), + // matching the "anything that doesn't match is left untouched" + // contract. Genuine corruption (Malformed) stays an error — + // silently exiting 0 would let a failed de-obfuscation pass CI + // while the il2cpp game assembly still crashes. + if let Some(v) = unsupported_version(&e) { + s.skipped += 1; + if !suppress_file_lines { + eprintln!( + "- {} unsupported metadata version {v}, left untouched", + rel.display() + ); + } + if let Some(log) = &log { + log.step(&format!( + "META SKIP {rel:?}: unsupported metadata version {v}" + )); + } + } else { + s.errors += 1; + crate::ui::err(&bar, suppress_file_lines, &rel, &e); + if let Some(log) = &log { + log.step(&format!("META ERR {rel:?}: {e:#}")); + } + } + } + Err(panic) => { + s.errors += 1; + let e = anyhow::anyhow!( + "unexpected panic during de-obfuscation: {}", + panic_payload(&panic) + ); + crate::ui::err(&bar, suppress_file_lines, &rel, &e); + if let Some(log) = &log { + log.step(&format!( + "META ERR {rel:?}: panic during de-obfuscation: {}", + panic_payload(&panic) + )); + } + } + } + } + + // Restore the original panic hook. + std::panic::set_hook(default_hook); + + bar.finish_and_clear(); + s.duration_ms = t0.elapsed().as_millis(); + if let Some(log) = &log { + log.step(&format!("done in {} ms", s.duration_ms)); + log.step(&format!( + "summary: {} unpacked · {} skipped · {} errors · {} suspect · {} metadata", + s.unpacked, s.skipped, s.errors, s.suspect, s.metadata + )); + } + Ok(s) +} + +/// Single-file (PE or metadata) with the same log/header/footer/timing as folder mode. +/// +/// Always returns `Ok(Summary)` for per-file unpack outcomes (including failures, +/// which set `errors: 1`) so callers always receive `duration_ms`. Fatal `Err` +/// only when the out dir / log cannot be created. +pub fn run_file_v( + input: &Path, + out_dir: Option<&Path>, + quiet: u8, + verbose: bool, + no_log: bool, +) -> anyhow::Result { + let t0 = std::time::Instant::now(); + let out_root = out_dir + .map(Path::to_path_buf) + .unwrap_or_else(|| default_out_root_for_file(input)); + std::fs::create_dir_all(&out_root)?; + + let log = if no_log { + None + } else { + let log = crate::logfile::Log::create(&out_root)?; + log.step(&format!("Senbei {}", env!("CARGO_PKG_VERSION"))); + log.step(&format!( + "started {}", + crate::logfile::local_stamp_display() + )); + log.step(&format!("input {}", input.display())); + log.step(&format!("out {}", out_root.display())); + Some(log) + }; + + let name = out_name(Path::new(input.file_name().unwrap_or_default())); + let dest = out_root.join(name); + let mut s = Summary { + unpacked: 0, + skipped: 0, + errors: 0, + suspect: 0, + metadata: 0, + duration_ms: 0, + }; + + let is_meta = { + use std::io::Read; + 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)) + .unwrap_or(false) + }; + + if is_meta { + match deobfuscate_metadata_to(input, &dest, verbose && quiet == 0) { + Ok(report) if report.remapped > 0 => { + s.metadata = 1; + if let Some(log) = &log { + log.step(&format!( + "META {:?} -> {:?}: v{} remapped {} method tokens", + input, dest, report.version, report.remapped + )); + } + if quiet == 0 { + println!( + "✓ metadata v{} -> {:?} ({} method tokens remapped)", + report.version, dest, report.remapped + ); + } + } + Ok(report) => { + if let Some(log) = &log { + log.step(&format!( + "META {:?}: v{} already de-obfuscated", + input, report.version + )); + } + if quiet == 0 { + println!( + "metadata v{}: already de-obfuscated, nothing to do", + report.version + ); + } + } + Err(e) => { + s.errors = 1; + if let Some(log) = &log { + log.step(&format!("META ERR {:?}: {e:#}", input)); + } + // Level 1 quiet: banner/summary/duration only (match folder mode). + if quiet == 0 { + eprintln!("error: {e:#}"); + } + } + } + } else { + match unpack_one_v(input, &dest, verbose && quiet == 0) { + Ok((kind, report)) => { + s.unpacked = 1; + if let Some(log) = &log { + log.step(&format!("OK {:?} -> {:?} ({kind:?})", input, dest)); + } + if quiet == 0 { + println!("✓ {:?} -> {:?}", kind, dest); + } + if !report.ok() { + s.suspect = 1; + if let Some(log) = &log { + log.step(&format!( + "SUSPECT {:?}: {}", + input, + report.issues.join("; ") + )); + } + if quiet == 0 { + eprintln!( + "! integrity check failed (likely to crash at runtime): {}", + report.issues.join("; ") + ); + } + } + } + Err(e) => { + s.errors = 1; + if let Some(log) = &log { + log.step(&format!("ERR {:?}: {e:#}", input)); + } + if quiet == 0 { + eprintln!("error: {e:#}"); + } + } + } + } + + s.duration_ms = t0.elapsed().as_millis(); + if let Some(log) = &log { + log.step(&format!("done in {} ms", s.duration_ms)); + log.step(&format!( + "summary: {} unpacked · {} skipped · {} errors · {} suspect · {} metadata", + s.unpacked, s.skipped, s.errors, s.suspect, s.metadata + )); + } + Ok(s) +} + +/// Path of `p` relative to `root`, for mirroring into the output tree. +/// +/// Falls back to just the file name when `p` is not under `root` (e.g. a +/// `\\?\`-prefixed root against plain candidate paths): `Path::join` with an +/// *absolute* path replaces the output root outright, which would write the +/// output back over the source tree instead of under `--out`. +fn rel_in_tree<'a>(root: &Path, p: &'a Path) -> std::borrow::Cow<'a, Path> { + match p.strip_prefix(root) { + Ok(rel) => std::borrow::Cow::Borrowed(rel), + Err(_) => std::borrow::Cow::Owned(PathBuf::from(p.file_name().unwrap_or_default())), + } +} + +/// Insert `.unpack` before the last dot in the **file name**, preserving any +/// parent directories. If the file name has no dot, append `.unpack`. +/// +/// The dot search is scoped to the file-name component only: a relative path +/// like `v1.2/launcher` (dotted directory, extension-less file) must become +/// `v1.2/launcher.unpack`, not `v1.unpack.2/launcher`. +pub fn out_name(input: &Path) -> PathBuf { + let file = input + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let renamed = match file.rfind('.') { + Some(i) => format!("{}.unpack{}", &file[..i], &file[i..]), + None => format!("{file}.unpack"), + }; + match input.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(renamed), + _ => PathBuf::from(renamed), + } +} + +/// Extract a printable message from a caught panic payload. +fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = panic.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = panic.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// If `e`'s chain contains [`crate::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::() + { + return Some(*v); + } + } + None +} + +/// Write `bytes` to `dest` atomically: a sibling temp file, then a rename. +/// A direct `std::fs::write` truncates the destination first, so a mid-write +/// failure (disk full, AV lock, quota) destroys a previously good unpack at +/// the same path; the temp+rename keeps the old file until the new one is +/// complete. Best-effort temp cleanup on failure. +fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut tmp_name = dest.as_os_str().to_os_string(); + tmp_name.push(".senbei-tmp"); + let tmp = PathBuf::from(tmp_name); + let r = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, dest)); + if r.is_err() { + let _ = std::fs::remove_file(&tmp); + } + 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`]). +/// +/// 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. +fn unpack_spliced_or_auto( + bytes: &[u8], + spliced: bool, + force_exe: bool, + verbose: bool, +) -> Result<(unpacker::Kind, Vec), unpacker::UnpackError> { + if spliced || force_exe { + let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?; + let out = unpacker::unpack_exe_v(bytes, verbose)?; + return Ok((detected.kind, out)); + } + unpacker::unpack_auto_v(bytes, verbose) +} + +/// Unpack a single file to `dest`. Returns the Kind and integrity report on success. +pub fn unpack_one( + input: &Path, + dest: &Path, +) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> { + unpack_one_v(input, dest, false) +} + +/// Outcome of a byte-level unpack ([`unpack_bytes`]): the image, its detected +/// kind, and its integrity report. No file I/O is involved. +pub struct UnpackedImage { + pub kind: unpacker::Kind, + pub bytes: Vec, + pub integrity: unpacker::IntegrityReport, + /// True when the input was reconstructed from an external companion (the + /// `._` layout), i.e. the export/TLS overlays ran. + pub companion: bool, +} + +/// 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. +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) +} + +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)?; + if spliced.is_some() { + overlay_exports_from_stub(&mut out, input); + restore_tls_from_stub(&mut out, input); + } + let integrity = unpacker::check_integrity(&out); + Ok(UnpackedImage { + kind, + bytes: out, + integrity, + companion: spliced.is_some(), + }) +} + +/// Like [`unpack_one`], but prints detailed `[N/9]` step progress (and a final +/// `Write to ` line) to stdout when `verbose` is true. +pub fn unpack_one_v( + input: &Path, + dest: &Path, + 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)?; + // 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 { + overlay_exports_from_stub(&mut out, &stub); + // ...and the TLS directory, which Crackproof strips from the payload and + // re-installs at runtime; the ordinary loader needs it or thread_local + // access crashes (see [`restore_tls_from_stub`]). + restore_tls_from_stub(&mut out, &stub); + } + let report = unpacker::check_integrity(&out); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + write_atomic(dest, &out)?; + if verbose { + println!("Write to {}", dest.display()); + } + Ok((kind, report)) +} + +/// De-obfuscate an il2cpp `global-metadata.dat` to `dest`. +/// +/// Crackproof's `-GMD` option scrambles each `Il2CppMethodDefinition`'s token +/// 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 output is written only when something actually changed +/// (`report.remapped > 0`); an already-clean metadata is left untouched and no +/// redundant copy is produced. Returns the [`metadata::Report`] either way so +/// the caller can report what happened. +pub fn deobfuscate_metadata_to( + input: &Path, + dest: &Path, + verbose: bool, +) -> 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) + .map_err(|e| anyhow::Error::new(e).context(format!("{input:?}")))?; + if report.remapped > 0 { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + write_atomic(dest, &out)?; + if verbose { + println!("Write to {}", dest.display()); + } + } + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Review regression: when the candidate path is not under `root` (e.g. a + /// `\\?\`-prefixed root against plain walk paths), the output name must + /// fall back to the bare file name — joining the absolute path would + /// replace the output root and write back over the source tree. + #[test] + fn rel_in_tree_falls_back_to_file_name_outside_root() { + let root = Path::new(r"D:\out-of-tree-root"); + let abs = Path::new(r"C:\game\bin\app.exe"); + let rel = rel_in_tree(root, abs); + assert_eq!(rel.as_ref(), Path::new("app.exe")); + + // And the normal case still preserves the tree structure. + let under = Path::new(r"D:\out-of-tree-root\bin\app.exe"); + let rel = rel_in_tree(root, under); + assert_eq!(rel.as_ref(), Path::new(r"bin\app.exe")); + } + + fn stub_with_header(header: &[u8; 32], extra: usize) -> Vec { + let mut s = vec![0u8; HEADER_OFF]; + s.extend_from_slice(header); + s.extend_from_slice(&vec![0xAAu8; extra]); + s + } + + #[test] + fn splices_when_header_matches() { + let header = [7u8; 32]; + let stub = stub_with_header(&header, 16); + // Companion: same 32-byte header, then the real (longer) payload. + let mut comp = header.to_vec(); + comp.extend_from_slice(&[0x42u8; 1000]); + + let out = splice_companion(&stub, &comp).expect("should splice"); + assert_eq!(out.len(), HEADER_OFF + comp.len()); + assert_eq!(&out[..HEADER_OFF], &stub[..HEADER_OFF]); + assert_eq!(&out[HEADER_OFF..], &comp[..]); + } + + #[test] + fn no_splice_when_header_differs() { + let stub = stub_with_header(&[7u8; 32], 16); + let mut comp = vec![9u8; 32]; // different header + comp.extend_from_slice(&[0x42u8; 1000]); + assert!(splice_companion(&stub, &comp).is_none()); + } + + #[test] + fn no_splice_when_too_short() { + let short_stub = vec![0u8; HEADER_OFF + 8]; // < HEADER_OFF + 32 + let comp = vec![0u8; 64]; + assert!(splice_companion(&short_stub, &comp).is_none()); + + let stub = stub_with_header(&[1u8; 32], 0); + let short_comp = vec![1u8; 16]; // < 32 + assert!(splice_companion(&stub, &short_comp).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..270c372 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +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/src/logfile.rs new file mode 100644 index 0000000..7027520 --- /dev/null +++ b/src/logfile.rs @@ -0,0 +1,148 @@ +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +pub struct Log { + path: PathBuf, + file: Mutex, +} + +impl Log { + pub fn create(dir: &Path) -> std::io::Result { + let ts = local_stamp_compact(); + // The stamp has one-second granularity and `File::create` truncates, so + // two runs into the same out dir within a second would clobber each + // other's log. Probe for a free name with create_new instead. + let mut path = dir.join(format!("senbei-{ts}.log")); + let mut file = File::create_new(&path); + for n in 2..100 { + if !matches!(&file, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists) { + break; + } + path = dir.join(format!("senbei-{ts}-{n}.log")); + file = File::create_new(&path); + } + let file = Mutex::new(file?); + Ok(Self { path, file }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn step(&self, msg: &str) { + if let Ok(mut f) = self.file.lock() { + let _ = writeln!(f, "{msg}"); + } + } +} + +/// Local wall-clock `YYYYMMDD-HHMMSS` for log filenames. +pub fn local_stamp_compact() -> String { + let t = local_parts(); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} + +/// Local wall-clock `YYYY-MM-DD HH:MM:SS` for log header. +pub fn local_stamp_display() -> String { + let t = local_parts(); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} + +struct LocalParts { + year: u32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, +} + +fn local_parts() -> LocalParts { + #[cfg(windows)] + { + use windows::Win32::System::SystemInformation::GetLocalTime; + let st = unsafe { GetLocalTime() }; + LocalParts { + year: st.wYear as u32, + month: st.wMonth as u32, + day: st.wDay as u32, + hour: st.wHour as u32, + minute: st.wMinute as u32, + second: st.wSecond as u32, + } + } + #[cfg(all(not(windows), not(target_arch = "wasm32")))] + { + // Local wall clock via POSIX localtime_r — same semantics as Windows GetLocalTime. + use std::time::{SystemTime, UNIX_EPOCH}; + let secs_u = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let t: libc::time_t = secs_u as libc::time_t; + let mut tm = unsafe { std::mem::zeroed::() }; + let ok = unsafe { libc::localtime_r(&t, &mut tm) }; + if ok.is_null() { + return utc_parts(secs_u); // emergency only if localtime_r fails + } + LocalParts { + year: (tm.tm_year + 1900) as u32, + month: (tm.tm_mon + 1) as u32, + day: tm.tm_mday as u32, + hour: tm.tm_hour as u32, + minute: tm.tm_min as u32, + 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). +/// Used as non-Windows fallback; keep pub(crate) if unit-tested. +fn utc_parts(secs: u64) -> LocalParts { + let s = secs as i64; + let time_of_day = s.rem_euclid(86400) as u32; + let days = s.div_euclid(86400); + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + LocalParts { + year: y as u32, + month: m, + day: d, + hour: time_of_day / 3600, + minute: (time_of_day % 3600) / 60, + second: time_of_day % 60, + } +} + +/// UTC civil stamp helper (used only as emergency fallback path via `utc_parts`). +#[allow(dead_code)] // retained for unit-style reuse / non-Windows emergency path symmetry +pub(crate) fn fmt_stamp(secs: u64) -> String { + let t = utc_parts(secs); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..da93dcc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,113 @@ +use senbei::{job, pause}; +use std::path::Path; + +fn main() -> std::process::ExitCode { + let mut args = std::env::args().skip(1); + let mut path: Option = None; + let mut out: Option = None; + let mut quiet: u8 = 0; + let mut no_pause = false; + let mut no_log = false; + let mut verbose = false; + let mut scan_all = false; + + while let Some(a) = args.next() { + match a.as_str() { + "-h" | "--help" => { + print_help(); + return std::process::ExitCode::SUCCESS; + } + "-V" | "--version" => { + println!("Senbei {}", env!("CARGO_PKG_VERSION")); + return std::process::ExitCode::SUCCESS; + } + "-q" | "--quiet" => quiet = quiet.saturating_add(1), + "-v" | "--verbose" => verbose = true, + "--no-pause" => no_pause = true, + "--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"); + return std::process::ExitCode::from(2); + } + }, + other if other.starts_with('-') => { + eprintln!("error: unknown option '{other}'"); + print_help(); + 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); + } + path = Some(other.to_string()); + } + } + } + + let code = match path { + None => { + print_help(); + 2 + } + Some(p) => { + if quiet < 2 { + println!("Senbei {}", env!("CARGO_PKG_VERSION")); + } + let p = Path::new(&p); + let out_path = out.as_deref().map(Path::new); + let r = if p.is_dir() { + job::run_folder_opts( + p, + out_path, + quiet, + verbose, + no_log, + scan_all || senbei::scan::scan_all_env(), + ) + } else { + job::run_file_v(p, out_path, quiet, verbose, no_log) + }; + match r { + Ok(s) => { + if quiet < 2 { + println!( + "{} unpacked · {} skipped · {} errors · {} suspect · {} metadata", + s.unpacked, s.skipped, s.errors, s.suspect, s.metadata + ); + println!("done in {} ms", s.duration_ms); + } + if s.errors > 0 { 1 } else { 0 } + } + Err(e) => { + // Fatal: out-dir/log create, etc. + if quiet < 2 { + eprintln!("error: {e:#}"); + } + 1 + } + } + } + }; + + pause::maybe_pause(no_pause); + std::process::ExitCode::from(code as u8) +} + +fn print_help() { + println!( + "senbei [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]" + ); + println!( + " --scan-all probe every file in a folder, including ones the scan\n\ + \x20 pre-filter skips (under 4128 bytes, or a bulk-asset\n\ + \x20 extension like .ab/.xml/.acb). Much slower on game trees." + ); +} diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 0000000..e8c87d9 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,348 @@ +//! il2cpp `global-metadata.dat` de-obfuscation. +//! +//! Crackproof's `-GMD` option obfuscates the **method-token** field of every +//! `Il2CppMethodDefinition` in `global-metadata.dat`. il2cpp resolves a method's +//! compiled function/invoker by indexing the per-module +//! `Il2CppCodeGenModule.methodPointers` / `invokerIndices` tables — which are +//! sized to the module's *compiled* method count — by `(token_row - 1)`. That +//! only works when each module's method tokens are the **contiguous** range +//! `1..=methodPointerCount`. `-GMD` replaces them with sparse, original-metadata-style +//! tokens (e.g. mscorlib rows reach ~55k for only ~14k compiled methods) and the +//! running game's Crackproof loader remaps them back at load time. +//! +//! A statically-unpacked il2cpp game assembly run without Crackproof reads the +//! tokens raw, so `(token_row - 1)` runs off the end of those tables — an +//! out-of-bounds read that crashes deep in il2cpp init (first hit: +//! `System.Array`'s interface method setup). See the project notes for the full +//! trace. +//! +//! This module reverses the obfuscation purely from the metadata's own +//! structure. Methods are laid out grouped by type, and types grouped by image +//! (module), so a method's correct token row is simply its position within its +//! module's method range. We re-derive that range from the images/types tables +//! and rewrite each method token to `0x06000000 | (local_index + 1)`. +//! +//! Only method tokens are touched: field tokens are already contiguous and type +//! tokens resolve correctly. The transform is a no-op on an unobfuscated +//! metadata (its tokens already equal `local_index + 1`), so it is safe to run on +//! any il2cpp game — `remapped == 0` then reports that nothing changed. + +/// il2cpp `global-metadata.dat` sanity magic (`Il2CppGlobalMetadataHeader.sanity`). +const MAGIC: u32 = 0xFAB1_1BAF; + +/// Metadata format version this de-obfuscator understands. The struct strides +/// and header field offsets below are specific to it; other versions are left +/// untouched rather than risk corrupting a layout we have not verified. +/// (Observed on real games shipping version 31 / Unity 2022.3.) +const SUPPORTED_VERSION: u32 = 31; + +// --- Il2CppGlobalMetadataHeader field byte-offsets (each is an i32 offset/size +// pair). Shared layout across recent versions. --- +const HDR_METHODS: usize = 0x30; // methodsOffset / methodsSize +const HDR_TYPES: usize = 0xA0; // typeDefinitionsOffset / size +const HDR_IMAGES: usize = 0xA8; // imagesOffset / size + +// --- version-31 struct strides and field offsets --- +const METHOD_STRIDE: usize = 0x24; // sizeof(Il2CppMethodDefinition) +const METHOD_TOKEN_OFF: usize = 0x18; // .token (u32) +const TYPE_STRIDE: usize = 0x58; // sizeof(Il2CppTypeDefinition) +const TYPE_METHOD_START_OFF: usize = 0x24; // .methodStart (i32) +const TYPE_METHOD_COUNT_OFF: usize = 0x40; // .method_count (u16) +const IMAGE_STRIDE: usize = 0x28; // sizeof(Il2CppImageDefinition) +const IMAGE_TYPE_START_OFF: usize = 0x08; // .typeStart (i32) +const IMAGE_TYPE_COUNT_OFF: usize = 0x0C; // .typeCount (u32) + +/// `IMAGE_CODE_GEN_MODULE` method-definition token table id (`0x06 << 24`). +const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; +/// `Il2Cpp*Index` "no value" sentinel (`kTypeIndexInvalid` etc.). +const NO_METHODS: u32 = 0xFFFF_FFFF; + +/// Outcome of a successful [`deobfuscate`] pass. +#[derive(Debug, Clone, Copy)] +pub struct Report { + pub version: u32, + /// Total `Il2CppMethodDefinition` entries. + pub methods: usize, + /// Number of method tokens actually rewritten (0 ⇒ the input was already + /// de-obfuscated, i.e. not `-GMD`-protected). + pub remapped: usize, + /// Number of modules (images) that own at least one method. + pub modules: usize, +} + +/// Why [`deobfuscate`] declined to process the input. None of these mutate the +/// input; the caller leaves the file untouched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Missing the il2cpp metadata sanity magic — not a `global-metadata.dat`. + NotMetadata, + /// Recognised metadata, but an unhandled format version. + UnsupportedVersion(u32), + /// Magic/version matched but the table layout is inconsistent with the + /// supported version (truncated, mis-sized, or overlapping ranges). + Malformed, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotMetadata => write!(f, "not an il2cpp global-metadata.dat"), + Error::UnsupportedVersion(v) => write!(f, "unsupported metadata version {v}"), + Error::Malformed => write!(f, "malformed metadata for version {SUPPORTED_VERSION}"), + } + } +} +impl std::error::Error for Error {} + +/// Cheap check for the il2cpp metadata sanity magic, for scanning prefixes. +pub fn is_metadata(data: &[u8]) -> bool { + rd_u32(data, 0) == Some(MAGIC) +} + +/// De-obfuscate the method tokens in an il2cpp `global-metadata.dat`. +/// +/// On success returns the (possibly-rewritten) file bytes and a [`Report`]. The +/// transform is idempotent: a metadata that is already de-obfuscated comes back +/// byte-identical with `report.remapped == 0`. +pub fn deobfuscate(data: &[u8]) -> Result<(Vec, Report), Error> { + if rd_u32(data, 0) != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = rd_u32(data, 4).ok_or(Error::Malformed)?; + if version != SUPPORTED_VERSION { + return Err(Error::UnsupportedVersion(version)); + } + + let (m_off, m_size) = table(data, HDR_METHODS)?; + let (t_off, t_size) = table(data, HDR_TYPES)?; + let (i_off, i_size) = table(data, HDR_IMAGES)?; + + // The strides must divide their tables exactly and the tables must lie + // within the file: a mismatch means our version-31 layout is wrong for this + // file, so bail without touching it rather than scribble at bad offsets. + if m_size % METHOD_STRIDE != 0 || t_size % TYPE_STRIDE != 0 || i_size % IMAGE_STRIDE != 0 { + return Err(Error::Malformed); + } + let method_count = m_size / METHOD_STRIDE; + let type_count = t_size / TYPE_STRIDE; + let image_count = i_size / IMAGE_STRIDE; + if !fits(data, m_off, m_size) || !fits(data, t_off, t_size) || !fits(data, i_off, i_size) { + return Err(Error::Malformed); + } + + // Map every method to its owning module and record each module's first + // (lowest) method index. A method's correct token row is its 1-based offset + // from that first index (methods are contiguous & grouped per module). + let mut module_of = vec![u32::MAX; method_count]; + let mut module_first = vec![u32::MAX; image_count]; + for (img, first_slot) in module_first.iter_mut().enumerate() { + let ib = i_off + img * IMAGE_STRIDE; + let type_start = rd_u32(data, ib + IMAGE_TYPE_START_OFF).ok_or(Error::Malformed)?; + let type_cnt = rd_u32(data, ib + IMAGE_TYPE_COUNT_OFF).ok_or(Error::Malformed)?; + let mut first = u32::MAX; + for t in type_start..type_start.saturating_add(type_cnt) { + if t as usize >= type_count { + return Err(Error::Malformed); + } + let tb = t_off + (t as usize) * TYPE_STRIDE; + let ms = rd_u32(data, tb + TYPE_METHOD_START_OFF).ok_or(Error::Malformed)?; + let mc = rd_u16(data, tb + TYPE_METHOD_COUNT_OFF).ok_or(Error::Malformed)? as u32; + if ms == NO_METHODS || mc == 0 { + continue; + } + first = first.min(ms); + for m in ms..ms.saturating_add(mc) { + let mi = m as usize; + if mi >= method_count { + return Err(Error::Malformed); + } + if module_of[mi] != u32::MAX { + return Err(Error::Malformed); // a method in two modules — layout is wrong + } + module_of[mi] = img as u32; + } + } + *first_slot = first; + } + + // Rewrite each owned method's token to `0x06000000 | (local_index + 1)`. + // Any method NOT owned by an image would keep its original (obfuscated) + // token — a silent partial remap that still crashes il2cpp at runtime, so + // treat it as a malformed layout instead of shipping it. + let mut out = data.to_vec(); + let mut remapped = 0usize; + for (mi, &img) in module_of.iter().enumerate() { + if img == u32::MAX { + return Err(Error::Malformed); // method outside every image's range + } + let first = module_first[img as usize]; + let local = (mi as u32) - first; // mi >= first by construction + let new_tok = METHOD_TOKEN_TABLE | ((local + 1) & 0x00FF_FFFF); + let off = m_off + mi * METHOD_STRIDE + METHOD_TOKEN_OFF; + // `fits` above guarantees this 4-byte write is in bounds. + if out[off..off + 4] != new_tok.to_le_bytes() { + out[off..off + 4].copy_from_slice(&new_tok.to_le_bytes()); + remapped += 1; + } + } + + let modules = module_first.iter().filter(|&&f| f != u32::MAX).count(); + Ok(( + out, + Report { + version, + methods: method_count, + remapped, + modules, + }, + )) +} + +/// Read the (offset, size) i32 pair of a metadata table from the header. +fn table(data: &[u8], hdr_off: usize) -> Result<(usize, usize), Error> { + let off = rd_u32(data, hdr_off).ok_or(Error::Malformed)? as usize; + let size = rd_u32(data, hdr_off + 4).ok_or(Error::Malformed)? as usize; + Ok((off, size)) +} + +/// True if `[off, off+len)` lies within `data`. +fn fits(data: &[u8], off: usize, len: usize) -> bool { + off.checked_add(len).is_some_and(|end| end <= data.len()) +} + +fn rd_u32(b: &[u8], o: usize) -> Option { + let s = b.get(o..o + 4)?; + Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn rd_u16(b: &[u8], o: usize) -> Option { + let s = b.get(o..o + 2)?; + Some(u16::from_le_bytes([s[0], s[1]])) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Build a minimal but structurally valid v31 metadata with two modules: + // image 0: 1 type, 2 methods (global 0,1) + // image 1: 1 type, 3 methods (global 2,3,4) + // Method tokens are seeded with *obfuscated* (sparse) values; the correct + // de-obfuscated tokens are per-module 1-based: [1,2] and [1,2,3]. + struct Built { + bytes: Vec, + m_off: usize, + } + fn build(method_tokens: &[u32]) -> Built { + // Layout: [header 0x100][images][types][methods] + let hdr = 0x100usize; + let images = hdr; + let i_count = 2; + let i_size = i_count * IMAGE_STRIDE; + let types = images + i_size; + let t_count = 2; + let t_size = t_count * TYPE_STRIDE; + let methods = types + t_size; + let m_count = method_tokens.len(); + let m_size = m_count * METHOD_STRIDE; + let total = methods + m_size; + let mut b = vec![0u8; total]; + + let put32 = |b: &mut [u8], o: usize, v: u32| b[o..o + 4].copy_from_slice(&v.to_le_bytes()); + let put16 = |b: &mut [u8], o: usize, v: u16| b[o..o + 2].copy_from_slice(&v.to_le_bytes()); + + put32(&mut b, 0, MAGIC); + put32(&mut b, 4, SUPPORTED_VERSION); + put32(&mut b, HDR_METHODS, methods as u32); + put32(&mut b, HDR_METHODS + 4, m_size as u32); + put32(&mut b, HDR_TYPES, types as u32); + put32(&mut b, HDR_TYPES + 4, t_size as u32); + put32(&mut b, HDR_IMAGES, images as u32); + put32(&mut b, HDR_IMAGES + 4, i_size as u32); + + // image 0: typeStart=0 typeCount=1 ; image 1: typeStart=1 typeCount=1 + put32(&mut b, images + IMAGE_TYPE_START_OFF, 0); + put32(&mut b, images + IMAGE_TYPE_COUNT_OFF, 1); + put32(&mut b, images + IMAGE_STRIDE + IMAGE_TYPE_START_OFF, 1); + put32(&mut b, images + IMAGE_STRIDE + IMAGE_TYPE_COUNT_OFF, 1); + // type 0: methodStart=0 count=2 ; type 1: methodStart=2 count=3 + put32(&mut b, types + TYPE_METHOD_START_OFF, 0); + put16(&mut b, types + TYPE_METHOD_COUNT_OFF, 2); + put32(&mut b, types + TYPE_STRIDE + TYPE_METHOD_START_OFF, 2); + put16(&mut b, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFF, 3); + // method tokens + for (i, &tok) in method_tokens.iter().enumerate() { + put32(&mut b, methods + i * METHOD_STRIDE + METHOD_TOKEN_OFF, tok); + } + Built { + bytes: b, + m_off: methods, + } + } + fn tok(b: &[u8], m_off: usize, i: usize) -> u32 { + rd_u32(b, m_off + i * METHOD_STRIDE + METHOD_TOKEN_OFF).unwrap() + } + + #[test] + fn remaps_obfuscated_method_tokens_per_module() { + // Obfuscated sparse tokens (rows way past each module's method count). + let built = build(&[ + 0x0600_D49F, + 0x0600_FFFF, + 0x0600_1234, + 0x0600_ABCD, + 0x0600_5555, + ]); + let (out, r) = deobfuscate(&built.bytes).expect("ok"); + assert_eq!(r.version, 31); + assert_eq!(r.methods, 5); + assert_eq!(r.modules, 2); + assert_eq!(r.remapped, 5); + // module 0 (methods 0,1) -> rows 1,2 ; module 1 (methods 2,3,4) -> rows 1,2,3 + assert_eq!(tok(&out, built.m_off, 0), 0x0600_0001); + assert_eq!(tok(&out, built.m_off, 1), 0x0600_0002); + assert_eq!(tok(&out, built.m_off, 2), 0x0600_0001); + assert_eq!(tok(&out, built.m_off, 3), 0x0600_0002); + assert_eq!(tok(&out, built.m_off, 4), 0x0600_0003); + } + + #[test] + fn idempotent_on_clean_metadata() { + // Already de-obfuscated: per-module contiguous rows. + let clean = [ + 0x0600_0001, + 0x0600_0002, + 0x0600_0001, + 0x0600_0002, + 0x0600_0003, + ]; + let built = build(&clean); + let (out, r) = deobfuscate(&built.bytes).expect("ok"); + assert_eq!(r.remapped, 0, "no rewrites on already-clean metadata"); + assert_eq!(out, built.bytes, "byte-identical output"); + } + + #[test] + fn rejects_non_metadata() { + assert_eq!( + deobfuscate(b"not metadata at all....").unwrap_err(), + Error::NotMetadata + ); + } + + #[test] + fn rejects_unsupported_version() { + let mut built = build(&[ + 0x0600_0001, + 0x0600_0002, + 0x0600_0001, + 0x0600_0002, + 0x0600_0003, + ]); + built.bytes[4..8].copy_from_slice(&29u32.to_le_bytes()); + assert_eq!( + deobfuscate(&built.bytes).unwrap_err(), + Error::UnsupportedVersion(29) + ); + } +} diff --git a/src/pause.rs b/src/pause.rs new file mode 100644 index 0000000..34129e6 --- /dev/null +++ b/src/pause.rs @@ -0,0 +1,24 @@ +pub fn should_pause() -> bool { + #[cfg(windows)] + { + use windows::Win32::System::Console::GetConsoleProcessList; + let mut buf = [0u32; 4]; + let n = unsafe { GetConsoleProcessList(&mut buf) }; + n == 1 + } + #[cfg(not(windows))] + { + false + } +} + +pub fn maybe_pause(force_skip: bool) { + if force_skip || !should_pause() { + return; + } + use std::io::Write; + eprint!("\nPress Enter to exit…"); + let _ = std::io::stderr().flush(); + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); +} diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..d0308f4 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,414 @@ +use crate::unpacker::detect; +use std::io::Read; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// Bytes read per file for content detection. `detect` inspects the DOS/PE +/// header and the Crackproof key table at offset 4096; its deepest read is the +/// key-table dword at 4124 (so a candidate must be ≥ 4128 bytes) or the PE +/// data-directory field at `e_lfanew + 252`, which is far below 8 KiB for any +/// real PE (`e_lfanew` is a few hundred bytes). `is_metadata` needs only the +/// first 4 bytes. An 8 KiB prefix therefore yields the same verdict as the whole +/// file while avoiding pulling multi-gigabyte game assets into memory just to +/// reject them — the previous 64 KiB was 8× larger than anything detect reads. +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 +/// 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 +/// processable is lost. +const MIN_SIZE: u64 = 4128; + +/// File extensions that are bulk data by construction and can never be a PE +/// image or an il2cpp metadata blob. +/// +/// This is deliberately a **deny**-list, not an allow-list: the default is to +/// probe, so anything unrecognised is still opened. Targets are recognised by +/// content, not extension, and can carry arbitrary names — there is no closed +/// set of target extensions an allow-list of `exe`/`dll` could enumerate. +/// Only extensions that are bulk asset or text formats by construction appear +/// here. +/// +/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless. +const DENY_EXT: &[&str] = &[ + // Unity and other engine asset containers + "ab", + "bundle", + "unity3d", + "manifest", + "resource", + "ress", + "assets", + "sharedassets", + // audio / video / image / font + "acb", + "awb", + "usm", + "wav", + "ogg", + "mp3", + "mp4", + "avi", + "png", + "jpg", + "jpeg", + "bmp", + "gif", + "tga", + "dds", + "svg", + "ttf", + "otf", + // text, markup, config, logs + "xml", + "json", + "txt", + "csv", + "md", + "toml", + "ini", + "yml", + "yaml", + "log", + "html", + "htm", + "css", + "aspx", + "browser", + "config", + "sig", + "map", + "pdb", + // rhythm-game chart/score data + "ma2", + "sr", +]; + +/// Whether `path`'s extension is on [`DENY_EXT`]. Extensionless files are never +/// denied (they could be anything). +fn denied_ext(path: &Path) -> bool { + let Some(ext) = path.extension() else { + return false; + }; + let Some(ext) = ext.to_str() else { + return false; + }; + // Extensions are ASCII in practice; compare case-insensitively without + // allocating for the overwhelmingly common non-match. + DENY_EXT + .iter() + .any(|d| d.len() == ext.len() && d.eq_ignore_ascii_case(ext)) +} + +/// Content classification of a single file. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Class { + /// Neither a Crackproof module nor il2cpp metadata — left untouched. + None, + /// A Crackproof-protected PE (unpack target). + Crackproof, + /// An il2cpp `global-metadata.dat` (de-obfuscation target). + Metadata, +} + +/// Walk `root` recursively (skipping any directory literally named `"unpack"`) +/// and return, **in walk order**, the Crackproof candidates and the il2cpp +/// metadata blobs found — from a *single* traversal that opens each file at +/// most once. +/// +/// # Why the cheap pre-filter dominates +/// +/// The traversal is not the cost. Measured on a 46,446-file / 61 GB game tree, +/// `readdir` (including each entry's size, which Windows returns from the +/// directory enumeration for free) takes ~0.2 s and opening all 46,446 files +/// takes ~1 s — but *reading* from them takes 40 s. Read size is irrelevant: a +/// 4-byte read costs the same ~900 µs as an 8 KiB one, because the cost is +/// per-file I/O latency, not bandwidth (that tree lives on a user-mode virtual +/// disk that tops out near 1,300 IOPS). Thread count barely moves it either. +/// +/// So the only lever is **probing fewer files**, which is what [`MIN_SIZE`] and +/// [`DENY_EXT`] do — both decided from the free directory metadata, before any +/// file is opened. On that tree they cut 46,446 probes to 1,814 and the scan +/// from ~40 s to ~2 s while still finding every target. +/// +/// The surviving probes (open + short read + magic test) are fanned out across +/// worker threads. Directory traversal itself stays serial (one cheap `readdir` +/// pass, no file opens) because it feeds the parallel probe. +/// +/// Thread count follows [`crate::unpacker::parallel::thread_cap`] (honoring +/// `SENBEI_THREADS`, `1` = fully sequential). Output order is independent of +/// thread count: each worker owns a disjoint contiguous slice of the path list +/// and writes the matching disjoint slice of the class list, so results are +/// deterministic. +pub fn find_targets(root: &Path) -> (Vec, Vec, ScanStats) { + find_targets_opts(root, scan_all_env()) +} + +/// Non-target tallies from a [`find_targets_opts`] walk. +#[derive(Default, Clone, Copy, Debug)] +pub struct ScanStats { + /// Files that were content-probed but matched neither detector (skipped). + pub skipped: usize, + /// Directory entries the walker could not read (permissions, transient + /// I/O errors). These files were never classified — surface this to the + /// user instead of silently reporting a clean scan. + pub walk_errors: usize, + /// Files selected for probing whose bytes could not be read (open/read + /// failure, or a detector panic). Unlike `skipped`, the scan could not + /// determine whether these are targets — a locked il2cpp game assembly + /// looks exactly like this, so the job layer counts them as errors. + pub probe_errors: usize, +} + +/// [`find_targets`], but with the pre-filter explicitly controlled. When +/// `scan_all` is true every regular file is probed, restoring the exhaustive +/// (and on asset-heavy trees, far slower) behavior. +pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec, Vec, ScanStats) { + // Phase 1: serial traversal collecting regular-file paths only. No file is + // opened here; `readdir` is fast relative to the content probe that follows, + // and `entry.metadata()` is served from the directory entry on Windows, so + // the size test below costs nothing. + let mut paths: Vec = Vec::new(); + let mut stats = ScanStats::default(); + for entry in WalkDir::new(root).into_iter().filter_entry(|e| { + if !e.file_type().is_dir() { + return true; + } + // The root itself is always walked, even if it is named "unpack" or is + // a junction the user pointed us at deliberately. + if e.depth() == 0 { + return true; + } + // Never descend into a previous output tree ("unpack", any case: NTFS + // is case-insensitive, so `Unpack` from an older run is still ours). + if e.file_name().eq_ignore_ascii_case("unpack") { + return false; + } + // Skip reparse-point directories (junctions, symlink-dirs): they point + // outside the scanned tree — walking one would silently unpack an + // entire foreign tree (e.g. a `samples` junction into the golden corpus). + !is_reparse_point(e) + }) { + let entry = match entry { + Ok(e) => e, + Err(_) => { + stats.walk_errors += 1; + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + if !scan_all { + // Skip on directory metadata alone — never open these. + let too_small = entry + .metadata() + .map(|m| m.len() < MIN_SIZE) + .unwrap_or(false); + if too_small || denied_ext(entry.path()) { + continue; + } + } + paths.push(entry.into_path()); + } + + // Phase 2: parallel content probe over disjoint chunks (no synchronization). + // `classify` yields `None` for unreadable/panicking probes (see ScanStats); + // `Some(Class::None)` means "probed, matched neither detector". + let n = paths.len(); + let mut class: Vec> = vec![Some(Class::None); n]; + let workers = crate::unpacker::parallel::thread_cap().clamp(1, n.max(1)); + if workers <= 1 { + for (p, c) in paths.iter().zip(class.iter_mut()) { + *c = classify(p); + } + } else { + let chunk = n.div_ceil(workers); + std::thread::scope(|scope| { + for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) { + scope.spawn(move || { + for (p, c) in pc.iter().zip(cc.iter_mut()) { + *c = classify(p); + } + }); + } + }); + } + + let mut candidates = Vec::new(); + let mut metadata = Vec::new(); + for (p, c) in paths.into_iter().zip(class) { + match c { + Some(Class::Crackproof) => candidates.push(p), + Some(Class::Metadata) => metadata.push(p), + Some(Class::None) => stats.skipped += 1, + // Unreadable / panicking probe: NOT skipped — the scan could not + // classify it, so it may be a target we failed to unpack. + None => stats.probe_errors += 1, + } + } + (candidates, metadata, stats) +} + +/// True if a walked directory entry is a reparse point (junction or symlink). +/// +/// `DirEntry::file_type` only flags true symlinks; NTFS junctions report as +/// ordinary directories, so without this check the walker descends into them. +/// Off-Windows there are no junctions — symlink dirs are already excluded +/// because `follow_links` is off (their `file_type().is_dir()` is false). +#[cfg(windows)] +fn is_reparse_point(e: &walkdir::DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + e.metadata() + .map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn is_reparse_point(_e: &walkdir::DirEntry) -> bool { + false +} + +/// Whether the scan pre-filter is disabled via `SENBEI_SCAN_ALL`. Any value +/// other than `0`/empty turns exhaustive scanning on. The `--scan-all` flag is +/// ORed with this. +pub fn scan_all_env() -> bool { + match std::env::var("SENBEI_SCAN_ALL") { + Ok(v) => !matches!(v.trim(), "" | "0"), + Err(_) => false, + } +} + +/// Classify one file by content. Reads a short prefix once and tests the +/// Crackproof detector first, then the il2cpp metadata magic. Returns `None` +/// when the file could not be classified at all — an I/O error opening it +/// (locked, permissions) or a panic inside a detector — so the caller counts +/// it as a probe error rather than a clean "not a target" skip. +/// +/// The detector is wrapped in `catch_unwind` because a panic in a scan worker +/// thread would otherwise abort the whole folder run (a scoped-thread panic +/// re-raises on join, before any per-file isolation exists). The default panic +/// hook still prints the message, keeping the bug diagnosable. +/// +/// A Crackproof PE never matches the metadata magic (it is a PE, not a +/// metadata blob) and vice versa, so the order is immaterial. +fn classify(path: &Path) -> Option { + let head = read_prefix(path, DETECT_PREFIX)?; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if detect(&head).is_some() { + Class::Crackproof + } else if crate::metadata::is_metadata(&head) { + Class::Metadata + } else { + Class::None + } + })); + r.ok() +} + +/// Read up to `max` bytes from the start of `path`. Returns `None` on any I/O +/// error (the file is simply not treated as a candidate). +fn read_prefix(path: &Path, max: u64) -> Option> { + let file = std::fs::File::open(path).ok()?; + let mut buf = Vec::with_capacity(max as usize); + file.take(max).read_to_end(&mut buf).ok()?; + Some(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denies_bulk_asset_extensions_case_insensitively() { + for p in ["a.ab", "a.XML", "a.Acb", "a.ma2", "a.manifest", "a.PNG"] { + assert!(denied_ext(Path::new(p)), "{p} should be denied"); + } + } + + #[test] + fn never_denies_what_a_target_can_be_named() { + // Targets are recognised by content, not name — a protected module + // can carry any extension, or none — so names like these must always + // be probed. An allow-list would have skipped them. + for p in [ + "app.exe.bak", + "managed.dll.bak", + "daemon.exe", + "GameLib.dll", + "global-metadata.dat", + "noextension", + "a.so", + "a.bin", + ] { + assert!(!denied_ext(Path::new(p)), "{p} must still be probed"); + } + } + + /// A file below the Crackproof key-table bound is skipped without being + /// opened, but a large non-asset file is still probed. + #[test] + fn prefilter_skips_small_and_denied_files_only() { + let td = tempfile::tempdir().unwrap(); + let root = td.path(); + std::fs::write(root.join("tiny.dll"), vec![0u8; 100]).unwrap(); + std::fs::write(root.join("assets.ab"), vec![0u8; 100_000]).unwrap(); + std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap(); + + // None of them are Crackproof, so both modes find nothing; the point is + // that the filtered walk does not panic and honors `scan_all`. + let (c, m, _) = find_targets_opts(root, false); + assert!(c.is_empty() && m.is_empty()); + let (c, m, _) = find_targets_opts(root, true); + assert!(c.is_empty() && m.is_empty()); + } + + /// An il2cpp metadata blob is found by the filtered scan: `.dat` is not on + /// the deny-list and a real one is far above `MIN_SIZE`. + #[test] + fn finds_metadata_through_the_prefilter() { + let td = tempfile::tempdir().unwrap(); + let root = td.path(); + let mut blob = vec![0u8; MIN_SIZE as usize + 1]; + blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes()); + std::fs::write(root.join("global-metadata.dat"), &blob).unwrap(); + // Same magic but too small to be processable — skipped by the size floor. + std::fs::write(root.join("stub.dat"), &blob[..64]).unwrap(); + + let (_, m, _) = find_targets_opts(root, false); + assert_eq!(m.len(), 1); + assert!(m[0].ends_with("global-metadata.dat")); + } + + /// Review regression: a previous output tree is pruned case-insensitively + /// (NTFS is case-insensitive, so `UNPACK` from an older run is still our + /// output), and probed non-targets are counted as skipped. + #[test] + fn prunes_unpack_dir_case_insensitively_and_counts_skipped() { + let td = tempfile::tempdir().unwrap(); + let root = td.path(); + let out_dir = root.join("UNPACK"); + std::fs::create_dir(&out_dir).unwrap(); + // A metadata-magic file inside the old output tree: must NOT be found. + let mut blob = vec![0u8; MIN_SIZE as usize + 1]; + blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes()); + std::fs::write(out_dir.join("global-metadata.dat"), &blob).unwrap(); + // A big non-target file at the root: probed, then skipped. + std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap(); + + let (c, m, stats) = find_targets_opts(root, false); + assert!( + c.is_empty() && m.is_empty(), + "old output tree must be pruned" + ); + assert_eq!(stats.skipped, 1, "the probed non-target counts as skipped"); + assert_eq!(stats.walk_errors, 0); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..d882745 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,73 @@ +use crate::unpacker::{IntegrityReport, Kind}; +use indicatif::{ProgressBar, ProgressStyle}; +use owo_colors::OwoColorize; +use std::path::Path; + +/// Create a progress bar for `n` items. Hidden when `quiet` is true. +pub fn progress(n: u64, quiet: bool) -> ProgressBar { + if quiet || n == 0 { + return ProgressBar::hidden(); + } + let bar = ProgressBar::new(n); + bar.set_style( + ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}") + .unwrap_or_else(|_| ProgressStyle::default_bar()), + ); + bar +} + +/// Print a green success line, suspending the progress bar. +pub fn ok(bar: &ProgressBar, quiet: bool, rel: &Path, kind: Kind, dest: &Path) { + if quiet { + return; + } + let msg = format!( + "{} {:?} {} -> {}", + "✓".green(), + kind, + rel.display(), + dest.display() + ); + bar.suspend(|| println!("{msg}")); +} + +/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`, +/// reporting how many method tokens were remapped. +pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) { + if quiet { + return; + } + let msg = format!( + "{} metadata {} -> {} ({} method tokens remapped)", + "✓".green(), + rel.display(), + dest.display(), + remapped + ); + bar.suspend(|| println!("{msg}")); +} + +/// Print a red error line, suspending the progress bar. +pub fn err(bar: &ProgressBar, quiet: bool, rel: &Path, e: &anyhow::Error) { + if quiet { + return; + } + let msg = format!("{} {} {e:#}", "✗".red(), rel.display()); + bar.suspend(|| eprintln!("{msg}")); +} + +/// Print a yellow warning line for a file that unpacked but failed the static +/// integrity check (likely to crash at runtime), suspending the progress bar. +pub fn suspect(bar: &ProgressBar, quiet: bool, rel: &Path, report: &IntegrityReport) { + if quiet { + return; + } + let msg = format!( + "{} {} integrity check failed: {}", + "!".yellow(), + rel.display(), + report.issues.join("; ") + ); + bar.suspend(|| eprintln!("{msg}")); +} diff --git a/src/unpacker/bytecode.rs b/src/unpacker/bytecode.rs new file mode 100644 index 0000000..f662c6e --- /dev/null +++ b/src/unpacker/bytecode.rs @@ -0,0 +1,119 @@ +// Bytecode interpreter for the custom-decryptor stages. Those stages are tiny +// instruction programs embedded in the decrypted buffer; we compile each +// program down to a Vec and interpret it. + +#[derive(Clone, Copy)] +pub enum Op { + Add(u8), + Sub(u8), + Xor(u8), + Rol(u32), + Ror(u32), + Inc, + Dec, +} + +pub fn apply(ops: &[Op], mut x: u8) -> u8 { + for &op in ops { + x = match op { + Op::Add(n) => x.wrapping_add(n), + Op::Sub(n) => x.wrapping_sub(n), + Op::Xor(n) => x ^ n, + Op::Rol(n) => x.rotate_left(n & 7), + Op::Ror(n) => x.rotate_right(n & 7), + Op::Inc => x.wrapping_add(1), + Op::Dec => x.wrapping_sub(1), + }; + } + x +} + +/// A precomputed 256-entry byte→byte translation table for a fixed op list. +/// +/// `apply` is a pure function of a single byte, but the hot decrypt paths run it +/// over multi-megabyte regions. Building the full table once and translating +/// each byte with a single lookup turns an O(region × ops) walk into O(region) — +/// a large constant-factor win on those paths. +pub struct OpsLut { + t: [u8; 256], +} + +impl OpsLut { + pub fn new(ops: &[Op]) -> Self { + let mut t = [0u8; 256]; + let mut i = 0; + while i < 256 { + t[i] = apply(ops, i as u8); + i += 1; + } + Self { t } + } + + /// Translate `d[off .. off + n]` in place through the table. + #[inline] + pub fn map_region(&self, d: &mut [u8], off: usize, n: usize) { + for b in &mut d[off..off + n] { + *b = self.t[*b as usize]; + } + } +} + +pub fn generate(data: &[u8], offset: u32) -> Option> { + // Bounds-checked cursor: a corrupt `data_offset` (bad decrypt_data6 / the + // alignment fallback) must yield `None`, not an out-of-bounds panic — the + // panic path would surface as a misleading `UnpackError::Corrupt` instead + // of the precise `BytecodeGenFailed`, and any future caller without a + // `catch_unwind` wrapper would abort outright. + let mut pos = offset as usize; + let mut next = move || { + let b = data.get(pos).copied()?; + pos += 1; + Some(b) + }; + let mut ops = Vec::new(); + loop { + match next()? { + 4 => ops.push(Op::Add(next()?)), + 44 => ops.push(Op::Sub(next()?)), + 52 => ops.push(Op::Xor(next()?)), + 144 => {} // nop + 192 => { + let mb = next()?; + let rm = mb & 7; + let reg = (mb >> 3) & 7; + let mod_ = (mb >> 6) & 3; + if mod_ != 3 || rm != 0 { + return None; + } + let imm = next()? as u32; + match reg { + 0 => ops.push(Op::Rol(imm)), + 1 => ops.push(Op::Ror(imm)), + _ => { + return None; + } + } + } + 254 => { + let mb = next()?; + let rm = mb & 7; + let reg = (mb >> 3) & 7; + let mod_ = (mb >> 6) & 3; + if mod_ != 3 || rm != 0 { + return None; + } + match reg { + 0 => ops.push(Op::Inc), + 1 => ops.push(Op::Dec), + _ => { + return None; + } + } + } + 195 => return Some(ops), + _ => { + return None; + } + } + } +} diff --git a/src/unpacker/crc32.rs b/src/unpacker/crc32.rs new file mode 100644 index 0000000..beb69a1 --- /dev/null +++ b/src/unpacker/crc32.rs @@ -0,0 +1,33 @@ +const fn build_table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u32; + let mut k = 0; + while k < 8 { + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + k += 1; + } + table[i] = c; + i += 1; + } + table +} + +const TABLE: [u32; 256] = build_table(); + +pub fn append(initial: u32, data: &[u8]) -> u32 { + let mut crc = !initial; + for &b in data { + crc = TABLE[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); + } + !crc +} + +pub fn compute(data: &[u8]) -> u32 { + append(0, data) +} diff --git a/src/unpacker/dll.rs b/src/unpacker/dll.rs new file mode 100644 index 0000000..6bc0894 --- /dev/null +++ b/src/unpacker/dll.rs @@ -0,0 +1,745 @@ +//! Native/managed-DLL unpack pipeline for the older protected-DLL layout. +//! +//! Naming note: the stage names used by this layout do NOT line up 1:1 with the +//! shared primitives. Mapping used here: +//! DecryptData1 (XOR+ROR over dwords) -> primitives::decrypt_data3 +//! DecryptData3 (shift-5 byte rotate) -> local `decrypt_data3_shift5` +//! DecryptData4/5 (AES+XORROR+huff) -> local `decrypt_data4` +//! DecryptData6 (shift-6 byte rotate) -> local `decrypt_data6_shift6` +//! DecryptData7 (nibble-swap rolling) -> primitives::decrypt_data7 +//! Decompress (LFSR keystream) -> primitives::decrypt_data6 +//! HuffmanDecompress -> primitives::decompress +//! AesDecrypt -> primitives::aes_decrypt +//! CalculateChecksumWithSizeXor -> primitives::calculate_checksum +//! CalculateCrc32 -> crc32::compute (via above) + +use super::UnpackError; +use super::bytecode::{Op, OpsLut, generate}; +use super::primitives::{self, *}; + +/// Read a signed 32-bit little-endian value. +fn get_i32(d: &[u8], offset: i32) -> i32 { + get_u32(d, offset as u32) as i32 +} + +/// Write a signed 32-bit little-endian value. +fn write_i32(d: &mut [u8], offset: i32, value: i32) { + write_u32(d, offset as u32, value as u32); +} + +/// `DecryptData3` (shift-5): byte-level bit rotation over a (addr,size) pair. +fn decrypt_data3_shift5(d: &mut [u8], offset: i32) { + let addr = get_i32(d, offset); + let size = get_i32(d, offset + 4); + let mut key1: u8 = (addr as u8).wrapping_add((addr >> 8) as u8); + let mut key2: u8 = key1.wrapping_add(1); + for i in 0..size { + let idx = (addr + i) as usize; + let val = d[idx]; + let step1 = key2 ^ val.rotate_left(3); + let step2 = key1 ^ step1.rotate_left(3); + d[idx] = step2.rotate_left(3); + key1 = key1.wrapping_add(1); + key2 = key2.wrapping_add(1); + } +} + +/// `DecryptData6` (shift-6): byte-level bit rotation over an explicit +/// (offset, size) range, with the low byte of `offset` as the rolling key. +fn decrypt_data6_shift6(d: &mut [u8], offset: i32, size: i32) { + let mut key1: u8 = offset as u8; + let mut key2: u8 = (offset as u8).wrapping_add(1); + for i in 0..size { + let idx = (offset + i) as usize; + let val = d[idx]; + let step1 = key2 ^ val.rotate_left(2); + let step2 = key1 ^ step1.rotate_left(2); + d[idx] = step2.rotate_left(2); + key1 = key1.wrapping_add(1); + key2 = key2.wrapping_add(1); + } +} + +/// `DecryptData4`/`DecryptData5`: AES-CBC decrypt + XOR/ROR (DecryptData1 +/// with rotate 19) + optional per-byte transform + Huffman decompress. +fn decrypt_data4( + d: &mut [u8], + offset: i32, + key: i32, + decomp_params: &[i32; 4], + transform: Option<&[Op]>, +) -> Result<(), UnpackError> { + let addr = get_i32(d, offset); + let size = get_i32(d, offset + 4); + let compressed_addr = get_i32(d, offset + 8); + let decompressed_size = get_i32(d, offset + 12); + + aes_decrypt(d, addr as u32, size as u32, decomp_params[3] as u32); + // DecryptData1(offset, key, 19) == primitives::decrypt_data3 with shift 19 + decrypt_data3(d, offset as u32, key as u32, 19); + + if let Some(ops) = transform + && size > 0 + { + OpsLut::new(ops).map_region(d, addr as usize, size as usize); + } + + if size != decompressed_size { + // decompress reports corruption (after partial writes) via its bool; + // surface it instead of shipping a garbage block. + if !decompress( + d, + addr as u32, + compressed_addr as u32, + decomp_params[1] as u32, + size as u32, + decompressed_size as u32, + ) { + return Err(UnpackError::DecompressFailed); + } + } + Ok(()) +} + +/// `InitializeKeys`. +fn initialize_keys(file_data: &[u8]) -> [i32; 8] { + let mut keys = [0i32; 8]; + keys[0] = get_i32(file_data, 4096); + let mut prev_key = keys[0]; + for i in 0..7i32 { + let val = get_i32(file_data, 4 * i + 4100); + keys[(i + 1) as usize] = val ^ prev_key; + prev_key = (i * i) ^ (val.wrapping_add(prev_key).wrapping_sub(i)); + } + keys +} + +/// `ProcessRelocBlock`. +fn process_reloc_block(d: &mut [u8], mut pos: i32) { + loop { + decrypt_data6_shift6(d, pos, 16); + let src_addr = get_i32(d, pos); + let size = get_i32(d, pos + 4); + let dst_addr = get_i32(d, pos + 8); + let verify = get_i32(d, pos + 12); + pos += 16; + + if src_addr != 0 && size != 0 && dst_addr != 0 && verify == size { + let s = src_addr as usize; + let dd = dst_addr as usize; + let n = size as usize; + d.copy_within(s..s + n, dd); + } + if size == 0 { + break; + } + } +} + +/// Number of section headers to walk, and the guard the walks share. +/// +/// The section table has no sentinel entry, so "iterate until VirtualSize is 0" +/// silently truncates the walk at the first section with a legitimately zero +/// VirtualSize (or a corrupt early field) — the later sections then keep the +/// packer's raw pointers and the image is broken with no error. Walk by +/// `NumberOfSections` instead, capped, with an all-zero-name break to guard the +/// other direction (a corrupt, overstated count): real sections always have a +/// name, header padding is all zero. +const MAX_SECTIONS: i32 = 96; + +fn section_count(file_data: &[u8], pe_offset: i32) -> i32 { + (get_u16(file_data, (pe_offset + 6) as u32) as i32).min(MAX_SECTIONS) +} + +fn section_header_blank(file_data: &[u8], off: i32) -> bool { + let s = off as usize; + match file_data.get(s..s + 8) { + Some(name) => name.iter().all(|&b| b == 0), + None => true, + } +} + +/// `ProcessImportTable`. +fn process_import_table(d: &mut [u8], mut import_table_offset: i32) { + while get_i32(d, import_table_offset + 12) != 0 { + let name_offset = get_i32(d, import_table_offset + 12); + decrypt_data7(d, name_offset as u32, name_offset as u8); + + let thunk_addr0 = get_i32(d, import_table_offset); + let orig_thunk_addr = get_i32(d, import_table_offset + 16); + let mut thunk_addr = if thunk_addr0 == 0 { + orig_thunk_addr + } else { + thunk_addr0 + }; + + loop { + // PE32+ thunks are 8 bytes: an ordinal import carries bit 63 with + // the ordinal in the low word; only a by-name thunk holds a + // hint/name RVA (in the low dword). Reading just the low dword + // would mistake an ordinal for a tiny RVA and scribble over the + // image header. + let v = get_u64(d, thunk_addr as u32); + if v == 0 { + break; + } + if (v & 0x8000_0000_0000_0000) == 0 { + let entry = v as u32; + decrypt_data7(d, entry.wrapping_add(2), entry as u8); + d[entry as usize] = 0; + d[entry.wrapping_add(1) as usize] = 0; + } + thunk_addr += 8; + } + import_table_offset += 20; + } +} + +/// `DecryptAndDecompressData`. +fn decrypt_and_decompress_data( + d: &mut [u8], + clean: &[u8], + section_image_base: i32, + mut section_data_offset: i32, + decrypt_func: &[Op], + decomp_params: &[i32; 4], +) -> Result<(), UnpackError> { + // Entry loop — Pass 1 (sequential): the 16-byte descriptors are decrypted + // in a position-keyed chain (decrypt_data6_shift6) terminated by a zero-size + // record, so collection cannot be parallelized. + struct Blk { + dest_offset: i32, + size: i32, + src_offset: i32, + expected_crc: i32, + } + let mut blocks: Vec = Vec::new(); + loop { + // Guard: need 16 bytes at section_data_offset in `d` + let off = section_data_offset as usize; + if off.saturating_add(16) > d.len() { + return Err(UnpackError::OutOfBounds(off)); + } + decrypt_data6_shift6(d, section_data_offset, 16); + let dest_offset = get_i32(d, section_data_offset); + let size = get_i32(d, section_data_offset + 4); + let src_offset = get_i32(d, section_data_offset + 8); + let expected_crc = get_i32(d, section_data_offset + 12); + section_data_offset += 16; + + if size == 0 { + break; + } + blocks.push(Blk { + dest_offset, + size, + src_offset, + expected_crc, + }); + } + // Pass 2: each block writes only [src_offset, src_offset+max(size,crc)) and + // reads only immutable input + the (snapshotted) key tables, so blocks with + // disjoint write spans are independent. `parallel_for` carves the spans + // into safe disjoint &mut slices (these blocks are only ever laid out + // disjointly; overlapping spans degrade to a sequential pass). + { + let lut = OpsLut::new(decrypt_func); + let ko0 = decomp_params[0]; + let ko2 = decomp_params[2]; + let ks_snap = + primitives::aes_schedule_snapshot(d, ko2 as u32).ok_or(UnpackError::Corrupt)?; + let tab_snap = primitives::huffman_table_snapshot(d, ko0 as u32) + .ok_or(UnpackError::DecompressFailed)?; + let spans: Vec<(usize, usize)> = blocks + .iter() + .map(|b| { + let s = b.src_offset as usize; + (s, s + b.size.max(b.expected_crc) as usize) + }) + .collect(); + let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> { + let b = &blocks[i]; + let src = (b.dest_offset as i64 + section_image_base as i64) as usize; + let rel = (b.src_offset as usize) - base; + let n = b.size as usize; + // Bounds-checked copy from `clean` (potentially truncated input). + primitives::try_copy_from_slice(span, rel, n, clean, src)?; + aes_decrypt_ks(&ks_snap, span, rel as u32, b.size as u32); + lut.map_region(span, rel, n); + if b.size != b.expected_crc { + // decompress reports corruption (after partial writes) via its + // bool; surface it instead of shipping a garbage block. + if !decompress_tbl( + &tab_snap, + span, + rel as u32, + rel as u32, + b.size as u32, + b.expected_crc as u32, + ) { + return Err(UnpackError::DecompressFailed); + } + } + Ok(()) + }; + super::parallel::parallel_for(d, &spans, 1, do_block)?; + } + + // Zero-fill loop. + loop { + let off = section_data_offset as usize; + // The entry block loop above correctly requires 16 bytes; this loop + // decrypts 16 too, so guard 16 (an 8-byte guard would let + // decrypt_data6_shift6 index past the end of a truncated descriptor). + if off.saturating_add(16) > d.len() { + return Err(UnpackError::OutOfBounds(off)); + } + decrypt_data6_shift6(d, section_data_offset, 16); + let zero_offset = get_i32(d, section_data_offset); + let zero_size = get_i32(d, section_data_offset + 4); + section_data_offset += 16; + + if zero_size == 0 { + break; + } + for i in 0..zero_size { + let idx = (zero_offset + i) as usize; + if idx >= d.len() { + return Err(UnpackError::OutOfBounds(idx)); + } + d[idx] = 0; + } + } + Ok(()) +} + +/// Unpack a native/managed DLL in the older protected-DLL layout. Returns the +/// unpacked image bytes. +pub fn unpack_dll(input: &[u8]) -> Result, UnpackError> { + unpack_dll_v(input, false) +} + +/// Like [`unpack_dll`], but prints detailed `[N/9]` step progress to stdout when +/// `verbose` is true. Output bytes are identical regardless. +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)) +} + +fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result, UnpackError> { + if input.len() < 4096 { + return Err(UnpackError::InputTooShort(input.len())); + } + + // `file_data` and `original_file_data` both borrow the same protected input. + let file_data = input; + let original_file_data = input; + + let keys = initialize_keys(file_data); + if verbose { + println!("[1/9] Initializing keys..."); + println!(" keys[0] key = 0x{:08X}", keys[0] as u32); + println!(" keys[1] signature = 0x{:08X}", keys[1] as u32); + println!(" keys[3] base = 0x{:08X}", keys[3] as u32); + println!(" keys[4] src_off = 0x{:08X}", keys[4] as u32); + println!(" keys[5] size = 0x{:08X}", keys[5] as u32); + println!(" keys[6] anchor = 0x{:08X}", keys[6] as u32); + } + + if !super::is_supported_magic(keys[1] as u32) { + return Err(UnpackError::DllUnpack( + "Not a Crackproof protected file (KONN magic mismatch)".into(), + )); + } + + let pe_offset = get_i32(file_data, 60); + if pe_offset < 0 || (pe_offset as usize).saturating_add(84) > file_data.len() { + return Err(UnpackError::DllUnpack("implausible PE offset".into())); + } + // This pipeline is PE32+-only: its header fixups write the data + // directories at PE32+ offsets (pe+144..180, pe+136 for the DD blob). On a + // PE32 image those land in the wrong optional-header fields and produce a + // structurally plausible but unloadable file. Reject early with a clear + // error so `unpack_auto`'s EXE-pipeline fallback handles PE32 DLLs (that + // path is PE32-aware — see run_pe32), instead of us mangling them here. + if get_i32(file_data, pe_offset + 24) & 0xFFFF != 0x20B { + return Err(UnpackError::DllUnpack( + "not a PE32+ image (the DLL pipeline handles 64-bit only)".into(), + )); + } + 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 { + return Err(UnpackError::DllUnpack("implausible SizeOfImage".into())); + } + let mut out = vec![0u8; size_of_image as usize]; + let base_offset = keys[6] - keys[3] + 0x2000; + if verbose { + println!("[2/9] Decrypting key table..."); + println!(" size_of_image = 0x{:08X}", size_of_image as u32); + println!(" base_offset = 0x{:08X}", base_offset as u32); + } + + // DecryptKeyTable. + { + let src_base = keys[4] + 4096; + let mut scramble = (!base_offset).wrapping_add(keys[0]); + let count = base_offset >> 2; + for i in 0..count { + let dst_offset = keys[3] + 4 * i; + let src_val = get_i32(file_data, src_base + 4 * i); + write_i32(&mut out, dst_offset, src_val ^ scramble); + scramble = (i * i) ^ (i.wrapping_add(src_val).wrapping_add(scramble)); + } + } + + // Array.Copy(fileData, keys[4]+baseOffset+4096, outputData, keys[3]+baseOffset, keys[5]-baseOffset) + { + let src = (keys[4] + base_offset + 4096) as usize; + let dst = (keys[3] + base_offset) as usize; + let n = (keys[5] - base_offset) as usize; + primitives::try_copy_from_slice(&mut out, dst, n, file_data, src)?; + } + write_i32(&mut out, keys[3], 4096); + out[..4096].copy_from_slice(&file_data[..4096]); + + let v144 = get_i32(&out, keys[6] + 5600); + let v148 = get_i32(&out, keys[6] + 5596); + let v152 = get_i32(&out, keys[6] + 5632); + let v156 = get_i32(&out, keys[6] + 5636); + write_i32(&mut out, pe_offset + 144, v144); + write_i32(&mut out, pe_offset + 148, v148); + write_i32(&mut out, pe_offset + 152, v152); + write_i32(&mut out, pe_offset + 156, v156); + write_i32(&mut out, pe_offset + 176, 0); + write_i32(&mut out, pe_offset + 180, 0); + + let mut checksum_offset1 = keys[6] + 5776; + let mut xor_accumulator: u32 = 0; + while get_i32(&out, checksum_offset1 + 4) != 0 { + xor_accumulator ^= calculate_checksum(&out, checksum_offset1 as u32); + checksum_offset1 += 8; + } + + let checksum1 = calculate_checksum(&out, (keys[6] + 5648) as u32) as i32; + let enc_key = get_u32(&out, (keys[6] + 5612) as u32); + let decrypt_offset1 = keys[6] + 5712; + decrypt_data3( + &mut out, + decrypt_offset1 as u32, + xor_accumulator ^ (checksum1 as u32) ^ enc_key, + 21, + ); + + let decrypted_addr1 = get_i32(&out, decrypt_offset1); + if verbose { + println!("[3/9] Decrypting primary descriptor..."); + println!(" xor_accumulator = 0x{:08X}", xor_accumulator); + println!(" checksum1 = 0x{:08X}", checksum1 as u32); + println!(" decrypted_addr1 = 0x{:08X}", decrypted_addr1 as u32); + } + let import_offset = get_i32(&out, decrypted_addr1 + 3444); + let decrypted_addr2_size = get_i32(&out, decrypted_addr1 + 3632); + decrypt_data3( + &mut out, + (decrypted_addr1 + 3632) as u32, + import_offset as u32, + 19, + ); + + let addr2 = decrypted_addr2_size; + let reloc_block_offset = addr2 + 9248; + let reloc_type = get_i32(&out, reloc_block_offset); + + if (reloc_type & 0x0F) == 1 { + decrypt_data3_shift5(&mut out, addr2 + 9252); + } else if reloc_type == 2 { + let p = get_i32(&out, addr2 + 9252); + process_reloc_block(&mut out, p); + } + + let reloc_block_offset2 = reloc_block_offset + 16; + let reloc_type2 = get_i32(&out, reloc_block_offset2); + + if (reloc_type2 & 0x0F) == 1 { + decrypt_data3_shift5(&mut out, reloc_block_offset2 + 4); + } else if reloc_type2 == 2 { + let p = get_i32(&out, reloc_block_offset2 + 4); + process_reloc_block(&mut out, p); + } + + let mut decomp_params = [0i32; 4]; + let param_base = addr2 + 9160; + decrypt_data3_shift5(&mut out, param_base); + decomp_params[0] = get_i32(&out, param_base); + decrypt_data3_shift5(&mut out, param_base + 8); + decomp_params[1] = get_i32(&out, param_base + 8); + decrypt_data3_shift5(&mut out, param_base + 32); + decomp_params[2] = get_i32(&out, param_base + 32); + decrypt_data3_shift5(&mut out, param_base + 40); + decomp_params[3] = get_i32(&out, param_base + 40); + if verbose { + println!("[4/9] Processing relocations & decomp params..."); + println!(" addr2 = 0x{:08X}", addr2 as u32); + println!( + " decomp_params = [0x{:08X}, 0x{:08X}, 0x{:08X}, 0x{:08X}]", + decomp_params[0] as u32, + decomp_params[1] as u32, + decomp_params[2] as u32, + decomp_params[3] as u32 + ); + } + + let checksum2 = calculate_checksum(&out, (keys[6] + 5640) as u32) as i32; + let mut table_val = get_i32(&out, decrypted_addr1 + 3448); + for k in 1..=100 { + table_val = table_val.wrapping_add(k); + } + for k in 1..=200 { + table_val = table_val.wrapping_add(k); + } + for k in 1..=300 { + table_val = table_val.wrapping_add(k); + } + for k in 1..=400 { + table_val = table_val.wrapping_add(k); + } + + let addr3_offset = decrypted_addr1 + 3712; + decrypt_data4( + &mut out, + addr3_offset, + table_val ^ checksum2 ^ (xor_accumulator as i32), + &decomp_params, + None, + )?; + + let addr3b = get_i32(&out, decrypted_addr1 + 3728); + if verbose { + println!("[5/9] Decrypting code block 1 (addr3)..."); + println!(" checksum2 = 0x{:08X}", checksum2 as u32); + println!(" addr3b = 0x{:08X}", addr3b as u32); + } + + let crc_data_offset = decrypted_addr1 + 3488; + let crc_data_addr = get_i32(&out, crc_data_offset); + let crc_data_size = get_i32(&out, crc_data_offset + 4); + 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 + }; + let crc_xored = crc_data_size ^ crc_val; + let trailing_val = get_i32(&out, crc_data_addr + crc_data_size - 4); + decrypt_data4( + &mut out, + decrypted_addr1 + 3728, + crc_xored ^ (xor_accumulator as i32) ^ trailing_val, + &decomp_params, + None, + )?; + + let checksum3 = calculate_checksum(&out, (decrypted_addr1 + 3480) as u32) as i32; + let not_val = !get_u32(&out, (addr3b + 1968) as u32); + let addr4_offset = decrypted_addr1 + 3760; + let xor_key = (xor_accumulator as i32) ^ checksum3; + decrypt_data4( + &mut out, + addr4_offset, + (not_val ^ (xor_key as u32)) as i32, + &decomp_params, + None, + )?; + + let addr4 = get_i32(&out, addr4_offset); + let lfsr = addr4 + 3200; + // Decompress == primitives::decrypt_data6 (LFSR keystream, len at +95). + decrypt_data6(&mut out, lfsr as u32); + if verbose { + println!("[6/9] Decrypting code blocks 2-3 (addr3b, addr4)..."); + println!(" crc_val = 0x{:08X}", crc_val as u32); + println!(" checksum3 = 0x{:08X}", checksum3 as u32); + println!(" addr4 = 0x{:08X}", addr4 as u32); + } + + let checksum4 = calculate_checksum(&out, (decrypted_addr1 + 3472) as u32) as i32; + let mut lfsr_seed_val = get_i32(&out, addr4 + 3160); + for k in 1..=100 { + lfsr_seed_val = lfsr_seed_val.wrapping_add(k); + } + for k in 1..=200 { + lfsr_seed_val = lfsr_seed_val.wrapping_add(k); + } + for k in 1..=300 { + lfsr_seed_val = lfsr_seed_val.wrapping_add(k); + } + + let decrypt_func = generate(&out, lfsr as u32) + .ok_or_else(|| UnpackError::DllUnpack("Failed to build decryption expression".into()))?; + + let addr5_offset = decrypted_addr1 + 3840; + let addr5 = get_i32(&out, addr5_offset); + decrypt_data4( + &mut out, + addr5_offset, + lfsr_seed_val ^ xor_key ^ checksum4, + &decomp_params, + Some(&decrypt_func), + )?; + if verbose { + println!("[7/9] Decrypting code block 4 (addr5)..."); + println!(" checksum4 = 0x{:08X}", checksum4 as u32); + println!(" addr5 = 0x{:08X}", addr5 as u32); + } + + let metadata_offset = addr5 + 12312; + let mut metadata_addr = get_i32(&out, metadata_offset); + + while get_i32(&out, metadata_addr + 4) != 0 { + decrypt_data6_shift6(&mut out, metadata_addr, 16); + metadata_addr += 16; + } + + let lfsr2 = metadata_offset + 88; + decrypt_data6(&mut out, lfsr2 as u32); + + let decrypt_func2 = generate(&out, lfsr2 as u32).ok_or_else(|| { + UnpackError::DllUnpack("Failed to build second decryption expression".into()) + })?; + + let section_image_base = 4095 - get_i32(original_file_data, 4224); + let section_data_offset = get_i32(&out, addr5 + 11976); + if verbose { + println!("[8/9] Decrypting & decompressing sections..."); + println!( + " section_image_base = 0x{:08X}", + section_image_base as u32 + ); + println!( + " section_data_offset = 0x{:08X}", + section_data_offset as u32 + ); + } + + // Managed-only pre-fill of .text (Task 3.2). No-op for native (clr_rva == 0). + // Data directories start at optional-header +96 on PE32, +112 on PE32+ — + // hardcoding +112 misreads the CLR RVA on a 32-bit image. + let dd_base = if get_u16(file_data, (pe_offset + 24) as u32) == 0x20B { + 112 + } else { + 96 + }; + let clr_dir_rva = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8); + if clr_dir_rva != 0 { + let sh_start = get_u16(file_data, (pe_offset + 20) as u32) as i32 + pe_offset + 24; + for i in 0..section_count(file_data, pe_offset) { + let off = sh_start + i * 40; + if section_header_blank(file_data, off) { + break; + } + let sec_va = get_i32(file_data, off + 12); + let sec_vsize = get_i32(file_data, off + 8); + let sec_raw = get_i32(file_data, off + 20); + let sec_raw_size = get_i32(file_data, off + 16); + if clr_dir_rva >= sec_va && clr_dir_rva < sec_va + sec_vsize { + let avail = sec_raw_size.min(file_data.len() as i32 - sec_raw); + let copy_len = avail.min(out.len() as i32 - sec_va); + if copy_len > 0 { + let s = sec_raw as usize; + let dd = sec_va as usize; + let n = copy_len as usize; + out[dd..dd + n].copy_from_slice(&file_data[s..s + n]); + } + break; + } + } + } + + decrypt_and_decompress_data( + &mut out, + original_file_data, + section_image_base, + section_data_offset, + &decrypt_func2, + &decomp_params, + )?; + + let import_table_offset = get_i32(&out, addr5 + 12016); + if import_table_offset != 0 { + process_import_table(&mut out, import_table_offset); + } + + out[..4096].copy_from_slice(&file_data[..4096]); + if verbose { + println!("[9/9] Fixing up PE header & section table..."); + } + + let section_header_base = get_u16(file_data, (pe_offset + 20) as u32) as i32 + pe_offset; + let section_start = section_header_base + 24; + let image_data_addr = get_i32(original_file_data, section_start - 128); + let image_data_size = get_i32(original_file_data, section_start - 124); + + let mut entry_point_adjustment = 0i32; + { + for i in 0..section_count(file_data, pe_offset) { + let offset = section_start + i * 40; + if section_header_blank(file_data, offset) { + break; + } + let virtual_size = get_i32(file_data, offset + 8); + let virtual_addr = get_i32(file_data, offset + 12); + let raw_data_offset = get_i32(file_data, offset + 20); + + if image_data_addr >= virtual_addr + && image_data_addr + image_data_size <= virtual_addr + virtual_size + { + entry_point_adjustment = raw_data_offset + image_data_addr - virtual_addr; + } + + write_i32(&mut out, offset + 16, virtual_size); + write_i32(&mut out, offset + 20, virtual_addr); + } + } + + if image_data_size != 0 { + let s = entry_point_adjustment as usize; + let dd = image_data_addr as usize; + let n = image_data_size as usize; + out[dd..dd + n].copy_from_slice(&file_data[s..s + n]); + } + + // Managed-only CLR header recopy (Task 3.2). No-op for native. + let clr_rva = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8); + let clr_size = get_i32(file_data, pe_offset + 24 + dd_base + 14 * 8 + 4); + if clr_rva != 0 && clr_size != 0 { + for i in 0..section_count(file_data, pe_offset) { + let offset = section_start + i * 40; + if section_header_blank(file_data, offset) { + break; + } + let sec_va = get_i32(file_data, offset + 12); + let sec_raw = get_i32(file_data, offset + 20); + let sec_vsize = get_i32(file_data, offset + 8); + if clr_rva >= sec_va && clr_rva + clr_size <= sec_va + sec_vsize { + let clr_file_off = sec_raw + (clr_rva - sec_va); + let s = clr_file_off as usize; + let dd = clr_rva as usize; + let n = clr_size as usize; + out[dd..dd + n].copy_from_slice(&file_data[s..s + n]); + break; + } + } + } + + decrypt_data6_shift6(&mut out, keys[3] + 16, 656); + let pe_offset2 = get_i32(&out, 60); + let final_size_of_image = get_i32(&out, keys[3] + 32); + write_i32(&mut out, pe_offset2 + 40, final_size_of_image); + { + let s = (keys[3] + 48) as usize; + let dd = (pe_offset2 + 136) as usize; + out.copy_within(s..s + 128, dd); + } + + Ok(out) +} diff --git a/src/unpacker/exe.rs b/src/unpacker/exe.rs new file mode 100644 index 0000000..cdf0470 --- /dev/null +++ b/src/unpacker/exe.rs @@ -0,0 +1,2812 @@ +use super::bytecode::{Op, OpsLut, generate}; +use super::primitives; +use super::primitives::*; + +#[derive(Debug, thiserror::Error)] +pub enum UnpackError { + #[error("input too short for header (need at least 4096 bytes, got {0})")] + InputTooShort(usize), + + #[error("info[1] mismatch — corrupt data or wrong offset")] + HeaderMismatch, + + #[error("anchor field not found — corrupt data or wrong offset")] + AnchorNotFound, + + #[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("stage4 bytecode generation failed — corrupt data or wrong offset")] + BytecodeGenFailed, + + #[error("stage5 marker not found — this build's layout is not supported by this unpacker")] + Stage5MarkerNotFound, + + #[error("stage5 bytecode generation failed — corrupt data or wrong offset")] + Stage5BytecodeGenFailed, + + #[error("DLL unpack failed: {0}")] + DllUnpack(String), + + #[error("not a Crackproof-protected file")] + NotCrackproof, + + #[error("out-of-bounds access at offset {0}")] + OutOfBounds(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 stage bytecode generation failed")] + Pe32BytecodeGenFailed, + + #[error("PE32 eighthStageKey not found")] + Pe32EighthKeyNotFound, + + #[error("PE32 file LFSR not found in eighthStage")] + Pe32FileLfsrNotFound, + + #[error("decompression failed — corrupt data or wrong offset")] + DecompressFailed, + + #[error("input is corrupt or not a supported Crackproof layout")] + Corrupt, +} + +pub fn unpack(input: &[u8]) -> Result, UnpackError> { + unpack_v(input, false) +} + +/// Map an RVA to a file offset using the protected file's section table. +/// Used by the new-layout managed (CLR) metadata restore to locate the COR20 +/// header and BSJB MetaData stream in the original protected file. +fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option { + let nsec = get_u16(file_data, pe_header + 6) as u32; + let opt = get_u16(file_data, pe_header + 20) as u32; + let tab = pe_header + 24 + opt; + for i in 0..nsec { + let s = tab + i * 40; + if (s as usize + 24) > file_data.len() { + return None; + } + let va = get_u32(file_data, s + 12); + let vs = get_u32(file_data, s + 8); + let rsz = get_u32(file_data, s + 16); + let rp = get_u32(file_data, s + 20); + if va <= rva && rva < va + vs.max(rsz) { + return Some(rp + (rva - va)); + } + } + None +} + +pub fn unpack_v(input: &[u8], verbose: bool) -> Result, UnpackError> { + if input.len() < 4096 { + return Err(UnpackError::InputTooShort(input.len())); + } + // The pipeline chases offsets read out of the decrypted image; on a + // truncated/garbled-but-detected file those run out of bounds. Trap any + // such panic and report it as corrupt input so the library never unwinds + // into the caller (same role as the DLL path's explicit bounds checks). + // `input` is read-only for the entire unpack (the payload is decrypted into a + // 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)) +} + +struct Unpacker<'a> { + file_data: &'a [u8], + decompressed: Vec, + info: [u32; 8], + key_offsets: [u32; 4], + decrypt_size: u32, +} + +impl<'a> Unpacker<'a> { + // Strategy (a): delegate to primitives::aes_decrypt + fn aes_decrypt(&mut self, pos: u32, size: u32, key_offset: u32) { + primitives::aes_decrypt(&mut self.decompressed, pos, size, key_offset); + } + + // Strategy (a): delegate to primitives::calculate_checksum + fn calculate_checksum(&self, pos: u32) -> u32 { + primitives::calculate_checksum(&self.decompressed, pos) + } + + // Strategy (a): delegate to primitives::calculate_checksum2 + fn calculate_checksum2(&self, pos: u32, start: u32) -> u32 { + primitives::calculate_checksum2(&self.decompressed, self.file_data, pos, start) + } + + // Strategy (a): delegate to primitives::decrypt_data1 + fn decrypt_data(&mut self) { + primitives::decrypt_data1(self.file_data, &mut self.info); + } + + // Strategy (b): decrypt_data2 is tightly coupled to Unpacker fields + // (file_data, decompressed, info, decrypt_size); left on Unpacker. + // The DLL port will need a different driver state anyway. + fn decrypt_data2(&mut self) { + let base_src = self.info[4].wrapping_add(4096); + let mut k = self.info[0].wrapping_add(!self.decrypt_size); + let words = self.decrypt_size >> 2; + for i in 0..words { + let off = i.wrapping_mul(4); + let cell = get_u32(self.file_data, base_src.wrapping_add(off)); + write_u32( + &mut self.decompressed, + self.info[3].wrapping_add(off), + k ^ cell, + ); + k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_add(i)); + } + } + + // Strategy (a): delegate to primitives::decrypt_data3 + fn decrypt_data3(&mut self, pos: u32, key: u32, shift: u32) { + primitives::decrypt_data3(&mut self.decompressed, pos, key, shift); + } + + // Strategy (b): decrypt_data4 is specific to EXE key-offset layout; + // left on Unpacker. DLL port will need its own variant. + fn decrypt_data4(&mut self, pos: u32) { + let base_addr = get_u32(&self.decompressed, pos); + let length = get_u32(&self.decompressed, pos.wrapping_add(4)); + let mut b: u8 = (((base_addr >> 8).wrapping_add(base_addr)) & 0xFF) as u8; + let mut b2: u8 = b.wrapping_add(1); + for i in 0..length { + let idx = (base_addr + i) as usize; + let b3 = self.decompressed[idx]; + let b4 = b3.rotate_left(3) ^ b2; + let b5 = b4.rotate_left(3) ^ b; + self.decompressed[idx] = b5.rotate_left(3); + b = b.wrapping_add(1); + b2 = b2.wrapping_add(1); + } + } + + // Strategy (b): decrypt_data5 is frequently called with the EXE's virtual + // address convention; kept on Unpacker to avoid signature changes at call + // sites. DLL port can call primitives directly with explicit slice. + fn decrypt_data5(&mut self, va: u32, size: u32) { + let mut b: u8 = va as u8; + let mut b2: u8 = b.wrapping_add(1); + for i in 0..size { + let idx = (va + i) as usize; + let b3 = self.decompressed[idx]; + let b4 = b3.rotate_left(2) ^ b2; + let b5 = b4.rotate_left(2) ^ b; + self.decompressed[idx] = b5.rotate_left(2); + b = b.wrapping_add(1); + b2 = b2.wrapping_add(1); + } + } + + // Strategy (a): delegate to primitives::decrypt_data6 + fn decrypt_data6(&mut self, pos: u32) { + primitives::decrypt_data6(&mut self.decompressed, pos); + } + + // Strategy (a): delegate to primitives::decrypt_data7 + fn decrypt_data7(&mut self, pos: u32, key: u8) { + primitives::decrypt_data7(&mut self.decompressed, pos, key); + } + + // Strategy (b): decrypt_data8 is specific to the EXE's .text page-level + // XOR pass; left on Unpacker. DLL port won't need this. + fn decrypt_data8(&mut self, va: u32, size: u32, mut key: u32) { + let blocks = size >> 4; + for i in 0..blocks { + let mixed = key.rotate_right(15).wrapping_add(i); + key = mixed.wrapping_add(i); + // The packer's dd8 loop does not XOR block i=0; its key state still + // advances. For shift-0 builds the i=0 XOR value is always 0 (a + // no-op), so this is byte-identical for the older EXE-64 family; + // for shift-15 builds it is the difference between a clean + // .text and one corrupt byte per page. + if i == 0 { + continue; + } + let target = va + .wrapping_add(i.wrapping_mul(16)) + .wrapping_add(mixed & 0xF); + self.decompressed[target as usize] ^= key as u8; + } + } + + // Strategy (a): delegate to primitives::decrypt_and_decompress_data + fn decrypt_and_decompress_data(&mut self, pos: u32, key: u32, custom: Option<&[Op]>) -> bool { + primitives::decrypt_and_decompress_data( + &mut self.decompressed, + pos, + key, + self.key_offsets[1], + self.key_offsets[3], + custom, + ) + } + + /// New-layout (marker-less) import reconstruction from the PE Import Directory. + /// Walks each IMAGE_IMPORT_DESCRIPTOR at `import_rva`, decrypt_data7-decrypts + /// and lowercases the DLL name, then walks the (OFT|IFT) thunk array + /// decrypting each by-name import's hint/name string, and recovers the IAT + /// directory (DD[12]) bounds. + fn process_imports_idt(&mut self, import_rva: u32, mut import_size: u32, pe_off2: u32) { + let len = self.decompressed.len() as u32; + let mut dll_count: u32 = 0; + let mut iat_min: u32 = 0xFFFF_FFFF; + let mut iat_max: u32 = 0; + let mut pos = import_rva; + let find_nul = |d: &[u8], from: u32, to: u32| -> u32 { + let hi = (to as usize).min(d.len()); + let mut i = from as usize; + while i < hi { + if d[i] == 0 { + return i as u32; + } + i += 1; + } + hi as u32 + }; + let all_ascii = |d: &[u8], a: u32, b: u32| -> bool { + d[a as usize..b as usize] + .iter() + .all(|&c| (0x20..0x7F).contains(&c)) + }; + while pos + 20 <= len { + let name_rva = get_u32(&self.decompressed, pos + 12); + if name_rva == 0 || name_rva >= len { + break; + } + // Decrypt the DLL name unless it already reads as a plain ASCII + // *.dll / *.exe string. + let end = find_nul(&self.decompressed, name_rva, name_rva + 64); + let mut already_plain = false; + if end > name_rva + && end - name_rva <= 60 + && all_ascii(&self.decompressed, name_rva, end) + { + let s = &self.decompressed[name_rva as usize..end as usize]; + let lower: Vec = s.iter().map(|c| c.to_ascii_lowercase()).collect(); + if lower.ends_with(b".dll") || lower.ends_with(b".exe") { + already_plain = true; + } + } + if !already_plain { + self.decrypt_data7(name_rva, name_rva as u8); + } + // Normalize to lowercase. + let end = find_nul(&self.decompressed, name_rva, name_rva + 64); + if end > name_rva && all_ascii(&self.decompressed, name_rva, end) { + for b in &mut self.decompressed[name_rva as usize..end as usize] { + b.make_ascii_lowercase(); + } + } + + let oft = get_u32(&self.decompressed, pos); + let ift = get_u32(&self.decompressed, pos + 16); + let mut thunk = if oft != 0 { oft } else { ift }; + if 0 < ift && ift < len { + iat_min = iat_min.min(ift); + } + while thunk != 0 && thunk + 8 <= len { + let v = get_u64(&self.decompressed, thunk); + if v == 0 { + break; + } + if (v & 0x8000_0000_0000_0000) == 0 { + let r = (v & 0xFFFF_FFFF) as u32; + if r + 2 < len { + let fend = find_nul(&self.decompressed, r + 2, r + 2 + 256); + let already = fend > r + 2 + && fend - (r + 2) <= 250 + && all_ascii(&self.decompressed, r + 2, fend); + if !already { + self.decrypt_data7(r + 2, r as u8); + write_u16(&mut self.decompressed, r, 0); + } + } + } + thunk += 8; + } + if 0 < ift && ift < len { + let mut tp = ift; + while tp + 8 <= len { + let v2 = get_u64(&self.decompressed, tp); + tp += 8; + if v2 == 0 { + break; + } + } + iat_max = iat_max.max(tp); + } + dll_count += 1; + pos += 20; + } + if import_size == 0 { + import_size = (dll_count + 1) * 20; + write_u32( + &mut self.decompressed, + pe_off2.wrapping_add(0x94), + import_size, + ); + } + if iat_min < iat_max { + write_u32(&mut self.decompressed, pe_off2.wrapping_add(0xE8), iat_min); + write_u32( + &mut self.decompressed, + pe_off2.wrapping_add(0xEC), + iat_max - iat_min, + ); + } + } + + fn run(file_data: &'a [u8], verbose: bool) -> Result, UnpackError> { + let mut u = Unpacker { + file_data, + decompressed: Vec::new(), + info: [0u32; 8], + key_offsets: [0u32; 4], + decrypt_size: 0, + }; + + u.decrypt_data(); + if verbose { + println!("[1/9] Decrypting file header..."); + println!(" info[0] key = 0x{:08X}", u.info[0]); + println!(" info[1] signature = 0x{:08X}", u.info[1]); + println!(" info[2] = 0x{:08X}", u.info[2]); + println!(" info[3] base = 0x{:08X}", u.info[3]); + println!(" info[4] src_off = 0x{:08X}", u.info[4]); + println!(" info[5] total_size = 0x{:08X}", u.info[5]); + 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]) { + return Err(UnpackError::HeaderMismatch); + } + + let pe_off = get_u32(u.file_data, 60); + 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 { + return Err(UnpackError::Corrupt); + } + u.decompressed = vec![0u8; size_of_image as usize]; + u.decrypt_size = u.info[6].wrapping_sub(u.info[3]).wrapping_add(8192); + if verbose { + println!("[2/9] Decrypting payload..."); + } + u.decrypt_data2(); + + { + let src_start = u.info[4].wrapping_add(4096).wrapping_add(u.decrypt_size) as usize; + let dst_start = u.info[3].wrapping_add(u.decrypt_size) as usize; + let len = u.info[5].wrapping_sub(u.decrypt_size) as usize; + u.decompressed[dst_start..dst_start + len] + .copy_from_slice(&u.file_data[src_start..src_start + len]); + } + write_u32(&mut u.decompressed, u.info[3], 4096); + u.decompressed[..4096].copy_from_slice(&u.file_data[..4096]); + + // PE32 (32-bit) images use an entirely different config layout and + // final-output transform than PE32+ (64-bit). Dispatch here, after the + // shared header/payload setup (the PE32 branch follows the common + // Stage 1/2 work). pe_magic at pe_off+24: 0x10B = PE32, 0x20B = PE32+. + if get_u16(u.file_data, pe_off.wrapping_add(24)) == 0x10B { + return u.run_pe32(pe_off, verbose); + } + + // The config block layout in the decrypted region varies between Crackproof + // versions. Find the anchor field — a dword equal to info[3], immediately + // followed by 0x28 and then info[6]-0x200 — and derive every other field + // offset relative to it. Observed anchor positions: +6728 (older EXE + // builds), +6744 (another old-layout build, +16), +5592 (managed-assembly + // builds, -1136). + let info6 = u.info[6]; + let mut anchor: Option = None; + let mut probe = info6.wrapping_add(1000); + let probe_end = info6.wrapping_add(8000); + while probe + 12 <= probe_end && (probe as usize + 12) <= u.decompressed.len() { + if get_u32(&u.decompressed, probe) == u.info[3] { + let v8 = get_u32(&u.decompressed, probe.wrapping_add(8)); + // v8 must be slightly less than info[6], aligned to 0x200, and + // close to it. Older EXE builds stay within 0x800; the + // external-companion DLLs reach 0xC00, so the window is 0x1000. + // The dword==info[3] equality is already a 32-bit match on the + // base RVA, making this secondary bound's exact value + // non-critical for false-positive rejection. + if v8 < info6 { + let delta = info6.wrapping_sub(v8); + if delta <= 0x1000 && delta.is_multiple_of(0x200) { + anchor = Some(probe); + break; + } + } + } + probe = probe.wrapping_add(4); + } + let anchor = match anchor { + Some(a) => a, + None => { + return Err(UnpackError::AnchorNotFound); + } + }; + + // Detect config-block layout version. Newer Crackproof builds (observed + // across several EXE families) shift every anchor-relative field from + // offset 40 onward by +8 bytes. The config-version stamp sits at + // anchor+104 in the old layout and anchor+112 in the new one. Across + // the whole corpus the stamp's top nibble is always 0x4 (top byte 0x40 + // or 0x44), whereas the +8 layout's anchor+104 holds an inserted small + // count (top nibble 0), so the stamp position is a reliable layout + // discriminator. + let stamp_at = |off: u32| -> bool { + (anchor + off + 4) as usize <= u.decompressed.len() + && (get_u32(&u.decompressed, anchor + off) >> 28) == 0x4 + }; + let magic_off: u32 = if stamp_at(104) { + 104 + } else if stamp_at(112) { + 112 + } else { + 104 + }; + let anchor_extra: u32 = magic_off - 104; + + let p1 = get_u32(&u.decompressed, anchor.wrapping_add(8)); + let p2 = get_u32(&u.decompressed, anchor.wrapping_add(4)); + let p3 = get_u32(&u.decompressed, anchor.wrapping_add(40 + anchor_extra)); + let p4 = get_u32(&u.decompressed, anchor.wrapping_add(44 + anchor_extra)); + // Save the anchor-stage import dir (`saved_import_rva` / `saved_import_size`). + // On the new layout the metadata data-dirs may carry a zero import entry + // (esp. managed DLLs); this anchor value is the fallback. + let saved_import_rva = p1; + let saved_import_size = p2; + write_u32(&mut u.decompressed, pe_off.wrapping_add(144), p1); + write_u32(&mut u.decompressed, pe_off.wrapping_add(148), p2); + write_u32(&mut u.decompressed, pe_off.wrapping_add(152), p3); + write_u32(&mut u.decompressed, pe_off.wrapping_add(156), p4); + write_u32(&mut u.decompressed, pe_off.wrapping_add(176), 0); + write_u32(&mut u.decompressed, pe_off.wrapping_add(180), 0); + + let mut walk = anchor.wrapping_add(184 + anchor_extra); + let mut xor_acc: u32 = 0; + while get_u32(&u.decompressed, walk.wrapping_add(4)) != 0 { + xor_acc ^= u.calculate_checksum(walk); + walk = walk.wrapping_add(8); + } + let chk1 = u.calculate_checksum(anchor.wrapping_add(56 + anchor_extra)); + let v_at = anchor.wrapping_add(20); + let v = get_u32(&u.decompressed, v_at); + let tgt = anchor.wrapping_add(120 + anchor_extra); + u.decrypt_data3(tgt, xor_acc ^ chk1 ^ v, 21); + let stage1 = get_u32(&u.decompressed, tgt); + if verbose { + println!("[3/9] Locating config layout..."); + println!(" stage1 = 0x{:08X}", stage1); + } + + // Field offsets inside stage1 vary between Crackproof versions. Locate + // stage2 (the only 16-byte entry where dword[0]==dword[2], dword[1] is the + // large encrypted size and dword[3] is a smaller decompressed size), then + // derive every other field as fixed offsets from there. Observed + // stage2_off: 3632 (older EXE builds), 3616 (another old-layout build), + // 3624 (managed-assembly builds). + let stage1_len = get_u32(&u.decompressed, tgt.wrapping_add(4)); + let info3 = u.info[3]; + let info5 = u.info[5]; + // Use the full info[3]..info[3]+info[5] range: stage entries may live in + // either the decrypt_data2 zone or the raw-copy zone (managed-assembly + // builds' stage3 sits just before the raw-copy boundary). + let raw_lo = info3; + let raw_hi = info3.wrapping_add(info5); + let mut stage2_off: Option = None; + let scan_lo: u32 = 3000; + let scan_hi: u32 = stage1_len.saturating_sub(16); + let mut off = scan_lo; + while off < scan_hi { + let p = stage1.wrapping_add(off); + if (p as usize + 16) <= u.decompressed.len() { + let d0 = get_u32(&u.decompressed, p); + let d1 = get_u32(&u.decompressed, p.wrapping_add(4)); + let d2 = get_u32(&u.decompressed, p.wrapping_add(8)); + let d3 = get_u32(&u.decompressed, p.wrapping_add(12)); + if d0 == d2 && d0 >= raw_lo && d0 < raw_hi && d1 > 0x10000 && d3 > 0 && d3 < d1 { + stage2_off = Some(off); + break; + } + } + off = off.wrapping_add(4); + } + let stage2_off = match stage2_off { + Some(x) => x, + None => { + return Err(UnpackError::Stage2NotFound); + } + }; + + // Find chk_src_start by walking back from stage2 looking for the first + // position where 4 consecutive (src, len) entries all sit in the raw-copy + // range with sensible lengths. The table holds chks for stage5/4/3b/3 in + // that order; stage1+3472/3480/3488 correspond to chk_src_start + 8 / +16 + // / +24. + let mut chk_src_start: Option = None; + let mut probe = stage2_off.wrapping_sub(200); + while probe + 32 <= stage2_off { + let mut all_valid = true; + for i in 0..4u32 { + let pp = stage1.wrapping_add(probe + i * 8); + let s = get_u32(&u.decompressed, pp); + let l = get_u32(&u.decompressed, pp.wrapping_add(4)); + if s < raw_lo || s >= raw_hi || l == 0 || l >= 0x10000 { + all_valid = false; + break; + } + } + if all_valid { + chk_src_start = Some(probe); + break; + } + probe = probe.wrapping_add(4); + } + let chk_src_start = match chk_src_start { + Some(x) => x, + None => { + return Err(UnpackError::ChkSrcStartNotFound); + } + }; + + let key_at = stage1.wrapping_add(chk_src_start.wrapping_sub(20)); + let key2 = get_u32(&u.decompressed, key_at); + let stage1b = stage1.wrapping_add(stage2_off); + u.decrypt_data3(stage1b, key2, 19); + let stage2 = get_u32(&u.decompressed, stage1b); + if verbose { + println!("[4/9] Decrypting stage2..."); + println!(" stage2 = 0x{:08X}", stage2); + } + + // The stage2 head/walk2 tables shift between Crackproof versions. The 4-entry + // table starts with a `kind=1, 0, info[3], 0` 16-byte entry; head is two + // entries (32 bytes) past that, walk2 sits 88 bytes before head. Observed + // table_start: 9136 (older EXE builds) and 9216 (another old-layout build + // / managed-assembly builds). + let mut table_start: Option = None; + let mut sc = 8000u32; + while sc + 16 < 12000 { + let p = stage2.wrapping_add(sc); + if (p as usize + 16) <= u.decompressed.len() { + let d0 = get_u32(&u.decompressed, p); + let d1 = get_u32(&u.decompressed, p.wrapping_add(4)); + let d2 = get_u32(&u.decompressed, p.wrapping_add(8)); + let d3 = get_u32(&u.decompressed, p.wrapping_add(12)); + if d0 == 1 && d1 == 0 && d2 == info3 && d3 == 0 { + table_start = Some(sc); + break; + } + } + sc = sc.wrapping_add(4); + } + let table_start = match table_start { + Some(x) => x, + None => { + return Err(UnpackError::TableStartNotFound); + } + }; + let head_off = table_start.wrapping_add(32); + let walk2_off = head_off.wrapping_sub(88); + + let mut head = stage2.wrapping_add(head_off); + for _iter in 0..2 { + let kind = get_u32(&u.decompressed, head); + // Some builds use kind=0x11 in place of kind=1 for the same operation + // (the upper nibble appears to be a build-version stamp). + if kind & 0x0F == 1 { + u.decrypt_data4(head.wrapping_add(4)); + } else if kind == 2 { + let mut p = get_u32(&u.decompressed, head.wrapping_add(4)); + loop { + u.decrypt_data5(p, 16); + let p0 = p; + let s = get_u32(&u.decompressed, p0); + let n = get_u32(&u.decompressed, p0.wrapping_add(4)); + let dst = get_u32(&u.decompressed, p0.wrapping_add(8)); + let chk = get_u32(&u.decompressed, p0.wrapping_add(12)); + p = p.wrapping_add(16); + if s != 0 && n != 0 && dst != 0 && chk == n { + let ss = s as usize; + let dd = dst as usize; + let nn = n as usize; + u.decompressed.copy_within(ss..ss + nn, dd); + } + if get_u32(&u.decompressed, p.wrapping_sub(16).wrapping_add(4)) == 0 { + break; + } + } + } + head = head.wrapping_add(16); + } + + let mut walk2 = stage2.wrapping_add(walk2_off); + for j in 0..2usize { + let mut p = walk2; + for k in 0..2usize { + u.decrypt_data4(p); + u.key_offsets[j * 2 + k] = get_u32(&u.decompressed, p); + p = p.wrapping_add(8); + } + walk2 = walk2.wrapping_add(32); + } + + let chk2 = u.calculate_checksum(anchor.wrapping_add(48 + anchor_extra)); + let accum_at = stage1.wrapping_add(chk_src_start.wrapping_sub(16)); + let mut accum = get_u32(&u.decompressed, accum_at); + for l in 0..4u32 { + let bound = (l + 1).wrapping_mul(25) << 2; + let mut i: u32 = 1; + while i <= bound { + accum = accum.wrapping_add(i); + i = i.wrapping_add(1); + } + } + + let at1 = stage1.wrapping_add(stage2_off.wrapping_add(88)); + let stage3_field = get_u32(&u.decompressed, at1); + let stage3_dlen = get_u32(&u.decompressed, at1.wrapping_add(12)); + if verbose { + println!("[5/9] Decrypting stages 3-5..."); + println!(" stage3 = 0x{:08X}", stage3_field); + } + if !u.decrypt_and_decompress_data(at1, xor_acc ^ chk2 ^ accum, None) { + return Err(UnpackError::DecompressFailed); + } + + let at2 = stage1.wrapping_add(stage2_off.wrapping_add(104)); + let stage3b_field = get_u32(&u.decompressed, at2); + let stage3b_dlen = get_u32(&u.decompressed, at2.wrapping_add(12)); + if verbose { + println!(" stage3b = 0x{:08X}", stage3b_field); + } + let chk3 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(24))); + // v4_val lives at the end of stage3, immediately following `C3 CC CC CC` + // (function epilogue + 3-byte int3 padding), with zeros to end of buffer. + // Scan stage3 from the end for the last non-zero dword anchored by the + // C3+CC pattern. + let v4 = find_v4_offset(&u.decompressed, stage3_field, stage3_dlen) + .unwrap_or_else(|| stage3_field.wrapping_add(4692)); + let v4_val = get_u32(&u.decompressed, v4); + if !u.decrypt_and_decompress_data(at2, xor_acc ^ chk3 ^ v4_val, None) { + return Err(UnpackError::DecompressFailed); + } + + let chk4 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(16))); + // v5_val sits 8 bytes before the first API name string in stage3b's + // hash/slot/name table. The table starts with hash(VirtualFree) + + // slot(8 bytes total before the "Virtual..." ASCII). The hardcoded + // offset 1960 corresponds to (string_pos - 8) for older EXE builds. + let v5 = find_str_pos(&u.decompressed, stage3b_field, stage3b_dlen, b"Virtual") + .map(|p| p.wrapping_sub(8)) + .unwrap_or_else(|| stage3b_field.wrapping_add(1960)); + let v5_val = !get_u32(&u.decompressed, v5); + let at3 = stage1.wrapping_add(stage2_off.wrapping_add(136)); + let stage4_field = get_u32(&u.decompressed, at3); + let stage4_dlen = get_u32(&u.decompressed, at3.wrapping_add(12)); + if verbose { + println!(" stage4 = 0x{:08X}", stage4_field); + } + if !u.decrypt_and_decompress_data(at3, xor_acc ^ chk4 ^ v5_val, None) { + return Err(UnpackError::DecompressFailed); + } + + // Inside stage4, two locations vary by build: + // accum2 source (offset 3504) sits 24 bytes before "IsDebuggerPresent" + // bytecode block (offset 3584) sits at the first 16-byte aligned + // boundary at or after the end of "CheckRemoteDebuggerPresent\0". + let idb_pos = find_str_pos( + &u.decompressed, + stage4_field, + stage4_dlen, + b"IsDebuggerPresent", + ) + .unwrap_or_else(|| stage4_field.wrapping_add(3528)); + let crdp_pos = find_str_pos( + &u.decompressed, + stage4_field, + stage4_dlen, + b"CheckRemoteDebuggerPresent", + ) + .unwrap_or_else(|| stage4_field.wrapping_add(3553)); + // Trial-decrypt every 16-byte aligned position to find the bytecode block. + // Works whether or not the build has IsDebuggerPresent/CRDP API strings. + let data_offset = find_bytecode_offset(&u.decompressed, stage4_field, stage4_dlen) + .or_else(|| { + find_bytecode_offset(&u.decompressed, stage4_field, stage4_dlen.saturating_mul(2)) + }) + .unwrap_or_else(|| { + let crdp_end = crdp_pos.wrapping_add("CheckRemoteDebuggerPresent\0".len() as u32); + (crdp_end.wrapping_add(15)) & !15u32 + }); + u.decrypt_data6(data_offset); + let chk5 = u.calculate_checksum(stage1.wrapping_add(chk_src_start.wrapping_add(8))); + // accum2 sits 4 bytes past the function-end `48 EB 01 B9` + any CC padding + // (i.e., the 4 bytes immediately after the last instance of that pattern). + let v6 = find_v_after_pad(&u.decompressed, stage4_field, stage4_dlen) + .unwrap_or_else(|| idb_pos.wrapping_sub(24)); + let mut accum2 = get_u32(&u.decompressed, v6); + for m in 0..3u32 { + let bound = (m + 1).wrapping_mul(25) << 2; + let mut i: u32 = 1; + while i <= bound { + accum2 = accum2.wrapping_add(i); + i = i.wrapping_add(1); + } + } + + let ops1 = match generate(&u.decompressed, data_offset) { + Some(v) => v, + None => { + return Err(UnpackError::BytecodeGenFailed); + } + }; + + let at4 = stage1.wrapping_add(stage2_off.wrapping_add(216)); + let stage5_field = get_u32(&u.decompressed, at4); + // at4 is a (src, src_len, dest, dest_len) quad; only src and dest_len + // are needed here, the other two are consumed by the decrypt below. + let stage5_dlen = get_u32(&u.decompressed, at4.wrapping_add(12)); + if verbose { + println!(" stage5 = 0x{:08X}", stage5_field); + } + if !u.decrypt_and_decompress_data(at4, xor_acc ^ chk4 ^ chk5 ^ accum2, Some(&ops1)) { + return Err(UnpackError::DecompressFailed); + } + + // Inside stage5, the loader stores a table of (ptr, size) pairs at a + // fixed offset from the `70 6D 00 00 63 6D 00 00` marker. The first two + // pairs are walk4 (raw data load) and walk3 (chk2 chain). Then a variable + // number of additional entries, followed by a `0x40000000, 0x1` kind + // marker, then walk5 (string-table pointer). The secondary custom + // decryptor bytecode sits at marker+960. + let s5_marker_opt = find_str_pos( + &u.decompressed, + stage5_field, + stage5_dlen, + &[0x70, 0x6D, 0x00, 0x00, 0x63, 0x6D, 0x00, 0x00], + ); + // Older builds embed the `pm\0\0cm\0\0` marker right before the stage5 + // (ptr,size) table, so it's a tight search base. Newer builds + // (marker-less native/managed DLLs) omit the marker entirely — the slots + // are discovered by scanning the whole eighthStage instead. When the + // marker is absent, fall back to the start of the stage5 region as the + // search base; the downstream kind-marker + bytecode scans (which derive + // the real walk3/walk4/walk5 slots) then run over the full region. + let s5_marker = s5_marker_opt.unwrap_or(stage5_field); + // The (ptr, size) entry table after the marker ends with a fixed tail: + // walk4 (raw data load) = kind_pos - 0x20 + // walk3 (checksum chain) = kind_pos - 0x18 + // = kind_pos - 0x10 + // <0x40000000, 0x1 kind> = kind_pos (walk5 = kind_pos + 8) + // The kind-marker is the only stable anchor: older builds place it at + // marker+0x58, newer ones at marker+0x60, and walk3's entry size differs + // (0x50 vs 0x30). Deriving walk3/walk4 relative to the kind-marker + // handles every observed variant. + let kind_pos_opt = find_str_pos( + &u.decompressed, + s5_marker, + stage5_dlen.saturating_sub(s5_marker.wrapping_sub(stage5_field)), + &[0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, 0x00], + ); + // Layout discriminator. Older builds (the 7 EXE + 2 DLL goldens) embed + // the `00 00 00 40 01 00 00 00` kind-marker, from which walk3/walk4/walk5 + // are derived at fixed offsets. Newer builds (marker-less native + + // managed DLLs) omit BOTH the `pm\0\0cm\0\0` and kind markers; their + // eighthStage slots are discovered structurally (LFSR scan + + // trial-decrypt), and imports are rebuilt from the PE Import Directory + // rather than the walk5 encrypted-pointer table. `new_layout` selects + // that path. + let new_layout = kind_pos_opt.is_none(); + let kind_pos = kind_pos_opt.unwrap_or_else(|| s5_marker.wrapping_add(88)); + let walk4_slot = kind_pos.wrapping_sub(0x20); + let walk3_slot = kind_pos.wrapping_sub(0x18); + let walk5_slot = kind_pos.wrapping_add(8); + // Stage5 bytecode block. Anchor by trial-decryption + parse; fall back to + // marker+960 (older EXE build layout). + let bc2_search_base = s5_marker; + let bc2_search_len = stage5_dlen.saturating_sub(s5_marker.wrapping_sub(stage5_field)); + let mut bc2_off = find_bytecode_offset(&u.decompressed, bc2_search_base, bc2_search_len) + .or_else(|| { + find_bytecode_offset( + &u.decompressed, + bc2_search_base, + bc2_search_len.saturating_mul(2), + ) + }) + .unwrap_or_else(|| s5_marker.wrapping_add(960)); + // walk4 is the section-load (compressedInfo) descriptor table; in the + // old layout it sits at a fixed offset from the kind-marker. + let mut walk4_slot = walk4_slot; + // New-layout discovery: the kind-marker is absent, so derive the + // compressedInfo table pointer (walk4) and file-decryptor LFSR (bc2) + // 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( + &u.decompressed, + stage5_field, + stage5_dlen, + u.info[3], + compress_data_offset, + u.file_data.len() as u32, + ) + .ok_or(UnpackError::Stage5MarkerNotFound)?; + walk4_slot = slots.compressed_info_ptr; + bc2_off = slots.file_lfsr; + } + let at5 = walk3_slot; + let mut walk3 = get_u32(&u.decompressed, at5); + // walk3 is a checksum-validation walk: its chain_crc is computed and + // discarded. Each 16-byte entry is decrypted only transiently (to feed + // the checksum); the on-disk bytes stay encrypted. Back up each block + // before decrypting and restore after, so the output matches the golden + // (which keeps this chain encrypted). The new layout has no walk3 chain + // (the kind-marker that anchors it is absent), so this whole transient + // checksum walk is skipped there. + let mut walk3_backups: Vec<(usize, [u8; 16])> = Vec::new(); + let snap16 = |buf: &[u8], addr: u32| -> [u8; 16] { + let s = addr as usize; + let mut b = [0u8; 16]; + b.copy_from_slice(&buf[s..s + 16]); + b + }; + if !new_layout { + walk3_backups.push((walk3 as usize, snap16(&u.decompressed, walk3))); + u.decrypt_data5(walk3, 16); + walk3 = walk3.wrapping_add(16); + let mut chain_crc: u32 = 0; + loop { + walk3_backups.push((walk3 as usize, snap16(&u.decompressed, walk3))); + u.decrypt_data5(walk3, 16); + let p = walk3; + let n = get_u32(&u.decompressed, p.wrapping_add(4)); + walk3 = walk3.wrapping_add(16); + if n != 0 { + chain_crc = u.calculate_checksum2(walk3.wrapping_sub(16), chain_crc); + } + if get_u32(&u.decompressed, walk3.wrapping_sub(16).wrapping_add(4)) == 0 { + break; + } + } + if verbose { + println!("[6/9] Decrypting section data..."); + println!( + " integrity = 0x{:08X}", + get_u32(u.file_data, 56).wrapping_add(1985229329) + ); + println!(" chain_crc = 0x{:08X}", chain_crc); + } + for (addr, bytes) in walk3_backups { + u.decompressed[addr..addr + 16].copy_from_slice(&bytes); + } + } else if verbose { + println!("[6/9] Decrypting section data (new layout)..."); + } + + let data_offset2 = bc2_off; + // fileCS chain: the file-checksum table is permanently decrypted in + // place. Its pointer lives 0x58 bytes before the file LFSR/decryptor + // block (fileDecryptorAddress = fileChecksumAddresses + 0x58). Walk + // 16-byte entries until the size dword (offset +4) is zero, decrypting + // each. + { + let mut fcs = get_u32(&u.decompressed, bc2_off.wrapping_sub(0x58)); + while get_u32(&u.decompressed, fcs.wrapping_add(4)) != 0 { + u.decrypt_data5(fcs, 16); + fcs = fcs.wrapping_add(16); + } + } + u.decrypt_data6(data_offset2); + let ops2 = match generate(&u.decompressed, data_offset2) { + Some(v) => v, + None => { + return Err(UnpackError::Stage5BytecodeGenFailed); + } + }; + // The new layout picked its file decryptor by distance (no marker, no + // content check). Prove the choice before shipping it: a wrong pick + // would garble every section block, and raw blocks would carry that + // garbling into the output without any error (see the validator). + let rebase = (!get_u32(u.file_data, 4224)).wrapping_add(4096); + if new_layout && !u.new_layout_file_ops_validate(walk4_slot, &ops2, rebase) { + return Err(UnpackError::DecompressFailed); + } + + let at6 = walk4_slot; + if verbose { + println!("[7/9] Loading and decompressing sections..."); + } + let mut walk4 = get_u32(&u.decompressed, at6); + // Pass 1 (sequential): the 16-byte descriptors are decrypted in a + // position-keyed chain (`decrypt_data5`) that terminates on the next + // record's length field, so collection cannot be parallelized. + struct Blk { + src: u32, + len: u32, + dst: u32, + plain_len: u32, + } + let mut blocks: Vec = Vec::new(); + loop { + u.decrypt_data5(walk4, 16); + let p = walk4; + let src = get_u32(&u.decompressed, p); + let len = get_u32(&u.decompressed, p.wrapping_add(4)); + let dst = get_u32(&u.decompressed, p.wrapping_add(8)); + let plain_len = get_u32(&u.decompressed, p.wrapping_add(12)); + walk4 = walk4.wrapping_add(16); + if len != 0 { + blocks.push(Blk { + src, + len, + dst, + plain_len, + }); + } + if get_u32(&u.decompressed, walk4.wrapping_sub(16).wrapping_add(4)) == 0 { + break; + } + } + // Pass 2: each block writes only its own [dst, dst+max(len,plain_len)) + // span and reads only immutable input + the (snapshotted) key tables, + // so blocks with disjoint dst spans are independent. `parallel_for` + // carves the spans into safe disjoint &mut slices and fans out when + // worthwhile (see its docs for the soundness argument). + { + let lut = OpsLut::new(&ops2); + let clean = &u.file_data; + let ko = u.key_offsets; + // Snapshot the shared tables before the fan-out: workers get + // disjoint span slices, not the whole buffer. + let ks_snap = primitives::aes_schedule_snapshot(&u.decompressed, ko[2]) + .ok_or(UnpackError::Corrupt)?; + let tab_snap = primitives::huffman_table_snapshot(&u.decompressed, ko[0]) + .ok_or(UnpackError::DecompressFailed)?; + let spans: Vec<(usize, usize)> = blocks + .iter() + .map(|b| { + let s = b.dst as usize; + (s, s + b.len.max(b.plain_len) as usize) + }) + .collect(); + let do_block = |i: usize, base: usize, span: &mut [u8]| -> Result<(), UnpackError> { + let b = &blocks[i]; + let cs = b.src.wrapping_add(rebase) as usize; + let rel = b.dst as usize - base; + let ll = b.len as usize; + span[rel..rel + ll].copy_from_slice(&clean[cs..cs + ll]); + primitives::aes_decrypt_ks(&ks_snap, span, rel as u32, b.len); + lut.map_region(span, rel, ll); + if b.len != b.plain_len { + // 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.len, + b.plain_len, + ) { + return Err(UnpackError::DecompressFailed); + } + } + Ok(()) + }; + super::parallel::parallel_for(&mut u.decompressed, &spans, 1, do_block)?; + } + loop { + u.decrypt_data5(walk4, 16); + let p = walk4; + let dst = get_u32(&u.decompressed, p); + let len = get_u32(&u.decompressed, p.wrapping_add(4)); + walk4 = walk4.wrapping_add(16); + if len != 0 { + for k in 0..len { + u.decompressed[(dst + k) as usize] = 0; + } + } + if get_u32(&u.decompressed, walk4.wrapping_sub(16).wrapping_add(4)) == 0 { + break; + } + } + + let at7 = walk5_slot; + if verbose { + println!("[8/9] Decrypting import strings..."); + } + // walk5 is the old layout's encrypted import-name pointer table. The new + // layout has no such table — imports are rebuilt from the PE Import + // Directory after the header is reconstructed (see `process_imports_idt` + // below). Skip the walk5 pass entirely for the new layout. + let mut walk5 = get_u32(&u.decompressed, at7); + if !new_layout { + loop { + let outer = get_u32(&u.decompressed, walk5.wrapping_add(12)); + if outer == 0 { + break; + } + u.decrypt_data7(outer, outer as u8); + // Normalize the DLL name to lowercase (e.g. 'UnityPlayer.dll' -> + // 'unityplayer.dll'). Only when the name is all printable ASCII. + { + let start = outer as usize; + let mut end = start; + while end < u.decompressed.len() && u.decompressed[end] != 0 { + end += 1; + } + if end > start + && u.decompressed[start..end] + .iter() + .all(|&b| (0x20..0x7F).contains(&b)) + { + for b in &mut u.decompressed[start..end] { + b.make_ascii_lowercase(); + } + } + } + let a = get_u32(&u.decompressed, walk5); + let b = get_u32(&u.decompressed, walk5.wrapping_add(16)); + let mut chain = if a == 0 { b } else { a }; + loop { + // PE32+ thunks are 8 bytes: an ordinal import carries bit 63 + // with the ordinal in the low word; only a by-name thunk holds + // a hint/name RVA (in the low dword). Reading just the low + // dword would mistake an ordinal for a tiny RVA and scribble + // over the image header. + let v = get_u64(&u.decompressed, chain); + if v == 0 { + break; + } + if (v & 0x8000_0000_0000_0000) == 0 { + let inner = v as u32; + u.decrypt_data7(inner.wrapping_add(2), inner as u8); + write_u16(&mut u.decompressed, inner, 0); + } + chain = chain.wrapping_add(8); + } + walk5 = walk5.wrapping_add(20); + } + } // end !new_layout (walk5) + + if verbose { + println!("[9/9] Reconstructing PE headers..."); + } + u.decompressed[..4096].copy_from_slice(&u.file_data[..4096]); + let opt_hdr_size = get_u16(u.file_data, pe_off.wrapping_add(20)); + let sect_table = pe_off.wrapping_add(24).wrapping_add(opt_hdr_size as u32); + let payload_va = get_u32( + u.file_data, + pe_off + .wrapping_add(24) + .wrapping_add(opt_hdr_size as u32) + .wrapping_sub(128), + ); + let payload_size = get_u32( + u.file_data, + pe_off + .wrapping_add(24) + .wrapping_add(opt_hdr_size as u32) + .wrapping_sub(124), + ); + // Walk the section table by NumberOfSections, not "until a zero + // VirtualSize": PE has no sentinel entry, so a section whose + // VirtualSize is legitimately 0 (or a corrupt early field) would + // silently truncate the fixups — .text, and the export payload below, + // would then be missed entirely. The all-zero-name break guards the + // other direction (a corrupt, overstated NumberOfSections): real + // sections always have a name, header padding is all zero. + let num_sections = get_u16(u.file_data, pe_off.wrapping_add(6)) as u32; + let mut text_va: u32 = 0; + let mut text_size: u32 = 0; + let mut payload_off: u32 = 0; + for i in 0..num_sections.min(96) { + let sect = sect_table.wrapping_add(i.wrapping_mul(40)); + if u.file_data[sect as usize..sect as usize + 8] + .iter() + .all(|&b| b == 0) + { + break; + } + let sec_va = get_u32(u.file_data, sect.wrapping_add(12)); + let sec_size = get_u32(u.file_data, sect.wrapping_add(8)); + let sec_raw = get_u32(u.file_data, sect.wrapping_add(20)); + if section_name(u.file_data, sect) == ".text" { + text_size = sec_size; + text_va = sec_va; + } + if payload_size != 0 + && payload_va >= sec_va + && payload_va.wrapping_add(payload_size) <= sec_va.wrapping_add(sec_size) + { + payload_off = payload_va.wrapping_sub(sec_va).wrapping_add(sec_raw); + } + write_u32(&mut u.decompressed, sect.wrapping_add(16), sec_size); + write_u32(&mut u.decompressed, sect.wrapping_add(20), sec_va); + // NOTE: do NOT flag .rdata writable. The Windows loader already + // makes the IAT pages temporarily writable while snapping imports + // (it knows the range from the IAT data directory DD[12]), so the + // original read-only .rdata characteristics are sufficient. + // + // Marking .rdata MEM_WRITE actively breaks statically-linked + // MSVC/UCRT EXEs: the CRT's float-format init (`_cfltcvt_init`, + // which populates `_cfltcvt_tab`) is gated by a security check that + // refuses to call an init function whose descriptor lives in a + // *writable* section. With .rdata writable that init is skipped, + // `_cfltcvt_tab` keeps its R6002 stubs, and the first `%f` aborts + // with "R6002 - floating point support not loaded" (observed on some + // statically-linked MSVC/UCRT EXEs). + } + // Guard `payload_off != 0` like the PE32 path does: if no section + // contained the export range, offset 0 would copy the DOS stub over + // the image's export directory. + if payload_size != 0 && payload_off != 0 { + let s = payload_off as usize; + let d = payload_va as usize; + let n = payload_size as usize; + u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); + } + // EP/data-directory layout. For the new layout (marker-less), the + // metadata block (EP@info[3]+0x20, dirs@info[3]+0x30, "Layout B") is read + // BEFORE running the .text dd8 pass, then the encrypted bytes are + // restored so dd8 operates on them like the golden. Because info[3] + // sits inside .text on these builds, reading after dd8 (as the old + // layout does) would see dd8-corrupted metadata. So capture EP + 128 + // dir bytes here, pre-dd8. + let new_ep_dirs: Option<(u32, [u8; 128])> = if new_layout { + let ms = u.info[3].wrapping_add(32) as usize; + let backup: Vec = u.decompressed[ms..ms + 144].to_vec(); + u.decrypt_data5(u.info[3].wrapping_add(32), 144); + let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(32)); + let mut dirs = [0u8; 128]; + dirs.copy_from_slice( + &u.decompressed[(u.info[3] + 48) as usize..(u.info[3] + 48 + 128) as usize], + ); + u.decompressed[ms..ms + 144].copy_from_slice(&backup); + Some((ep, dirs)) + } else { + None + }; + // Old layout runs dd8 here (its metadata/.text regions don't overlap the + // not-yet-restored COR20/BSJB metadata). The new layout defers dd8 until + // AFTER the CLR metadata restore + section fixup (dd8 is the final .text + // step), because on managed DLLs the restored BSJB stream lives inside + // .text and must itself be dd8-processed to match the golden — and the + // shift is re-selected there over the restored bytes, so selecting it + // here would be wasted work on stale content. + if !new_layout { + // The packer keys the dd8 page-XOR with `page_idx << shift`. Most + // builds use shift 0; some newer builds use shift 15. The shift is + // NOT recorded in any header/config field — some older and newer + // builds carry byte-identical config-version stamps yet need + // different shifts — so it must be derived from the .text content + // (see `select_dd8_shift`). An explicit DD8_SHIFT env var (incl. + // 99 = skip) overrides for analysis. + 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]) + } + }; + if dd8_shift != 99 { + let mut page = text_va >> 12; + let end_page = text_va.wrapping_add(text_size) >> 12; + while page < end_page { + u.decrypt_data8(page << 12, 4096, page << dd8_shift); + page = page.wrapping_add(1); + } + } + } + // EP/DD layout in info[3] varies between Crackproof versions. Old + // layout: EP at info[3]+64, ImageBase at info[3]+68, DD[0..15] at + // info[3]+80..info[3]+208 — total 144 bytes encrypted starting at +64. + // New layout: everything shifts 32 bytes earlier — EP at info[3]+32, + // DD at info[3]+48, encrypted region at info[3]+32..info[3]+176. Probe + // both candidates and pick the one whose ImageBase-low matches info[3] + // (the dword right after EP in the PE optional header). + let ep_off: u32 = if new_layout { + // New-layout (marker-less) builds always use "Layout B": EP at + // info[3]+0x20, data dirs at info[3]+0x30. The old ImageBase-low + // probe assumes ImageBase==info[3], which does not hold for these + // builds (esp. managed DLLs), so pin the offset directly. + 32 + } else { + [32u32, 64] + .iter() + .copied() + .find(|&off| { + trial_decrypt5_u32(&u.decompressed, u.info[3].wrapping_add(off + 4)) + == u.info[3] + }) + .unwrap_or(64) + }; + let dd_off = ep_off.wrapping_add(16); + // The metadata block (EP + data directories) is read transiently: decrypt + // it, copy EP and dirs into the PE header, then RESTORE the original + // encrypted bytes. The packer leaves this region encrypted in its output, + // so leaving it decrypted in-place would diverge from the golden (the + // first byte at info[3]+ep_off would carry the decrypted EP low byte). + let pe_off2 = get_u32(&u.decompressed, 60); + if let Some((ep, dirs)) = new_ep_dirs { + // New layout: EP and dirs were captured pre-dd8 (Layout B). Write the + // EP and the 128-byte data-directory block into the PE header. + write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep); + u.decompressed[(pe_off2 + 136) as usize..(pe_off2 + 136) as usize + 128] + .copy_from_slice(&dirs); + // Import-RVA selection. Prefer the metadata import dir (dirs[1] @ +8) + // when it points at a plausible IDT; otherwise fall back to the + // anchor-stage value saved earlier whenever it is set at all (a + // non-zero-but-implausible anchor value still beats a metadata dir + // we already rejected). Managed DLLs leave the metadata import dir + // zero, so the anchor value is used there. + let image_size = get_u32(&u.decompressed, pe_off2.wrapping_add(80)); + let meta_imp_rva = u32::from_le_bytes([dirs[8], dirs[9], dirs[10], dirs[11]]); + let meta_imp_size = u32::from_le_bytes([dirs[12], dirs[13], dirs[14], dirs[15]]); + let idt_plausible = |rva: u32, sz: u32, d: &[u8]| -> bool { + if !(0x1000 < rva && rva < image_size && 0 < sz && sz < 0x10000) { + return false; + } + ((rva + 12) as usize + 4) <= d.len() && get_u32(d, rva + 12) != 0 + }; + let (imp_rva, imp_size) = if idt_plausible(meta_imp_rva, meta_imp_size, &u.decompressed) + || saved_import_rva == 0 + { + (meta_imp_rva, meta_imp_size) + } else { + (saved_import_rva, saved_import_size) + }; + write_u32(&mut u.decompressed, pe_off2.wrapping_add(0x90), imp_rva); + write_u32(&mut u.decompressed, pe_off2.wrapping_add(0x94), imp_size); + } else { + // The metadata block (EP + data directories) is read transiently: + // decrypt it, copy EP and dirs into the PE header, then RESTORE the + // original encrypted bytes. The packer leaves this region encrypted + // in its output, so leaving it decrypted in-place would diverge from + // the golden (the first byte at info[3]+ep_off would carry the + // decrypted EP low byte). + let meta_start = u.info[3].wrapping_add(ep_off) as usize; + let backup: Vec = u.decompressed[meta_start..meta_start + 144].to_vec(); + u.decrypt_data5(u.info[3].wrapping_add(ep_off), 144); + let ep = get_u32(&u.decompressed, u.info[3].wrapping_add(ep_off)); + write_u32(&mut u.decompressed, pe_off2.wrapping_add(40), ep); + for n in 0..128 { + u.decompressed[(pe_off2 + 136 + n) as usize] = + u.decompressed[(u.info[3] + dd_off + n) as usize]; + } + u.decompressed[meta_start..meta_start + 144].copy_from_slice(&backup); + } + + // New-layout import reconstruction runs AFTER the deferred .text dd8 + // pass (see below): dd8 precedes the IDT name/thunk decryption. The + // import RVA lives inside .text on these builds, so decrypting names + // before dd8 would let dd8 re-scramble them. The actual call is placed + // after the dd8 block. + + // New-layout managed (CLR) metadata restore happens AFTER the .text dd8 + // pass below. CrackProof preserves the COR20 header + BSJB MetaData + // verbatim in the protected file; both live inside .text on these + // builds. Restoring them before dd8 would let dd8 corrupt the metadata + // (~1 byte per 16) and leave an invalid COR20 header signature. We + // restore after dd8 so the copied-back bytes are final. `restored_clr` + // (set in that later block) suppresses the native COR20-directory + // clearing. + let mut restored_clr = false; + + // Deferred .text dd8 for the new layout (dd8 is the final .text step). + // The shift/formula is re-selected here over the now-fully-restored + // .text so the 0xCC-padding heuristic scores the real post-restore + // bytes. Only run dd8 when the entry point falls inside .text. + if new_layout { + let ep_final = get_u32(&u.decompressed, pe_off2.wrapping_add(40)); + let ep_in_text = text_size > 0 + && text_va > 0 + && text_va <= ep_final + && ep_final < text_va + text_size; + if ep_in_text { + 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]) + } + }; + if shift != 99 { + let mut page = text_va >> 12; + let end_page = text_va.wrapping_add(text_size) >> 12; + while page < end_page { + u.decrypt_data8(page << 12, 4096, page << shift); + page = page.wrapping_add(1); + } + } + } + // IDT name/thunk decryption, after dd8. The PE Import Directory + // points at the IDT; decrypt+lowercase each DLL name and each + // by-name import's hint/name, and recover the IAT (DD[12]). + let import_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0x90)); + let import_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0x94)); + if import_rva != 0 { + u.process_imports_idt(import_rva, import_size, pe_off2); + } + + // Managed (CLR) COR20 + BSJB MetaData restore — AFTER dd8 so the + // verbatim-copied bytes are final. CrackProof preserves these regions + // in the protected file at their RVA-mapped offsets; they live inside + // .text but must NOT be dd8-processed (they are not packer-encrypted + // code, just copied through). Restoring post-dd8 overwrites whatever + // dd8 scribbled, yielding a valid CLR header + BSJB stream. + let clr_rva = get_u32(&u.decompressed, pe_off2.wrapping_add(0xF8)); + let clr_size = get_u32(&u.decompressed, pe_off2.wrapping_add(0xFC)); + if clr_rva != 0 + && clr_size != 0 + && (clr_rva as u64 + clr_size as u64) <= u.decompressed.len() as u64 + && let Some(cor_off) = prot_rva_to_off(u.file_data, pe_off, clr_rva) + && (cor_off as u64 + 0x48) <= u.file_data.len() as u64 + && get_u32(u.file_data, cor_off) == 0x48 + { + // Restore the COR20 header from the protected file (its + // MetaData RVA/size fields are authoritative). + let s = cor_off as usize; + let d = clr_rva as usize; + u.decompressed[d..d + 0x48].copy_from_slice(&u.file_data[s..s + 0x48]); + restored_clr = true; + // Read MetaData RVA/size from the just-restored header. + let md_rva = get_u32(&u.decompressed, clr_rva + 0x08); + let md_size = get_u32(&u.decompressed, clr_rva + 0x0C); + if md_rva != 0 + && md_size != 0 + && (md_rva as u64 + md_size as u64) <= u.decompressed.len() as u64 + && let Some(md_off) = prot_rva_to_off(u.file_data, pe_off, md_rva) + && (md_off as u64 + md_size as u64) <= u.file_data.len() as u64 + && &u.file_data[md_off as usize..md_off as usize + 4] == b"BSJB" + { + let s = md_off as usize; + let d = md_rva as usize; + let n = md_size as usize; + u.decompressed[d..d + n].copy_from_slice(&u.file_data[s..s + n]); + } + } + } + + // The payload's TLS directory (DD[9]) arrives blanked: Crackproof strips + // the struct and re-installs TLS itself when it maps the module. Prefer + // recovering the real one from the stub's plaintext `.rdata` — dropping + // DD[9] leaves `_tls_index` unwritten, so every `thread_local` access + // resolves through TLS slot 0 (another module's block, or NULL). Only if + // the stub can't supply it do we clear the entry, which at least stops + // the loader writing through a NULL `AddressOfIndex`. + // + // Old builds have no TLS directory at all (DD[9] already 0), so this is + // a no-op for them. Restricted to the old, `/FIXED` layout: the caller + // zeroes BaseReloc/DllCharacteristics below, so the restored absolute + // VAs stay valid without relocations. The new-layout companion case is + // handled by `job::restore_tls_from_stub`. + let tls_dd_off = pe_off2.wrapping_add(136 + 9 * 8); + let tls_rva = get_u32(&u.decompressed, tls_dd_off); + if tls_rva != 0 && (tls_rva as usize + 40) <= u.decompressed.len() { + let tls_all_zero = u.decompressed[tls_rva as usize..tls_rva as usize + 40] + .iter() + .all(|&b| b == 0); + if tls_all_zero { + let image_base = get_u64(&u.decompressed, pe_off2.wrapping_add(48)); + if new_layout || !u.restore_pe64_tls_from_stub(pe_off, tls_rva, image_base) { + write_u32(&mut u.decompressed, tls_dd_off, 0); + write_u32(&mut u.decompressed, tls_dd_off.wrapping_add(4), 0); + } + } + } + + // If the COR20 (CLR) directory points at a zero-cb header, clear it so + // the loader treats the image as native instead of handing off to + // mscoree._CorExeMain (which crashes on the empty header). Skipped when + // the new-layout managed restore above repopulated a real COR20 header. + let cor20_dd_off = pe_off2.wrapping_add(0xF8); + let cor20_rva = get_u32(&u.decompressed, cor20_dd_off); + if !restored_clr + && cor20_rva != 0 + && (cor20_rva as usize + 4) <= u.decompressed.len() + && get_u32(&u.decompressed, cor20_rva) == 0 + { + write_u32(&mut u.decompressed, cor20_dd_off, 0); + write_u32(&mut u.decompressed, cor20_dd_off.wrapping_add(4), 0); + } + + // Old-layout CrackProof binaries are /FIXED: the shell discards the + // relocation table and clears DllCharacteristics, so the loader needs no + // relocations (exe_pe+0x5E / +0xB0). The new-layout (external-companion) + // modules are *not* /FIXED — they keep a real DllCharacteristics + // (ASLR/high-entropy) and a valid BaseReloc table (DD[5]); zeroing those + // produces a DLL that the loader can only place at its preferred base, + // and any rebase leaves every pointer — including the restored TLS + // directory — unrelocated and the module crashes. So preserve both for + // the new layout. + // + // The same applies to a *DLL* on the old layout: EXEs load at their + // preferred base, but a DLL is almost always rebased, so stripping its + // BaseReloc/DllCharacteristics makes it unloadable. The PE32 pipeline + // already gates this on IMAGE_FILE_DLL (see run_pe32); this is the + // PE32+ counterpart of that fix. EXEs keep the original /FIXED zeroing. + let is_dll = (get_u16(&u.decompressed, pe_off2.wrapping_add(22)) & 0x2000) != 0; + if !new_layout && !is_dll { + write_u16(&mut u.decompressed, pe_off2.wrapping_add(0x5E), 0); + write_u32(&mut u.decompressed, pe_off2.wrapping_add(0xB0), 0); + write_u32(&mut u.decompressed, pe_off2.wrapping_add(0xB4), 0); + } else if !new_layout { + // Old-layout DLL: keep whatever BaseReloc the header restore + // produced and make sure DYNAMIC_BASE is set (same as run_pe32). + let mut dll_chars = get_u16(&u.decompressed, pe_off2.wrapping_add(0x5E)); + if dll_chars == 0 { + dll_chars = 0x0040; // IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE + } + write_u16( + &mut u.decompressed, + pe_off2.wrapping_add(0x5E), + dll_chars as u32, + ); + } + + Ok(u.decompressed) + } + + /// Validate the new-layout (marker-less) file-decryptor choice by + /// trial-decompression, same approach as [`Self::pe32_file_lfsr_validates`]. + /// + /// The marker-less discovery picks the file LFSR by *distance* (the LFSR + /// whose fileCS pointer sits just past `info[3]`), not by content. A + /// coincidental LFSR-shaped block at a shorter distance would decode to a + /// wrong `ops2` translate and silently garble every section block — raw + /// blocks never hit `DecompressFailed`, so the failure would ship as a + /// plausible but wrong image. Replay the first *compressed* block's full + /// transform (raw copy, AES, translate, decompress) on a snapshot and + /// require decompression to succeed; restore the region afterwards. + /// + /// `walk4_slot` holds the compressedInfo table pointer; entries are read + /// with the non-mutating `trial_decrypt5_u32` (the position-keyed cipher + /// has no cross-byte state, so trial reads equal the real pass's + /// in-place decrypts). Returns `true` when no compressed block exists + /// (nothing to validate against). + fn new_layout_file_ops_validate(&mut self, walk4_slot: u32, ops: &[Op], rebase: u32) -> bool { + let mut walk4 = get_u32(&self.decompressed, walk4_slot); + for _ in 0..4096 { + if walk4 as usize + 16 > self.decompressed.len() { + return false; + } + let src = trial_decrypt5_u32(&self.decompressed, walk4); + let len = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(4)); + let dst = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(8)); + let plain_len = trial_decrypt5_u32(&self.decompressed, walk4.wrapping_add(12)); + if len == 0 { + return true; // terminator: no compressed block to validate against + } + if len != plain_len { + let cs = src.wrapping_add(rebase) as usize; + let dd = dst as usize; + let ll = len as usize; + let touch = len.max(plain_len) as usize; + if dd < 0x1000 + || cs.checked_add(ll).is_none_or(|e| e > self.file_data.len()) + || dd + .checked_add(touch) + .is_none_or(|e| e > self.decompressed.len()) + { + return false; + } + let snap: Vec = self.decompressed[dd..dd + touch].to_vec(); + self.decompressed[dd..dd + ll].copy_from_slice(&self.file_data[cs..cs + ll]); + 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]); + } + let ok = primitives::decompress( + &mut self.decompressed, + dst, + dst, + self.key_offsets[0], + len, + plain_len, + ); + self.decompressed[dd..dd + touch].copy_from_slice(&snap); + return ok; + } + walk4 = walk4.wrapping_add(16); + } + true + } + + /// Validate a candidate PE32 file-decryptor LFSR block by trial-decompression. + /// + /// `file_dec_addr` is the absolute address of the candidate bytecode block; + /// `ci_slot` is the absolute address of the compressedInfo pointer slot + /// (`eighth_start + off_compressed_info`). Decodes the candidate's `file_ops` + /// (the per-byte translate applied to every data block before Huffman + /// decompression), then replays the first *compressed* data block's full + /// transform — raw copy, AES, translate, decompress — on a snapshot and + /// reports whether decompression succeeded. The correct fileLFSR yields a + /// translate that lets every block decompress; a coincidental valid-opcode + /// block decodes to a bogus translate that makes decompression fail. + /// + /// Non-destructive: the 96-byte LFSR block and the touched destination + /// region are snapshotted and restored before returning. + fn pe32_file_lfsr_validates(&mut self, file_dec_addr: u32, ci_slot: u32) -> bool { + let da = file_dec_addr as usize; + if da + 96 > self.decompressed.len() { + return false; + } + // Decode file_ops transiently (decrypt_data6 mutates 96 bytes in place). + let lfsr_snap: Vec = self.decompressed[da..da + 96].to_vec(); + self.decrypt_data6(file_dec_addr); + let ops = generate(&self.decompressed, file_dec_addr); + self.decompressed[da..da + 96].copy_from_slice(&lfsr_snap); + let ops = match ops { + Some(o) => o, + None => return false, + }; + + let cdo = (!get_u32(self.file_data, 0x1080)).wrapping_add(0x1000); + let table = get_u32(&self.decompressed, ci_slot); + if table == 0 || table as usize + 16 > self.decompressed.len() { + return false; + } + // Walk the compressedInfo table (entries read via the non-mutating + // trial decrypt) to the first compressed block, then test it. + let mut entry = table; + for _ in 0..256 { + if entry as usize + 16 > self.decompressed.len() { + return false; + } + let src2 = trial_decrypt5_u32(&self.decompressed, entry); + let s_sz2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(4)); + let dst2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(8)); + let d_sz2 = trial_decrypt5_u32(&self.decompressed, entry.wrapping_add(12)); + if s_sz2 == 0 { + return false; // terminator: no compressed block to validate against + } + if s_sz2 != d_sz2 { + let file_src = src2.wrapping_add(cdo) as usize; + let dd = dst2 as usize; + let n = s_sz2 as usize; + let dlen = self.decompressed.len(); + // The trial transform writes `s_sz2` bytes starting at dd + // (copy + AES + translate) before decompress reads them, so + // the snapshot/restore must cover max(s_sz2, d_sz2) — restoring + // only d_sz2 leaves [dd+d_sz2, dd+s_sz2) permanently corrupted + // for the wrong-candidate case (s_sz2 > d_sz2), and every + // later candidate is then validated against a polluted buffer. + let touch = (s_sz2.max(d_sz2)) as usize; + if dst2 < 0x1000 + || file_src + .checked_add(n) + .is_none_or(|e| e > self.file_data.len()) + || dd.checked_add(touch).is_none_or(|e| e > dlen) + { + return false; + } + let dst_snap: Vec = self.decompressed[dd..dd + touch].to_vec(); + self.decompressed[dd..dd + n] + .copy_from_slice(&self.file_data[file_src..file_src + n]); + 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]); + } + let ok = primitives::decompress( + &mut self.decompressed, + dst2, + dst2, + self.key_offsets[0], + s_sz2, + d_sz2, + ); + self.decompressed[dd..dd + touch].copy_from_slice(&dst_snap); + return ok; + } + entry = entry.wrapping_add(16); + } + false + } + + /// PE32+ counterpart of [`Self::restore_pe32_tls_from_stub`]: recover the + /// genuine 40-byte `IMAGE_TLS_DIRECTORY64` and its raw-data template from the + /// loader stub, which keeps `.rdata` in plaintext at the same RVAs. + /// + /// Only meaningful for the old (single-file, `/FIXED`) layout, where the + /// caller goes on to zero `DllCharacteristics` and `BaseReloc` — the image + /// then loads at `image_base`, so the struct's absolute VAs are already + /// correct and need no relocations. The external-companion layout keeps its + /// relocations and is handled separately by `job::restore_tls_from_stub`, + /// which also synthesizes the four DIR64 fixups. + /// + /// Returns `false` without touching the image if the stub cannot supply a + /// plausible directory, so the caller can fall back to clearing DD[9]. + fn restore_pe64_tls_from_stub( + &mut self, + pe_off: u32, + tls_dir_rva: u32, + image_base: u64, + ) -> bool { + let src = match prot_rva_to_off(self.file_data, pe_off, tls_dir_rva) { + Some(o) => o as usize, + None => return false, + }; + if src.checked_add(40).is_none_or(|e| e > self.file_data.len()) { + return false; + } + let field = |i: u32| get_u64(self.file_data, (src as u32).wrapping_add(i)); + let (start_va, end_va, idx_va, cb_va) = (field(0), field(8), field(16), field(24)); + + let img_len = self.decompressed.len() as u64; + let in_image = |va: u64| va > image_base && (va - image_base) < img_len; + if !in_image(start_va) || !in_image(idx_va) || !in_image(cb_va) { + return false; + } + if end_va < start_va || (end_va - start_va) > 0x10_0000 { + return false; + } + + // Template first: bail before touching the struct so a failure leaves the + // caller's all-zero directory intact. + let tpl_len = (end_va - start_va) as usize; + if tpl_len > 0 { + let tpl_rva = (start_va - image_base) as u32; + let ts = match prot_rva_to_off(self.file_data, pe_off, tpl_rva) { + Some(o) => o as usize, + None => return false, + }; + let td = tpl_rva as usize; + if ts + .checked_add(tpl_len) + .is_none_or(|e| e > self.file_data.len()) + || td + .checked_add(tpl_len) + .is_none_or(|e| e > self.decompressed.len()) + { + return false; + } + self.decompressed[td..td + tpl_len].copy_from_slice(&self.file_data[ts..ts + tpl_len]); + } + + let d = tls_dir_rva as usize; + self.decompressed[d..d + 40].copy_from_slice(&self.file_data[src..src + 40]); + true + } + + /// Restore the genuine `IMAGE_TLS_DIRECTORY32` — and the raw-data template + /// it points at — from the loader stub. + /// + /// Crackproof zeroes the TLS directory struct inside the encrypted payload + /// and re-installs TLS itself when it maps the module, so a statically + /// unpacked image reaches the ordinary Windows loader with a blank struct. + /// Synthesizing a placeholder (empty template, index/callbacks aimed at + /// scratch) makes the image *load*, but it is not equivalent to the + /// original: the module's initialized thread-local bytes are never copied, + /// `_tls_index` is written somewhere the code never reads, and the TLS + /// callback array — which is where the CRT runs `__dyn_tls_init` — is empty. + /// Every `thread_local` access then hits a garbage slot (the same class of + /// `0xC0000005` documented for the companion-DLL path in `job.rs`). + /// + /// The stub keeps the module's original `.rdata` and `.tls` in plaintext, so + /// both the 24-byte struct and its template are copied back byte-for-byte at + /// their RVAs. EXE images are emitted without base relocations and therefore + /// load at `image_base`, so the struct's absolute VAs stay correct as-is; + /// DLLs keep the original relocation table, which already covered these four + /// fields before packing. + /// + /// Returns `false` without touching the image when the stub cannot supply a + /// plausible directory, so the caller can fall back to the placeholder. + fn restore_pe32_tls_from_stub( + &mut self, + pe_off: u32, + tls_dir_rva: u32, + image_base: u32, + ) -> bool { + let src = match prot_rva_to_off(self.file_data, pe_off, tls_dir_rva) { + Some(o) => o as usize, + None => return false, + }; + if src.checked_add(24).is_none_or(|e| e > self.file_data.len()) { + return false; + } + let field = |i: u32| get_u32(self.file_data, (src as u32).wrapping_add(i)); + let (start_va, end_va, idx_va, cb_va) = (field(0), field(4), field(8), field(12)); + + // Sanity-check before trusting it: a stub whose `.rdata` is not plaintext + // at this RVA yields noise, and installing noise is worse than the + // placeholder. All three pointers must land inside the image at its + // preferred base, and the template must be a sane, non-inverted range. + let img_len = self.decompressed.len() as u64; + let in_image = |va: u32| va > image_base && ((va - image_base) as u64) < img_len; + if !in_image(start_va) || !in_image(idx_va) || !in_image(cb_va) { + return false; + } + if end_va < start_va || (end_va - start_va) as u64 > 0x10_0000 { + return false; + } + + // Template first: a failure here must leave the struct untouched so the + // caller's fallback still sees an all-zero directory. + let tpl_len = (end_va - start_va) as usize; + if tpl_len > 0 { + let tpl_rva = start_va - image_base; + let ts = match prot_rva_to_off(self.file_data, pe_off, tpl_rva) { + Some(o) => o as usize, + None => return false, + }; + let td = tpl_rva as usize; + if ts + .checked_add(tpl_len) + .is_none_or(|e| e > self.file_data.len()) + || td + .checked_add(tpl_len) + .is_none_or(|e| e > self.decompressed.len()) + { + return false; + } + self.decompressed[td..td + tpl_len].copy_from_slice(&self.file_data[ts..ts + tpl_len]); + } + + let d = tls_dir_rva as usize; + 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::Corrupt); + } + 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::Corrupt); + } + 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 !self.decrypt_and_decompress_data(forth_addr, fk, None) { + return Err(UnpackError::DecompressFailed); + } + + // ---- 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 !self.decrypt_and_decompress_data(fifth_addr, fk5, None) { + return Err(UnpackError::DecompressFailed); + } + + // ---- 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 !self.decrypt_and_decompress_data(seven_addr, fk7, None) { + return Err(UnpackError::DecompressFailed); + } + + // ---- 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::Pe32BytecodeGenFailed)?; + + 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 hit DecompressFailed), + // 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::Pe32BytecodeGenFailed)?; + + // ---- 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::Corrupt)?; + let tab_snap = primitives::huffman_table_snapshot(&self.decompressed, ko[0]) + .ok_or(UnpackError::DecompressFailed)?; + 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::DecompressFailed); + } + } + 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::Corrupt)?; + Ok(compact) + } +} diff --git a/src/unpacker/integrity.rs b/src/unpacker/integrity.rs new file mode 100644 index 0000000..b49cecb --- /dev/null +++ b/src/unpacker/integrity.rs @@ -0,0 +1,365 @@ +//! Static sanity check for unpacked PE images. +//! +//! The unpack pipelines can succeed structurally (no error, no panic) yet emit +//! a binary the OS loader rejects at runtime with `0xC0000005` +//! (STATUS_ACCESS_VIOLATION) — e.g. when the entry-point stub or import strings +//! were left encrypted because a layout heuristic picked the wrong offset. This +//! module inspects the *output* bytes alone (no reference, no execution) and +//! reports defects that are near-certain runtime crashes. +//! +//! It is intentionally conservative: it only flags conditions that cannot occur +//! in a correctly unpacked image, so a clean report is not a guarantee of +//! correctness, but a non-clean report is a reliable "this is broken" signal. +//! +//! All reads are bounds-checked; the check never panics on any input. + +/// Result of a static integrity check over an unpacked image. +#[derive(Debug, Clone, Default)] +pub struct IntegrityReport { + /// Each entry describes one detected defect. Empty means no defect found. + pub issues: Vec, +} + +impl IntegrityReport { + /// True when no defects were detected. + pub fn ok(&self) -> bool { + self.issues.is_empty() + } +} + +// `checked_add`, not `+`: `usize` is 32-bit on wasm32, so a header-derived +// offset near `u32::MAX` would wrap the range and panic (`start > end`) in a +// module documented never to panic on any input. +fn rd_u16(d: &[u8], off: u32) -> Option { + let i = off as usize; + d.get(i..i.checked_add(2)?) + .map(|s| u16::from_le_bytes([s[0], s[1]])) +} + +fn rd_u32(d: &[u8], off: u32) -> Option { + let i = off as usize; + d.get(i..i.checked_add(4)?) + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +/// A parsed section-table entry (only the fields we translate against). +struct Section { + va: u32, + vsize: u32, + raw_ptr: u32, + raw_size: u32, + chars: u32, +} + +/// Walk the output's own section table and translate an RVA to a file offset. +/// Works for both memory-image output (raw_ptr == va) and compacted disk +/// output (real raw pointers), because it consults whatever the output declares. +/// Returns the offset only if the translated range `[off, off+need)` lies inside +/// the file. +fn rva_to_off(secs: &[Section], file_len: usize, rva: u32, need: u32) -> Option { + for s in secs { + // The mapped span is the larger of virtual and raw size, so an RVA that + // falls in the virtual tail of a section still resolves. + let span = s.vsize.max(s.raw_size); + if span == 0 { + continue; + } + if rva >= s.va && rva < s.va.wrapping_add(span) { + let delta = rva - s.va; + let off = s.raw_ptr.checked_add(delta)?; + let end = off.checked_add(need)?; + if (end as usize) <= file_len { + return Some(off); + } + return None; + } + } + None +} + +/// Inspect an unpacked PE image and report any defect that would make the OS +/// loader fault at runtime. `out` is the bytes the unpacker produced. +pub fn check(out: &[u8]) -> IntegrityReport { + let mut r = IntegrityReport::default(); + let file_len = out.len(); + + // --- DOS + PE headers --------------------------------------------------- + if rd_u16(out, 0) != Some(0x5A4D) { + r.issues.push("missing 'MZ' DOS signature".into()); + return r; // nothing else is meaningful + } + let pe_off = match rd_u32(out, 0x3C) { + Some(v) => v, + None => { + r.issues.push("truncated DOS header (no e_lfanew)".into()); + return r; + } + }; + if rd_u32(out, pe_off) != Some(0x0000_4550) { + r.issues + .push(format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}")); + return r; + } + + let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) { + Some(v) => v as u32, + None => { + r.issues.push("truncated COFF header".into()); + return r; + } + }; + let opt_hdr_size = rd_u16(out, pe_off.wrapping_add(20)).unwrap_or(0) as u32; + let opt = pe_off.wrapping_add(24); + let magic = match rd_u16(out, opt) { + Some(v) => v, + None => { + r.issues.push("truncated optional header".into()); + return r; + } + }; + let is64 = match magic { + 0x20B => true, + 0x10B => false, + other => { + r.issues + .push(format!("bad optional-header magic 0x{other:X}")); + return r; + } + }; + + if num_sections == 0 || num_sections > 96 { + r.issues + .push(format!("implausible section count {num_sections}")); + } + + let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0); + if size_of_image == 0 { + r.issues.push("SizeOfImage is zero".into()); + } + + // --- Section table ------------------------------------------------------ + let sec_table = opt.wrapping_add(opt_hdr_size); + let mut secs: Vec
= Vec::new(); + for i in 0..num_sections { + let base = sec_table.wrapping_add(i * 40); + // If the table runs past EOF the image is structurally broken. + let (vsize, va, raw_size, raw_ptr, chars) = match ( + rd_u32(out, base.wrapping_add(8)), + rd_u32(out, base.wrapping_add(12)), + rd_u32(out, base.wrapping_add(16)), + rd_u32(out, base.wrapping_add(20)), + rd_u32(out, base.wrapping_add(36)), + ) { + (Some(a), Some(b), Some(c), Some(d), Some(e)) => (a, b, c, d, e), + _ => { + r.issues + .push("section table extends past end of file".into()); + return r; + } + }; + // Raw data must lie within the file for compacted (disk-layout) output. + if raw_size != 0 { + let end = raw_ptr.wrapping_add(raw_size) as usize; + if end > file_len { + r.issues.push(format!( + "section #{i} raw data [0x{raw_ptr:X}..0x{end:X}] exceeds file size 0x{file_len:X}" + )); + } + } + secs.push(Section { + va, + vsize, + raw_ptr, + raw_size, + chars, + }); + } + + // --- Managed (CLR) detection ------------------------------------------ + // The COR20 (CLR) data directory, when present and non-zero, marks a managed + // assembly. Such images are dispatched through the CLR (via the COR20 header + // + BSJB metadata), not the native loader, so the native-loader heuristics + // below (zeroed EP stub, encrypted first import name) do NOT apply: CrackProof + // legitimately leaves a managed DLL's native EP and import strings in a state + // the native loader would reject, and that state is preserved here. + // Detect it before the EP / import checks so we can scope them to native + // images only. + let clr_rva = rd_u32( + out, + opt.wrapping_add(if is64 { 112 } else { 96 }) + .wrapping_add(14 * 8), + ) + .unwrap_or(0); + let is_managed = clr_rva != 0; + + // --- Native DLL relocatability ------------------------------------------ + // A native DLL is almost always loaded at a non-preferred base, so a + // missing base-relocation directory (DD[5]) is a guaranteed crash on + // rebase — exactly the failure mode produced when an unpacker wrongly + // applies the /FIXED-EXE fixup (zero BaseReloc + DllCharacteristics) to a + // DLL. Managed assemblies are exempt: the CLR rebases nothing through the + // native table, and their golden outputs legitimately carry no DD[5]. + let dd_base = opt.wrapping_add(if is64 { 112 } else { 96 }); + let chars_coff = rd_u16(out, pe_off.wrapping_add(22)).unwrap_or(0); + let is_dll = (chars_coff & 0x2000) != 0; + if is_dll && !is_managed { + let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0); + if reloc_rva == 0 { + r.issues.push( + "native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base" + .into(), + ); + } + } + + // --- Entry point -------------------------------------------------------- + // An entry RVA that does not resolve to a section, or whose target bytes are + // all zero, is a guaranteed access violation the instant the loader jumps to + // it. A zeroed/encrypted entry stub is the classic broken-unpack symptom. + let ep = rd_u32(out, pe_off.wrapping_add(40)).unwrap_or(0); + if ep == 0 { + // A DLL may legitimately have no entry point; an EXE never does. + if !is_dll { + r.issues.push("entry point RVA is zero".into()); + } + } else if !is_managed { + match rva_to_off(&secs, file_len, ep, 16) { + None => { + r.issues.push(format!( + "entry point RVA 0x{ep:X} does not map into any section" + )); + } + Some(off) => { + let stub = &out[off as usize..off as usize + 16]; + if stub.iter().all(|&b| b == 0) { + r.issues.push(format!( + "entry point at RVA 0x{ep:X} is all zeros (stub not recovered)" + )); + } else if stub.iter().all(|&b| b == 0xCC) { + // 16 bytes of int3 padding where the entry stub should be: + // the stub region was never recovered, the loader walks + // straight into a debug-break wall. + r.issues.push(format!( + "entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)" + )); + } + // The entry must live in an executable section. + let exec = secs.iter().any(|s| { + let span = s.vsize.max(s.raw_size); + ep >= s.va && ep < s.va.wrapping_add(span) && (s.chars & 0x2000_0000) != 0 + }); + if !exec { + r.issues.push(format!( + "entry point RVA 0x{ep:X} is not in an executable section" + )); + } + } + } + } + + // --- Import table ------------------------------------------------------- + // If an import directory is present, every descriptor's DLL name must be + // readable printable ASCII. Encrypted/garbage names mean import-string + // decryption failed, and the loader faults resolving them — checking only + // the first descriptor misses later ones still left as ciphertext. Skipped + // for managed assemblies (their import table is a CLR bootstrap stub the + // native loader doesn't resolve the same way). Note this no longer gates + // on NumberOfRvaAndSizes: a corrupt optional header shrinking that field + // must not silence the walk while a bogus import RVA still points at + // ciphertext. + if !is_managed { + let imp_rva = rd_u32(out, dd_base.wrapping_add(8)).unwrap_or(0); + if imp_rva != 0 { + match rva_to_off(&secs, file_len, imp_rva, 20) { + None => r.issues.push(format!( + "import directory RVA 0x{imp_rva:X} does not map into any section" + )), + Some(desc_off) => { + // 256 descriptors is far beyond any real import table; the + // cap keeps a corrupt, never-null table from walking on. + for i in 0..256u32 { + let d_off = desc_off.wrapping_add(i.wrapping_mul(20)); + let name_rva = rd_u32(out, d_off.wrapping_add(12)).unwrap_or(0); + // name_rva == 0 is the terminating null descriptor (or + // a read past the table) — done. + if name_rva == 0 { + break; + } + match rva_to_off(&secs, file_len, name_rva, 1) { + None => r.issues.push(format!( + "import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section" + )), + Some(noff) => { + if !looks_like_dll_name(out, noff) { + r.issues.push(format!( + "import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)" + )); + } + } + } + } + } + } + } + } + + // --- Managed (CLR) header + metadata ------------------------------------ + // For a managed assembly the COR20 (CLR) header and the BSJB MetaData stream + // it points at must survive unpacking intact, or the runtime rejects the + // image with BadImageFormatException ("Invalid COR20 header signature" / + // bad metadata) before any code runs. CrackProof copies both regions through + // verbatim; a unpacker that lets the .text dd8 pass scribble over them (they + // live inside .text) produces a structurally-valid-looking PE that the CLR + // still refuses to load. Validate: COR20 cb == 0x48, and the MetaData stream + // begins with the "BSJB" signature. + if is_managed { + match rva_to_off(&secs, file_len, clr_rva, 0x48) { + None => r.issues.push(format!( + "CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section" + )), + Some(coff) => { + let cb = rd_u32(out, coff).unwrap_or(0); + if cb != 0x48 { + r.issues.push(format!( + "COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt" + )); + } else { + // MetaData RVA/size live at COR20 + 0x08 / + 0x0C. + let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0); + if md_rva != 0 { + match rva_to_off(&secs, file_len, md_rva, 4) { + None => r.issues.push(format!( + "CLR MetaData RVA 0x{md_rva:X} does not map into any section" + )), + Some(moff) => { + let sig = out.get(moff as usize..moff as usize + 4); + if sig != Some(b"BSJB") { + r.issues.push(format!( + "CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)" + )); + } + } + } + } + } + } + } + } + + r +} + +/// True if the NUL-terminated string starting at `off` looks like a DLL name: +/// at least one byte, all printable ASCII up to the NUL, within a sane length. +fn looks_like_dll_name(d: &[u8], off: u32) -> bool { + let start = off as usize; + let mut end = start; + let limit = (start + 256).min(d.len()); + while end < limit && d[end] != 0 { + end += 1; + } + if end == start || end >= limit { + return false; // empty, or no NUL within a sane window + } + d[start..end].iter().all(|&b| (0x20..0x7F).contains(&b)) +} diff --git a/src/unpacker/mod.rs b/src/unpacker/mod.rs new file mode 100644 index 0000000..e4b9c0a --- /dev/null +++ b/src/unpacker/mod.rs @@ -0,0 +1,210 @@ +//! Pure, panic-free Crackproof unpacker core. No file I/O lives here. + +mod bytecode; +mod crc32; +pub mod dll; +pub mod exe; +pub mod integrity; +pub(crate) mod parallel; +pub(crate) mod primitives; +mod tables; + +pub use dll::{unpack_dll, unpack_dll_v}; +pub use exe::{UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v}; +pub use integrity::{IntegrityReport, check as check_integrity}; + +/// 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 + +/// Run an unpack pipeline, converting any internal panic into a clean +/// [`UnpackError::Corrupt`] so the public API stays panic-free on any input +/// (truncated/garbled files chase offsets out of bounds). The default panic +/// hook is suppressed transiently so a trapped panic does not spill a +/// backtrace to stderr. +/// +/// Note: allocation *failures* abort the process and are NOT caught here; size +/// requests are bounds-checked against [`MAX_IMAGE_SIZE`] before allocating. +pub(crate) fn catch_unpack(f: F) -> Result, UnpackError> +where + F: FnOnce() -> Result, UnpackError>, +{ + // Hook suppression is skipped on wasm: the prebuilt std cannot unwind + // there, so a panic traps immediately — and the suppressed hook would + // hide the panic message, leaving a bare `unreachable` with no clue. + #[cfg(not(target_arch = "wasm32"))] + let prev = std::panic::take_hook(); + #[cfg(not(target_arch = "wasm32"))] + std::panic::set_hook(Box::new(|_| {})); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + #[cfg(not(target_arch = "wasm32"))] + std::panic::set_hook(prev); + r.unwrap_or(Err(UnpackError::Corrupt)) +} + +/// Crackproof header magic stored in `keys[1]`/`info[1]`. +pub(crate) const MAGIC_KONN: u32 = 0x4E4E4F4B; // b"KONN" little-endian (= 1313754955) + +/// True if `magic` is the Crackproof magic this unpacker supports. +pub(crate) fn is_supported_magic(magic: u32) -> bool { + magic == MAGIC_KONN +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Exe, + NativeDll, + ManagedDll, +} + +#[derive(Debug, Clone, Copy)] +pub struct Detected { + pub kind: Kind, + pub magic: u32, +} + +// --------------------------------------------------------------------------- +// Content-based detection +// --------------------------------------------------------------------------- + +/// Derive the 8-element Crackproof key table from the header at offset 4096. +/// Returns `None` if the input is too short or doesn't have a valid PE signature. +fn key_table(input: &[u8]) -> Option<[u32; 8]> { + // Need at least 4128 bytes: the key-table loop below reads dwords up to + // offset 4124 (bytes 4124..4127). Guarding only `< 4096` would let a + // 4096..4127-byte PE (e.g. a 4 KiB stub) panic in `get_u32`. + 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. + 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()) { + return None; + } + if &input[pe_start..pe_start + 4] != b"PE\0\0" { + return None; + } + // Derive 8 keys per the Crackproof header-key formula. + let mut keys = [0u32; 8]; + keys[0] = primitives::get_u32(input, 4096); + let mut k = keys[0]; + for i in 0u32..7 { + let cell = primitives::get_u32(input, 4100u32.wrapping_add(i.wrapping_mul(4))); + keys[(i + 1) as usize] = k ^ cell; + k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i)); + } + Some(keys) +} + +/// Detect whether `input` is a Crackproof-protected binary and classify it. +/// Returns `None` if the magic doesn't match. +/// +/// Routing: `keys[1]` must be the Crackproof magic (`KONN`). +/// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL; +/// the CLR data-directory RVA further distinguishes ManagedDll from NativeDll. +pub fn detect(input: &[u8]) -> Option { + let keys = key_table(input)?; + let magic = keys[1]; + // Anything whose magic doesn't match is left untouched rather than + // detected-then-errored, honoring the "anything that doesn't match is + // left untouched" contract. + if !is_supported_magic(magic) { + return None; + } + // Use the PE DLL characteristic to distinguish EXE from DLL. + // IMAGE_FILE_HEADER.Characteristics is at peOff+4+18; bit 0x2000 = IMAGE_FILE_DLL. + let pe_off = primitives::get_u32(input, 0x3C); + let chars_offset = pe_off.wrapping_add(4).wrapping_add(18); + if (chars_offset as usize) + .checked_add(2) + .is_none_or(|end| end > input.len()) + { + return None; + } + let chars = + (input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8); + let is_dll = (chars & 0x2000) != 0; + if !is_dll { + return Some(Detected { + kind: Kind::Exe, + magic, + }); + } + // DLL: determine managed vs native via CLR data-directory RVA. + // peOff + 24 = start of optional header. The data directories start at a + // magic-dependent offset within it: PE32 (0x10B) at +96, PE32+ (0x20B) at + // +112. Using the PE32+ offset on a PE32 image reads the wrong dword and + // can mis-flag a native DLL as managed. + // + // `get_u16`/`get_u32` index unchecked, so every read past the already- + // checked Characteristics word must be bounds-checked first: a truncated + // DLL (e.g. `e_lfanew` pointing at len-24) would otherwise panic here, + // and this detector runs on the folder scan threads where a panic aborts + // the whole run. + let opt_magic_off = pe_off.wrapping_add(24) as usize; + let b = input.get(opt_magic_off..opt_magic_off.checked_add(2)?)?; + let opt_magic = u16::from_le_bytes([b[0], b[1]]); + let dd_off: u32 = if opt_magic == 0x20B { 112 } else { 96 }; + // + 14*8 = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR + let clr_rva_offset = pe_off + .wrapping_add(24) + .wrapping_add(dd_off) + .wrapping_add(14u32.wrapping_mul(8)); + if (clr_rva_offset as usize) + .checked_add(4) + .is_none_or(|end| end > input.len()) + { + return None; + } + let clr_rva = primitives::get_u32(input, clr_rva_offset); + let kind = if clr_rva != 0 { + Kind::ManagedDll + } else { + Kind::NativeDll + }; + Some(Detected { kind, magic }) +} + +/// Detect the file type and dispatch to the matching pipeline. +/// Returns the detected `Kind` together with the unpacked image bytes. +pub fn unpack_auto(input: &[u8]) -> Result<(Kind, Vec), UnpackError> { + unpack_auto_v(input, false) +} + +/// Like [`unpack_auto`], but prints detailed `[N/9]` unpack-step progress to +/// stdout when `verbose` is true. Output bytes are identical regardless. +pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec), UnpackError> { + let detected = detect(input).ok_or(UnpackError::NotCrackproof)?; + let out = match detected.kind { + Kind::Exe => unpack_exe_v(input, verbose)?, + Kind::NativeDll | Kind::ManagedDll => { + // Two Crackproof DLL layouts exist. The older one (the byte-identical + // DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect + // DLLs with the EXE-style shell layout instead — `dll::unpack_dll` + // cannot parse them and errors. Try the DLL pipeline first; on + // failure, fall back to the EXE pipeline, which handles the new + // layout (including managed-DLL CLR metadata restore). The DLL-first + // order keeps the old-layout goldens byte-identical (the EXE + // pipeline "succeeds" on them but with different bytes). + match dll::unpack_dll_v(input, verbose) { + Ok(out) => out, + Err(dll_err) => match exe::unpack_v(input, verbose) { + Ok(out) => out, + // Surface the DLL-pipeline error, not the EXE one: for a + // genuinely corrupt DLL the DLL error is the more relevant + // diagnostic, and the EXE fallback is best-effort. + Err(_) => return Err(dll_err), + }, + } + } + }; + Ok((detected.kind, out)) +} diff --git a/src/unpacker/parallel.rs b/src/unpacker/parallel.rs new file mode 100644 index 0000000..e6e0a8a --- /dev/null +++ b/src/unpacker/parallel.rs @@ -0,0 +1,227 @@ +//! Deterministic block-parallel fan-out for the section decrypt/decompress +//! loops. +//! +//! Each block writes a disjoint output span and reads only immutable input plus +//! snapshotted key tables, so distributing blocks across worker threads +//! produces byte-identical output regardless of thread count or scheduling. +//! +//! # Soundness +//! +//! This module contains **no `unsafe`**. The output buffer is carved into the +//! per-block spans with safe `split_at_mut` chains, so Rust itself guarantees +//! no two workers can hold aliasing `&mut` slices — an earlier version handed +//! every worker a whole-buffer `&mut [u8]` reconstructed from a raw pointer, +//! which is UB under Stacked/Tree Borrows even when the concrete writes never +//! overlap. The shared data the blocks read (AES key schedule, Huffman table) +//! is copied out by the caller before the fan-out and captured by the closure, +//! so no shared borrow of the output buffer is needed either. + +use std::sync::Mutex; +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 { + if let Ok(v) = std::env::var("SENBEI_THREADS") + && let Ok(n) = v.trim().parse::() + && n >= 1 + { + return n; + } + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) +} + +/// Run `f(i, span_base, span)` for every block `i`, fanning out across worker +/// threads when the spans are disjoint and worthwhile, else sequentially. +/// +/// `spans[i]` is the `[start, end)` region of `buf` block `i` writes. The +/// closure receives `span_base = spans[i].0` and the disjoint +/// `&mut buf[start..end]`; any shared data it needs must be captured by value +/// before the call. When the spans overlap (only possible on corrupt input), +/// the whole thing degrades to a sequential whole-buffer pass (`span_base = 0`, +/// `span = buf`), which preserves the deterministic last-writer-wins behavior +/// the pipeline had before parallelization. +/// +/// Returns the first `Err` any block produces; re-raises the first block panic +/// on the calling thread (so the pipeline's existing `catch_unpack` still +/// converts it to `UnpackError::Corrupt`). +pub(crate) fn parallel_for( + buf: &mut [u8], + spans: &[(usize, usize)], + min_per_thread: usize, + f: F, +) -> Result<(), E> +where + E: Send, + F: Fn(usize, usize, &mut [u8]) -> Result<(), E> + Sync, +{ + let n = spans.len(); + if n == 0 { + return Ok(()); + } + + // Verify the spans are in-bounds and mutually disjoint. Overlapping spans + // only arise from corrupt block descriptors; the sequential whole-buffer + // fallback handles them exactly as the pre-parallel pipeline did. + let mut sorted: Vec<(u64, u64)> = spans.iter().map(|&(s, e)| (s as u64, e as u64)).collect(); + let in_bounds = spans.iter().all(|&(s, e)| s <= e && e <= buf.len()); + let disjoint = in_bounds && spans_disjoint(&mut sorted); + + if !disjoint { + for i in 0..n { + f(i, 0, &mut *buf)?; + } + return Ok(()); + } + + // Carve the disjoint span pieces out of `buf` with safe splits. Rust's + // borrow checker proves the pieces never alias. + // + // Sort by the whole span, not just its start: `spans_disjoint` compares + // `(start, end)` tuples, so it accepts an empty span that shares a start + // with a non-empty one (`(100,100)` and `(100,200)`). Ordering by start + // alone would then carve them in input order, and a `(100,100)` arriving + // after `(100,200)` makes `s - base` underflow — a panic instead of the + // documented degrade-to-sequential fallback. + let mut order: Vec = (0..n).collect(); + order.sort_by_key(|&i| spans[i]); + let mut pieces: Vec> = Vec::new(); + pieces.resize_with(n, || None); + { + let mut rest: &mut [u8] = buf; + let mut base = 0usize; + for &i in &order { + let (s, e) = spans[i]; + let (_, tail) = rest.split_at_mut(s - base); + let (piece, tail2) = tail.split_at_mut(e - s); + pieces[i] = Some(piece); + rest = tail2; + base = e; + } + } + + let cap = thread_cap(); + let per = min_per_thread.max(1); + let workers = if cap > 1 && n >= per.saturating_mul(2) { + cap.min(n / per) + } else { + 1 + }; + + if workers <= 1 { + // Fully safe baseline: sequential on the current thread; panics and + // `Err`s propagate exactly as they did before parallelization. + for (i, piece) in pieces.into_iter().enumerate() { + f(i, spans[i].0, piece.unwrap())?; + } + return Ok(()); + } + + // Hand each span piece to exactly one worker through a shared iterator: + // the `&mut [u8]` is moved, never aliased. + let iter = Mutex::new(pieces.into_iter().enumerate()); + let stop = AtomicBool::new(false); + let first_err: Mutex> = Mutex::new(None); + let first_panic: Mutex>> = Mutex::new(None); + + std::thread::scope(|scope| { + for _ in 0..workers { + let iter = &iter; + let stop = &stop; + let first_err = &first_err; + let first_panic = &first_panic; + let f = &f; + scope.spawn(move || { + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let next = iter.lock().unwrap().next(); + let Some((i, piece)) = next else { break }; + let span = piece.unwrap(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + f(i, spans[i].0, span) + })); + match r { + Ok(Ok(())) => {} + Ok(Err(e)) => { + let mut slot = first_err.lock().unwrap(); + if slot.is_none() { + *slot = Some(e); + } + stop.store(true, Ordering::Relaxed); + break; + } + Err(panic) => { + let mut slot = first_panic.lock().unwrap(); + if slot.is_none() { + *slot = Some(panic); + } + stop.store(true, Ordering::Relaxed); + break; + } + } + } + }); + } + }); + + if let Some(panic) = first_panic.into_inner().unwrap() { + std::panic::resume_unwind(panic); + } + match first_err.into_inner().unwrap() { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// True if the half-open spans are mutually disjoint. Spans are +/// `[write_base, write_base + max(compressed_len, decompressed_len))` so a block +/// whose decompressed output exceeds its compressed size is fully covered. A +/// conservative (larger) span can only push a borderline case onto the safe +/// sequential path, never the reverse, so it cannot change output. +pub(crate) fn spans_disjoint(spans: &mut [(u64, u64)]) -> bool { + spans.sort_unstable(); + for w in spans.windows(2) { + if w[1].0 < w[0].1 { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Review regression: an empty span sharing a start with a non-empty one + /// passes `spans_disjoint` (it genuinely overlaps nothing), so the carve + /// runs. Ordering the carve by start alone put `(100,100)` after + /// `(100,200)` — `s - base` then underflowed and panicked instead of doing + /// the work. Reachable from a corrupt descriptor chain whose block size is + /// negative and whose expected length is zero. + #[test] + fn carves_empty_span_sharing_a_start() { + let mut buf = vec![0u8; 512]; + // Non-empty span first in input order, empty span second: the order + // that used to underflow. + let spans = [(100usize, 200usize), (100, 100)]; + let seen: Mutex> = Mutex::new(Vec::new()); + let r: Result<(), ()> = parallel_for(&mut buf, &spans, 1, |i, base, span| { + seen.lock().unwrap().push((i, base, span.len())); + for b in span.iter_mut() { + *b = 0xAB; + } + Ok(()) + }); + assert!(r.is_ok()); + let mut seen = seen.into_inner().unwrap(); + seen.sort_unstable(); + assert_eq!(seen, vec![(0, 100, 100), (1, 100, 0)]); + assert!(buf[100..200].iter().all(|&b| b == 0xAB)); + assert!(buf[..100].iter().all(|&b| b == 0)); + assert!(buf[200..].iter().all(|&b| b == 0)); + } +} diff --git a/src/unpacker/primitives.rs b/src/unpacker/primitives.rs new file mode 100644 index 0000000..f003261 --- /dev/null +++ b/src/unpacker/primitives.rs @@ -0,0 +1,2268 @@ +//! 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 { + d.get(off..off + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + .ok_or(super::UnpackError::OutOfBounds(off)) +} + +#[allow(dead_code)] +pub(crate) fn try_i32(d: &[u8], off: usize) -> Result { + try_u32(d, off).map(|v| v as i32) +} + +/// Checked copy: returns OutOfBounds if src or dst ranges exceed their respective slices. +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::OutOfBounds(dst_off))?; + let src_end = src_off + .checked_add(dst_len) + .ok_or(super::UnpackError::OutOfBounds(src_off))?; + if dst_end > dst.len() { + return Err(super::UnpackError::OutOfBounds(dst_off)); + } + if src_end > src.len() { + return Err(super::UnpackError::OutOfBounds(src_off)); + } + 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 `true` when exactly `d_size` bytes were written (full success), +/// `false` on any corruption-triggered early exit. The PE32 eighth-stage key +/// brute force uses this status to discriminate the correct key. +pub(crate) fn decompress( + d: &mut [u8], + src: u32, + mut dest: u32, + key_offset: u32, + s_size: u32, + d_size: u32, +) -> bool { + // 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 false; + } + DECOMPRESS_SCRATCH.with_borrow_mut(|buf| { + 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 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 = 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 false; + } + 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 { + // corrupt input: stop decompressing (diagnostics go to caller/log, not stdout) + 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`. 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 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); + } + } + _ => { + // 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 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 { + // 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 false; + } + } + src_consumed += if bit_pos != 0 { 1 } else { 0 }; + // Mismatch in consumed/written sizes indicates corrupt input; the unpack + // result will then fail downstream checks. No stdout diagnostics here — + // the pure core stays I/O-free; surface errors via the caller/logfile. + let _ = src_consumed; + written == d_size + }) +} + +/// 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( + d: &mut [u8], + pos: u32, + key: u32, + key1_offset: u32, + key3_offset: u32, + ops: Option<&[Op]>, +) -> bool { + 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(d, src, dest, key1_offset, src_len, dest_len); + } + true +} + +// --------------------------------------------------------------------------- +// 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 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!(!decompress(&mut d, 0x40, 0x80, 0, 4, 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/src/unpacker/tables.rs b/src/unpacker/tables.rs new file mode 100644 index 0000000..04dcc9b --- /dev/null +++ b/src/unpacker/tables.rs @@ -0,0 +1,161 @@ +//! AES inverse tables (inverse S-box + InvMixColumns "Td" T-tables), generated +//! at compile time from GF(2^8) arithmetic rather than embedded as a transcribed +//! blob. These are the standard AES *decryption* tables — not proprietary data — +//! so we derive them. The generated bytes are verified byte-identical to the +//! original hand-transcribed arrays (CRC32-locked in the test at the bottom). +//! +//! Each table is 1024 bytes = 256 u32 little-endian, read by +//! `primitives::aes_round` via `get_u32(&TABLE, x * 4)`. The byte layout matches +//! the original exactly, so `aes_round` is unchanged: +//! SBOX[x] = invsbox(x) broadcast to 4 bytes +//! COLUMMIX1[x] = [0b*s, 0d*s, 09*s, 0e*s], s = invsbox(x) (Td0, this byte order) +//! COLUMMIX2/3/4 = COLUMMIX1's 4-byte group rotated left by 1 / 2 / 3 bytes +//! +//! Generated the same way as the existing `const fn` CRC-table generation in +//! `crc32.rs`. + +/// GF(2^8) multiply with the AES reduction polynomial (x^8 + x^4 + x^3 + x + 1). +const fn gf_mul(mut a: u8, mut b: u8) -> u8 { + let mut p: u8 = 0; + let mut i = 0; + while i < 8 { + if b & 1 != 0 { + p ^= a; + } + let hi = a & 0x80; + a <<= 1; + if hi != 0 { + a ^= 0x1B; + } + b >>= 1; + i += 1; + } + p +} + +/// The AES inverse S-box, derived from the multiplicative inverse in GF(2^8) +/// followed by inverting the forward S-box's affine transform. +const fn inv_sbox() -> [u8; 256] { + // Multiplicative inverse: inv[a] = b such that a*b == 1 (inv[0] stays 0). + let mut inv = [0u8; 256]; + let mut a = 1usize; + while a < 256 { + let mut b = 1usize; + while b < 256 { + if gf_mul(a as u8, b as u8) == 1 { + inv[a] = b as u8; + break; + } + b += 1; + } + a += 1; + } + // Forward S-box: affine transform over the inverse. + let mut sb = [0u8; 256]; + let mut i = 0usize; + while i < 256 { + let mut x = inv[i]; + let mut s = inv[i]; + let mut r = 0; + while r < 4 { + s = s.rotate_left(1); + x ^= s; + r += 1; + } + sb[i] = x ^ 0x63; + i += 1; + } + // Inverse S-box is the inverse permutation of the forward S-box. + let mut isb = [0u8; 256]; + let mut i = 0usize; + while i < 256 { + isb[sb[i] as usize] = i as u8; + i += 1; + } + isb +} + +/// The five generated tables (each 1024 bytes = 256 u32 LE). +struct AesTables { + cm1: [u8; 1024], + cm2: [u8; 1024], + cm3: [u8; 1024], + cm4: [u8; 1024], + sbox: [u8; 1024], +} + +/// Build all five tables in one compile-time pass. +const fn build_tables() -> AesTables { + let isb = inv_sbox(); + let mut cm1 = [0u8; 1024]; + let mut cm2 = [0u8; 1024]; + let mut cm3 = [0u8; 1024]; + let mut cm4 = [0u8; 1024]; + let mut sbox = [0u8; 1024]; + let mut x = 0usize; + while x < 256 { + let s = isb[x]; + // SBOX: invsbox(x) broadcast to all four lanes. + let mut j = 0; + while j < 4 { + sbox[x * 4 + j] = s; + j += 1; + } + // COLUMMIX1 lane bytes; CM2/3/4 are byte-rotations of the same four. + let b = [ + gf_mul(0x0b, s), + gf_mul(0x0d, s), + gf_mul(0x09, s), + gf_mul(0x0e, s), + ]; + let mut j = 0; + while j < 4 { + cm1[x * 4 + j] = b[j]; + cm2[x * 4 + j] = b[(j + 1) % 4]; + cm3[x * 4 + j] = b[(j + 2) % 4]; + cm4[x * 4 + j] = b[(j + 3) % 4]; + j += 1; + } + x += 1; + } + AesTables { + cm1, + cm2, + cm3, + cm4, + sbox, + } +} + +const TABLES: AesTables = build_tables(); + +pub static COLUMMIX1: [u8; 1024] = TABLES.cm1; +pub static COLUMMIX2: [u8; 1024] = TABLES.cm2; +pub static COLUMMIX3: [u8; 1024] = TABLES.cm3; +pub static COLUMMIX4: [u8; 1024] = TABLES.cm4; +pub static SBOX: [u8; 1024] = TABLES.sbox; + +#[cfg(test)] +mod tests { + use super::*; + + /// Lock the generated tables to the original hand-transcribed bytes. The + /// CRC32 oracles were computed from the previously-committed `tables.rs` + /// arrays; any drift in the generator (or the GF math) fails here before it + /// can reach the byte-identical corpus goldens. + #[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); + // 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]); + assert_eq!(&COLUMMIX3[..4], &[0xf4, 0x51, 0x50, 0xa7]); + assert_eq!(&COLUMMIX4[..4], &[0x51, 0x50, 0xa7, 0xf4]); + assert_eq!(&SBOX[..4], &[0x52, 0x52, 0x52, 0x52]); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..b598c3f --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,10 @@ +//! 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 new file mode 100644 index 0000000..8fb8a9c --- /dev/null +++ b/tests/job.rs @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..c8250ea --- /dev/null +++ b/tests/logfile.rs @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..3a909e7 --- /dev/null +++ b/tests/run_log.rs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..7adf8fe --- /dev/null +++ b/tests/samples.rs @@ -0,0 +1,215 @@ +//! 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; + +/// 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<_> = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_file() && is_input(p)) + .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.file_name().unwrap().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 new file mode 100644 index 0000000..5d0237d --- /dev/null +++ b/web/Cargo.lock @@ -0,0 +1,441 @@ +# 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 new file mode 100644 index 0000000..1ce74ec --- /dev/null +++ b/web/Cargo.toml @@ -0,0 +1,19 @@ +[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 new file mode 100644 index 0000000..fe6b903 --- /dev/null +++ b/web/LICENSE @@ -0,0 +1,662 @@ + 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 new file mode 100644 index 0000000..c284174 --- /dev/null +++ b/web/README.md @@ -0,0 +1,75 @@ +# 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 new file mode 100644 index 0000000..a5c9df6 --- /dev/null +++ b/web/app.js @@ -0,0 +1,392 @@ +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 new file mode 100644 index 0000000..59e9c2c --- /dev/null +++ b/web/index.html @@ -0,0 +1,78 @@ + + + + + +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 new file mode 100644 index 0000000..4b98583 --- /dev/null +++ b/web/src/lib.rs @@ -0,0 +1,180 @@ +//! 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 new file mode 100644 index 0000000..8a2da81 --- /dev/null +++ b/web/style.css @@ -0,0 +1,369 @@ +: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 new file mode 100644 index 0000000..439d0a6 --- /dev/null +++ b/web/worker.js @@ -0,0 +1,41 @@ +// 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) }); + } +};