mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
refactor: init
This commit is contained in:
@@ -47,6 +47,3 @@ LICENSE text eol=lf
|
||||
*.gz binary
|
||||
*.xz binary
|
||||
*.dat binary
|
||||
|
||||
# wasm-pack output is generated.
|
||||
web/pkg/** linguist-generated=true
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a file that fails to unpack, unpacks incorrectly, or crashes senbei
|
||||
title: "[Bug]: "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the report. Attaching the protected input file helps us
|
||||
better diagnose the issue. Please only attach files you are
|
||||
authorized to share.
|
||||
- type: textarea
|
||||
id: symptom
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: The command you ran and the output/error you got. Use -v/--verbose and paste the stage log if unpacking failed midway.
|
||||
placeholder: "senbei app.exe -> error: ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect?
|
||||
placeholder: "A runnable unpacked image at unpack\\app.unpack.exe"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Senbei version
|
||||
description: Output of `senbei -V`
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: fileinfo
|
||||
attributes:
|
||||
label: About the input file
|
||||
description: |
|
||||
Without attaching it: is it an EXE or DLL? 32-bit or 64-bit? Managed
|
||||
(.NET) or native? Roughly how large? Does it have a `._` companion
|
||||
file next to it?
|
||||
- type: checkboxes
|
||||
id: legal
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I am authorized to analyze and share the attached file(s), and I understand attachments are removed after investigation.
|
||||
required: true
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or support for a new layout
|
||||
title: "[Feature]: "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For a new Crackproof layout, attaching a sample protected file helps
|
||||
us better understand the request. Please only attach files you are
|
||||
authorized to share.
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What would you like?
|
||||
description: The problem it solves for you, and any layout details you can share.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: legal
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: This request is for lawful research/interoperability use, and I am authorized to share any file I attach.
|
||||
required: true
|
||||
@@ -1,99 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
# The test suite exercises Windows path semantics, so it runs on Windows.
|
||||
# The golden corpus (samples/) is user-managed and absent on CI; the
|
||||
# samples test is a no-op pass there by design.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo test --release
|
||||
|
||||
check-portable:
|
||||
# Build-only portability gate: non-Windows host and the wasm target the
|
||||
# web build uses. Clippy runs here too, not just on Windows: the platform
|
||||
# `cfg` branches (the POSIX `localtime_r` path, the non-Windows stubs) are
|
||||
# invisible to the Windows clippy job, so without this they are never
|
||||
# linted at all.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo check --target wasm32-unknown-unknown
|
||||
|
||||
cli:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bin: senbei.exe
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
bin: senbei
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo build --release --locked
|
||||
- name: Package senbei-<version>-<target>
|
||||
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 <package>.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
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publishing a GitHub release:
|
||||
# - cli-assets builds the Windows and Linux CLI binaries and attaches
|
||||
# senbei-<version>-<target>.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-<version>-<target>
|
||||
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
|
||||
+1
-6
@@ -118,7 +118,7 @@ debug/
|
||||
target/
|
||||
|
||||
# Cargo.lock is committed on purpose: senbei is a binary, and CI builds with
|
||||
# --locked (web/Cargo.lock likewise, for the wasm build).
|
||||
# --locked.
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
@@ -151,8 +151,3 @@ target/
|
||||
# descend into it to find the re-included README). See senbei/samples/README.md.
|
||||
/samples/*
|
||||
!/samples/README.md
|
||||
|
||||
### senbei web build ###
|
||||
/web/pkg/
|
||||
/web/target/
|
||||
/web/.playwright-cli
|
||||
@@ -1,79 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents (and human contributors) working in this repo.
|
||||
|
||||
## Project
|
||||
|
||||
Senbei is a static unpacker for Crackproof-protected PE files: a pure,
|
||||
panic-free, no-I/O unpacker core (`src/unpacker/`) plus a thin CLI shell
|
||||
(`src/`), an il2cpp metadata de-obfuscator (`src/metadata.rs`), and a
|
||||
WebAssembly browser frontend (`web/`). Read `docs/design.md` first.
|
||||
|
||||
## Commands
|
||||
|
||||
```cmd
|
||||
cargo build --release :: CLI
|
||||
cargo test --release :: full suite (golden corpus: samples/, git-ignored)
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --all
|
||||
cd web && wasm-pack build --target web --release :: browser build
|
||||
```
|
||||
|
||||
The `samples/` corpus is user-managed and absent on CI; without it the
|
||||
samples test is a no-op pass. `SENBEI_REQUIRE_SAMPLES=1` makes an absent
|
||||
corpus fail (use this on a private CI that *does* have the corpus). Do not
|
||||
delete `samples/` with `rm -rf` — it may be a junction; use git
|
||||
worktree-aware cleanup.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **The unpacker core stays pure**: no file I/O, no `unsafe`, no panics across
|
||||
the public boundary, no platform-specific code. It must keep compiling to
|
||||
`wasm32-unknown-unknown` (`cargo check --target wasm32-unknown-unknown`).
|
||||
- **`catch_unwind` does not work on wasm** (the prebuilt std can't unwind; a
|
||||
caught panic becomes a fatal `unreachable` trap). Native code may rely on
|
||||
`catch_unpack`, but any routing decision must also work without a catchable
|
||||
panic: spliced companion inputs route straight to the EXE pipeline, and the
|
||||
web app isolates every unpack in a disposable Web Worker, retrying trapped
|
||||
DLLs with `job::unpack_bytes_force_exe`. Never make correctness on wasm
|
||||
depend on catching a panic.
|
||||
- **Byte-identical output is the contract.** Any pipeline change must re-run
|
||||
the full golden corpus; a byte mismatch on any golden is a regression.
|
||||
- **Trial-and-validate, never trust a heuristic.** A silently wrong offset
|
||||
produces a silently broken binary — worse than an error. Every layout
|
||||
candidate must be validated (checksum / structural oracle) with fall-through
|
||||
to the next candidate.
|
||||
- **Determinism under parallelism.** Block fan-out must stay byte-identical
|
||||
regardless of thread count (`SENBEI_THREADS=1` is the sequential reference).
|
||||
- **Folder scanning: deny-list, never allow-list.** Targets are recognised by
|
||||
content, not extension, and can carry arbitrary names — there is no closed
|
||||
set of target extensions an allow-list could enumerate. Only known
|
||||
bulk-asset formats are excluded.
|
||||
- **No binaries in the repo** — not as fixtures, not in commits. The only
|
||||
corpus is the local git-ignored `samples/`. (Issue attachments of
|
||||
protected inputs are fine when the user is authorized to share them, but
|
||||
never commit them.)
|
||||
|
||||
## Public-repo hygiene (important)
|
||||
|
||||
This is a public research repository. In code comments, docs, tests, and
|
||||
commit messages:
|
||||
|
||||
- **Never name specific games, publishers, or product codenames.** Refer to
|
||||
build families generically ("older EXE-64 builds", "the marker-less
|
||||
layout", "external-companion builds"). Keep offsets/numbers — drop names.
|
||||
- **Never name specific protected filenames** from real distributions. Test
|
||||
fixtures use generic names (`app.exe`, `managed.dll`, `daemon.exe`).
|
||||
Exceptions (platform-standard technology names, allowed): `il2cpp`,
|
||||
`Unity`, `global-metadata.dat`, the Crackproof magic `KONN`.
|
||||
- **Never reference other tools, projects, implementations, or paths outside
|
||||
this repo.** Describe behavior and layout directly; do not mention prior
|
||||
art, porting, or where any algorithm came from.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Comments explain *why* (layout rationale, observed variants, failure modes),
|
||||
not *what*.
|
||||
- Rust 2024 edition; clippy-clean at `-D warnings`; rustfmt default style.
|
||||
- CLI behavior (flags, exit codes, output naming) is documented in
|
||||
`docs/usage.md` — update the doc when changing behavior.
|
||||
Generated
+29
-2
@@ -208,19 +208,46 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei"
|
||||
name = "senbei-cli"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"senbei-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-crypto"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-io"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indicatif",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
"senbei-metadata",
|
||||
"senbei-pe",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-metadata"
|
||||
version = "1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "senbei-pe"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"senbei-crypto",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
||||
+21
-24
@@ -1,39 +1,36 @@
|
||||
[package]
|
||||
name = "senbei"
|
||||
[workspace]
|
||||
members = [
|
||||
"senbei-cli",
|
||||
"senbei-crypto",
|
||||
"senbei-io",
|
||||
"senbei-metadata",
|
||||
"senbei-pe",
|
||||
]
|
||||
default-members = ["senbei-cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "Static unpacker for Crackproof-protected PE files"
|
||||
license = "AGPL-3.0-only"
|
||||
keywords = ["unpacker", "reverse-engineering", "pe", "security-research"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[lib]
|
||||
name = "senbei"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "senbei"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
indicatif = "0.18"
|
||||
libc = "0.2"
|
||||
owo-colors = "4"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
walkdir = "2"
|
||||
indicatif = "0.18"
|
||||
owo-colors = "4"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_SystemInformation",
|
||||
] }
|
||||
|
||||
[target.'cfg(all(not(windows), not(target_arch = "wasm32")))'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
senbei-crypto = { path = "senbei-crypto" }
|
||||
senbei-io = { path = "senbei-io" }
|
||||
senbei-metadata = { path = "senbei-metadata" }
|
||||
senbei-pe = { path = "senbei-pe" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Senbei
|
||||
|
||||
A static unpacker for Crackproof-protected 64-bit and 32-bit PE files. Point it
|
||||
at a file or a folder and it writes decrypted copies — no launch of the
|
||||
protected program, no kernel driver, no code runs out of the protected binary.
|
||||
|
||||
> _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._
|
||||
|
||||
Senbei reads a protected `.exe` or `.dll`, replays the unpacking algorithm
|
||||
entirely in memory, and writes the recovered image to a new file. The core is a
|
||||
pure, panic-free library with no file I/O; the CLI wraps it with scanning, a
|
||||
progress bar, and a run log. A browser version (WebAssembly, fully client-side)
|
||||
lives in [`web/`](web/).
|
||||
|
||||
## Legal notice and intended use
|
||||
|
||||
**Read this before using Senbei.**
|
||||
|
||||
- Senbei is a research and interoperability tool. It exists to enable lawful
|
||||
reverse engineering, security research, preservation, and interoperability
|
||||
with software you already legitimately possess.
|
||||
- **Only process binaries you own or are explicitly authorized to analyze.**
|
||||
Depending on your jurisdiction and license agreements, circumventing
|
||||
technological protection measures may be restricted (for example under
|
||||
DMCA §1201 in the United States, which contains exemptions for security
|
||||
research and interoperability). It is your responsibility to ensure your use
|
||||
is lawful.
|
||||
- Senbei does not bypass any access control for you: it performs a purely
|
||||
static transformation of a file already on your disk. It derives everything
|
||||
it needs from the input file itself, contains no vendor code or secrets, and
|
||||
distributes no keys, cracks, or copyrighted content.
|
||||
- Senbei does not enable online play, license fraud, or cheating, and must not
|
||||
be used to redistribute decrypted binaries. Do not upload outputs anywhere.
|
||||
- The authors provide this software "as is", without warranty of any kind, and
|
||||
accept no liability for misuse. See [LICENSE](LICENSE) (AGPL-3.0).
|
||||
- "Crackproof" is a trademark of its respective owner; this project is not
|
||||
affiliated with or endorsed by the protection vendor or any software
|
||||
publisher. Names are used for identification only.
|
||||
|
||||
## What it handles
|
||||
|
||||
| Kind | Description |
|
||||
| --- | --- |
|
||||
| `Exe` | Crackproof-protected executable (PE32+ and PE32). |
|
||||
| `NativeDll` | Protected native (unmanaged) DLL. |
|
||||
| `ManagedDll` | Protected .NET assembly (has a CLR data directory). |
|
||||
| `._` companion | Stub + external encrypted payload layout, spliced automatically. |
|
||||
| `global-metadata.dat` | il2cpp metadata with obfuscated method tokens, de-obfuscated in place. |
|
||||
|
||||
Detection is content-based (header key-table at offset 4096, magic `KONN`),
|
||||
not extension-based. Anything unrecognized is left untouched.
|
||||
|
||||
## Quick start
|
||||
|
||||
```cmd
|
||||
cargo build --release
|
||||
|
||||
senbei protected.exe
|
||||
:: -> unpack\protected.unpack.exe
|
||||
|
||||
senbei "C:\Games\MyGame"
|
||||
:: -> C:\Games\MyGame\unpack\... (recursive, skips non-targets)
|
||||
```
|
||||
|
||||
Every output is sanity-checked statically; structurally broken results are
|
||||
flagged as suspect rather than silently trusted.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Usage reference](docs/usage.md) — CLI flags, exit codes, integrity check
|
||||
- [Design](docs/design.md) — architecture, routing, and error model
|
||||
- [Development](docs/development.md) — building, testing, environment variables
|
||||
- [Web version](web/README.md) — run Senbei in a browser
|
||||
|
||||
## License
|
||||
|
||||
[GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0-only).
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
# Design
|
||||
|
||||
Senbei is a fully static unpacker: it replays the unpacking algorithm on the
|
||||
file bytes in memory and writes the recovered PE image. No code from the
|
||||
protected binary is ever executed, no process is launched or attached to, and
|
||||
no driver or proxy DLL is involved.
|
||||
|
||||
## Crate layout
|
||||
|
||||
The crate is split into a pure core and a thin CLI shell:
|
||||
|
||||
- **`src/unpacker/`** — the core. Pure functions over byte slices: no file
|
||||
I/O, no environment access (beyond a few debugging overrides, see
|
||||
[development.md](development.md)), panic-free at the public boundary (all
|
||||
internal panics are trapped and converted to `UnpackError::InternalPanic`). This
|
||||
is what the WebAssembly build embeds.
|
||||
- **`src/` (top level)** — the CLI shell: argument parsing, recursive folder
|
||||
scanning, per-run log file, progress bar, Explorer-friendly exit pause, and
|
||||
the single-file/folder orchestration in `job.rs`.
|
||||
- **`src/metadata.rs`** — il2cpp `global-metadata.dat` method-token
|
||||
de-obfuscation (format version 31; other versions are left untouched).
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs argument parsing + dispatch
|
||||
├── lib.rs module roots
|
||||
├── job.rs single-file + folder orchestration, out-naming,
|
||||
│ companion splice, stub overlay/TLS restore,
|
||||
│ pipeline routing (incl. the wasm-safe byte API)
|
||||
├── scan.rs recursive Crackproof + metadata discovery
|
||||
├── metadata.rs il2cpp global-metadata.dat de-obfuscation
|
||||
├── logfile.rs per-run timestamped log
|
||||
├── ui.rs progress bar + status lines
|
||||
├── pause.rs Explorer-friendly exit pause
|
||||
└── unpacker/ pure, panic-free, no-I/O core
|
||||
├── mod.rs detection + unpack_auto dispatch
|
||||
├── exe.rs EXE pipeline (PE32+ and PE32)
|
||||
├── dll.rs native + managed DLL pipeline
|
||||
├── integrity.rs static post-unpack sanity check
|
||||
├── primitives.rs decrypt_data* steps, key/shift selection
|
||||
├── bytecode.rs bytecode VM
|
||||
├── parallel.rs deterministic block-parallel fan-out
|
||||
├── tables.rs constant tables
|
||||
└── crc32.rs checksum
|
||||
```
|
||||
|
||||
## Detection and routing
|
||||
|
||||
Detection is content-based (`unpacker::detect`), never extension-based: the
|
||||
key table is derived from the file header and checked against the format
|
||||
magic, then the PE characteristics classify the input as EXE, native DLL, or
|
||||
managed DLL.
|
||||
|
||||
`unpack_auto` then dispatches:
|
||||
|
||||
- `Exe` → the EXE pipeline (handles both PE32+ and PE32).
|
||||
- `NativeDll` / `ManagedDll` → the DLL pipeline first; on failure, the EXE
|
||||
pipeline as a fallback. Two DLL layouts exist in the wild: an older layout
|
||||
the DLL pipeline parses, and a newer one that protects DLLs with the
|
||||
EXE-style shell layout instead. The DLL-first order keeps old-layout outputs
|
||||
byte-identical (the EXE pipeline also "succeeds" on old-layout DLLs but
|
||||
produces different bytes); the fallback handles the new layout (including
|
||||
the managed-DLL .NET metadata restore).
|
||||
|
||||
One routing shortcut bypasses `unpack_auto`: inputs spliced from an external
|
||||
companion (`job.rs`, both the CLI and the wasm byte API) go **straight to the
|
||||
EXE pipeline**. The companion layout is definitionally the EXE-style shell,
|
||||
so the DLL probe can never be right for it — and the probe's rejection of
|
||||
EXE-shell DLLs relies on a caught panic, which is a fatal trap on targets
|
||||
without unwinding (WebAssembly). Output bytes are identical to the
|
||||
probe-then-fallback route.
|
||||
|
||||
## External-companion inputs
|
||||
|
||||
Some builds split a protected module into an on-disk loader stub plus an
|
||||
encrypted `._` companion. When a `<name>._` sibling matches the stub's header
|
||||
region, `job.rs` splices the two before unpacking and afterwards overlays the
|
||||
export table and TLS directory from the stub — pieces the encrypted companion
|
||||
does not carry. All overlay steps are best-effort no-ops when their inputs
|
||||
can't be mapped, so a malformed stub can never corrupt an otherwise-good
|
||||
unpack.
|
||||
|
||||
## Pipelines
|
||||
|
||||
Both pipelines are **heuristic with trial-and-validate**: where a layout
|
||||
leaves ambiguity (e.g. which block is the real file decryptor, or a page-XOR
|
||||
shift), the pipeline tries candidates and validates the result structurally
|
||||
(an entry-stub oracle, checksum stamps, cluster stamps) instead of trusting
|
||||
the first match. A validation failure falls through to the next candidate
|
||||
rather than producing silently wrong output.
|
||||
|
||||
Several protected stages are themselves little bytecode programs. The core
|
||||
includes a small VM (`bytecode.rs`) that generates and interprets those
|
||||
programs rather than hardcoding each variant's constants.
|
||||
|
||||
The PE32+ configuration block has two observed anchor-relative alignments.
|
||||
Senbei selects between them by validating the stage1 `(RVA, length)`
|
||||
descriptor against the image, rather than relying on a version-like word whose
|
||||
value is not stable across build families. Stage3 seed advancement also varies:
|
||||
the usual four-round result is tried first, then bounded alternatives are
|
||||
replayed from the untouched ciphertext and accepted only when decompression
|
||||
writes the exact target size and the recovered stage has its expected function
|
||||
tail structure.
|
||||
|
||||
## Integrity check
|
||||
|
||||
Every produced image passes through `integrity::check` — a static, execution-
|
||||
free sanity check that only flags defects impossible in a correctly unpacked
|
||||
image (malformed headers, unmapped/non-executable/all-zero/all-int3 entry
|
||||
point, a native DLL with no base-relocation directory, any import descriptor
|
||||
whose DLL name is still ciphertext, a managed image whose COR20 header or BSJB
|
||||
metadata did not survive). See [usage.md](usage.md#integrity-check).
|
||||
A clean report is not a proof of correctness; a non-clean report is a reliable
|
||||
"broken" signal.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Section decrypt/decompress blocks write disjoint output spans and read only
|
||||
immutable input plus snapshotted key tables, so `parallel.rs` fans them out
|
||||
across worker threads with **byte-identical** output regardless of thread
|
||||
count. There is no `unsafe`: the buffer is carved with safe `split_at_mut`
|
||||
chains so the borrow checker proves spans never alias. Overlapping spans (only
|
||||
possible on corrupt input) degrade to the sequential whole-buffer pass,
|
||||
preserving the deterministic last-writer-wins behavior of the serial
|
||||
pipeline. `SENBEI_THREADS=1` forces the sequential path; on targets without
|
||||
threads (WebAssembly) the sequential path is used automatically.
|
||||
|
||||
## Error model
|
||||
|
||||
The public API never panics: every pipeline runs under a `catch_unwind`
|
||||
wrapper (`catch_unpack`) that converts a trapped panic to
|
||||
`UnpackError::InternalPanic`, including the Rust source location and panic
|
||||
payload. The capture context is propagated into section worker threads; panics
|
||||
outside an active unpack continue through the previously installed panic hook.
|
||||
Expected validation failures use structured variants carrying the failed stage,
|
||||
block index, table kind, or invalid range instead of collapsing unrelated causes
|
||||
into a generic corruption error. Huffman/LZ failures distinguish invalid code
|
||||
lengths, tree traversal, pending-length overflow, invalid back-references,
|
||||
output overflow, and size mismatch. When both DLL parsing and the EXE-layout
|
||||
fallback fail, the returned error retains both pipeline errors.
|
||||
Size requests are bounds-checked against a 1 GiB `MAX_IMAGE_SIZE` before
|
||||
allocation so a crafted header cannot abort the process with a huge
|
||||
allocation. In folder mode each file is isolated: one file's failure is logged
|
||||
and counted, never fatal to the run.
|
||||
|
||||
**WebAssembly caveat:** the prebuilt wasm std cannot unwind, so a caught
|
||||
panic becomes a fatal `unreachable` trap there. The DLL-routing probe relies
|
||||
on this mechanism to reject EXE-shell-layout DLLs, so the web build routes
|
||||
around it instead of through it: spliced companion inputs skip the probe
|
||||
entirely (see "Detection and routing"), and the web app isolates every unpack
|
||||
in a disposable Web Worker — a trapped DLL is retried once in a fresh worker
|
||||
with the forced-EXE pipeline (`job::unpack_bytes_force_exe`), reproducing the
|
||||
probe-then-fallback outcome without a catchable panic. A trap on any other
|
||||
input is reported as a clean error rather than freezing the page.
|
||||
@@ -1,116 +0,0 @@
|
||||
# Development
|
||||
|
||||
## Building
|
||||
|
||||
Requires a Rust toolchain (MSVC backend is the default on Windows;
|
||||
`rustup-init.exe` from <https://rustup.rs> 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 `<base>.golden.<ext>`
|
||||
reference outputs. Every input goes through `job::unpack_bytes` — the same
|
||||
routing the CLI uses, so an `<input>._` 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.
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
# Usage
|
||||
|
||||
```
|
||||
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all]
|
||||
[--no-log] [--no-pause] [-V|--version] [-h|--help]
|
||||
```
|
||||
|
||||
Real runs print `Senbei <version>` once at start. Use `-V` / `--version` to
|
||||
print the version and exit.
|
||||
|
||||
## Single file
|
||||
|
||||
The decrypted image is written under `<parent>/unpack/` with `.unpack` inserted
|
||||
before the extension. A `senbei-<timestamp>.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 `<root>/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 `<name>._` 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 <version>` 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.
|
||||
@@ -1,3 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"]
|
||||
@@ -1,88 +0,0 @@
|
||||
# senbei/samples
|
||||
|
||||
Drop-in corpus for the `samples` integration test (`tests/samples.rs`).
|
||||
|
||||
This folder is **git-ignored** (only this `README.md` is tracked), so it holds
|
||||
whatever Crackproof binaries happen to be on your machine. Nothing here is
|
||||
committed.
|
||||
|
||||
## What to put here
|
||||
|
||||
Place protected inputs in this folder, either directly or grouped in
|
||||
subdirectories:
|
||||
|
||||
- `*.exe` — Crackproof-protected executables (PE32 or PE32+)
|
||||
- `*.dll` — Crackproof-protected DLLs (native or managed)
|
||||
- `*.dat` — il2cpp `global-metadata.dat` blobs (method-token de-obfuscation)
|
||||
|
||||
For an **external-companion** module, copy the `<name>._` payload in as well,
|
||||
keeping the exact `._` suffix on the full file name. The test splices it the
|
||||
same way the CLI does; without it the loader stub alone is meaningless and the
|
||||
splice / export-overlay / TLS-restore code is never exercised.
|
||||
|
||||
The corpus scan is recursive and skips directories named `unpack`, matching the
|
||||
CLI's output-directory rule.
|
||||
|
||||
Optionally, place a **golden** next to each input — the known-good unpacked
|
||||
output, named `<base>.golden.<ext>`:
|
||||
|
||||
```
|
||||
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 `<base>.golden.<ext>` sitting next to its input. Files with
|
||||
`.golden.` in the name are never treated as inputs.
|
||||
- A **companion** is `<input file name>._` (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.
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "senbei-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Command-line entry point for Senbei"
|
||||
license.workspace = true
|
||||
keywords = ["unpacker", "reverse-engineering", "pe", "security-research"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[[bin]]
|
||||
name = "senbei"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
senbei-io.workspace = true
|
||||
@@ -1,4 +1,4 @@
|
||||
use senbei::{job, pause};
|
||||
use senbei_io::{job, pause, scan};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
@@ -27,9 +27,6 @@ fn main() -> std::process::ExitCode {
|
||||
"--no-log" => no_log = true,
|
||||
"--scan-all" => scan_all = true,
|
||||
"--out" => match args.next() {
|
||||
// Reject a missing value (and a following flag swallowed as the
|
||||
// value): previously `--out` at end of argv silently fell back
|
||||
// to the default output directory.
|
||||
Some(v) if !v.starts_with('-') => out = Some(v),
|
||||
_ => {
|
||||
eprintln!("error: --out requires a directory argument");
|
||||
@@ -42,7 +39,6 @@ fn main() -> std::process::ExitCode {
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
other => {
|
||||
// Previously the last positional silently won.
|
||||
if let Some(prev) = &path {
|
||||
eprintln!("error: multiple input paths given ('{prev}' and '{other}')");
|
||||
return std::process::ExitCode::from(2);
|
||||
@@ -63,33 +59,36 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
let p = Path::new(&p);
|
||||
let out_path = out.as_deref().map(Path::new);
|
||||
let r = if p.is_dir() {
|
||||
let result = if p.is_dir() {
|
||||
job::run_folder_opts(
|
||||
p,
|
||||
out_path,
|
||||
quiet,
|
||||
verbose,
|
||||
no_log,
|
||||
scan_all || senbei::scan::scan_all_env(),
|
||||
scan_all || scan::scan_all_env(),
|
||||
)
|
||||
} else {
|
||||
job::run_file_v(p, out_path, quiet, verbose, no_log)
|
||||
};
|
||||
match r {
|
||||
Ok(s) => {
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
if quiet < 2 {
|
||||
println!(
|
||||
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
s.unpacked, s.skipped, s.errors, s.suspect, s.metadata
|
||||
summary.unpacked,
|
||||
summary.skipped,
|
||||
summary.errors,
|
||||
summary.suspect,
|
||||
summary.metadata
|
||||
);
|
||||
println!("done in {} ms", s.duration_ms);
|
||||
println!("done in {} ms", summary.duration_ms);
|
||||
}
|
||||
if s.errors > 0 { 1 } else { 0 }
|
||||
if summary.errors > 0 { 1 } else { 0 }
|
||||
}
|
||||
Err(e) => {
|
||||
// Fatal: out-dir/log create, etc.
|
||||
Err(error) => {
|
||||
if quiet < 2 {
|
||||
eprintln!("error: {e:#}");
|
||||
eprintln!("error: {error:#}");
|
||||
}
|
||||
1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "senbei-crypto"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Cryptographic and compression primitives for Senbei"
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Cryptographic, checksum, compression, and bytecode primitives.
|
||||
|
||||
pub mod bytecode;
|
||||
pub mod crc32;
|
||||
pub mod primitives;
|
||||
mod tables;
|
||||
|
||||
/// Maximum buffer size accepted by allocation-sensitive transforms.
|
||||
pub const MAX_IMAGE_SIZE: u64 = 1 << 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BufferOperation {
|
||||
Read,
|
||||
CopySource,
|
||||
CopyDestination,
|
||||
ZeroFill,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BufferOperation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Read => "read",
|
||||
Self::CopySource => "copy source",
|
||||
Self::CopyDestination => "copy destination",
|
||||
Self::ZeroFill => "zero-fill",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(
|
||||
"{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})"
|
||||
)]
|
||||
BufferRangeOutOfBounds {
|
||||
operation: BufferOperation,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
buffer_len: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum DecompressionFailure {
|
||||
#[error("compressed source size {size} exceeds limit {max}")]
|
||||
SourceTooLarge { size: u32, max: u64 },
|
||||
#[error("Huffman code length {bits} is invalid")]
|
||||
InvalidCodeLength { bits: u8 },
|
||||
#[error("Huffman tree traversal exceeded 64 levels")]
|
||||
HuffmanTraversalLimit,
|
||||
#[error("pending length accumulator overflowed at {pending}")]
|
||||
PendingLengthOverflow { pending: u32 },
|
||||
#[error("output step {step} at byte {written} exceeds expected size {expected}")]
|
||||
OutputOverflow {
|
||||
written: u32,
|
||||
step: u32,
|
||||
expected: u32,
|
||||
},
|
||||
#[error("run-fill width {width} reads before output offset 0x{destination:08X}")]
|
||||
RunFillBeforeOutput { width: u32, destination: u32 },
|
||||
#[error("run-fill width {width} is unsupported")]
|
||||
InvalidRunFillWidth { width: u32 },
|
||||
#[error("back-reference distance {distance} exceeds {written} written bytes")]
|
||||
InvalidBackReference { distance: u32, written: u32 },
|
||||
#[error("Huffman symbol consumed no input and produced no output")]
|
||||
NoProgress,
|
||||
#[error(
|
||||
"output size mismatch (wrote {written}/{expected} bytes after consuming {consumed}/{source_size})"
|
||||
)]
|
||||
OutputSizeMismatch {
|
||||
written: u32,
|
||||
expected: u32,
|
||||
consumed: u32,
|
||||
source_size: u32,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,11 +146,11 @@ mod tests {
|
||||
#[test]
|
||||
fn generated_tables_match_committed_bytes() {
|
||||
assert_eq!(COLUMMIX1.len(), 1024);
|
||||
assert_eq!(super::super::crc32::compute(&COLUMMIX1), 0x7e8d_5d5f);
|
||||
assert_eq!(super::super::crc32::compute(&COLUMMIX2), 0xfcc4_acfc);
|
||||
assert_eq!(super::super::crc32::compute(&COLUMMIX3), 0x637a_f0cd);
|
||||
assert_eq!(super::super::crc32::compute(&COLUMMIX4), 0x1e7b_c381);
|
||||
assert_eq!(super::super::crc32::compute(&SBOX), 0x10fd_6dc1);
|
||||
assert_eq!(crate::crc32::compute(&COLUMMIX1), 0x7e8d_5d5f);
|
||||
assert_eq!(crate::crc32::compute(&COLUMMIX2), 0xfcc4_acfc);
|
||||
assert_eq!(crate::crc32::compute(&COLUMMIX3), 0x637a_f0cd);
|
||||
assert_eq!(crate::crc32::compute(&COLUMMIX4), 0x1e7b_c381);
|
||||
assert_eq!(crate::crc32::compute(&SBOX), 0x10fd_6dc1);
|
||||
// Spot-check the first dword of each (matches the original first row).
|
||||
assert_eq!(&COLUMMIX1[..4], &[0x50, 0xa7, 0xf4, 0x51]);
|
||||
assert_eq!(&COLUMMIX2[..4], &[0xa7, 0xf4, 0x51, 0x50]);
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "senbei-io"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Filesystem, scanning, logging, and CLI orchestration for Senbei"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
indicatif.workspace = true
|
||||
owo-colors.workspace = true
|
||||
senbei-metadata.workspace = true
|
||||
senbei-pe.workspace = true
|
||||
walkdir.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows.workspace = true
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::unpacker;
|
||||
use senbei_pe as unpacker;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Crackproof header key table lives at this fixed file offset. For the
|
||||
@@ -518,7 +518,7 @@ pub fn run_folder_opts(
|
||||
// il2cpp metadata pass. Crackproof's `-GMD` option obfuscates the method
|
||||
// tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked
|
||||
// il2cpp game assembly resolves methods instead of indexing its per-module
|
||||
// tables out of bounds (see [`crate::metadata`]). This is additive to the
|
||||
// tables out of bounds (see [`senbei_metadata`]). This is additive to the
|
||||
// Crackproof module unpack above — the metadata blob is not itself a
|
||||
// Crackproof file.
|
||||
for meta in metas {
|
||||
@@ -660,7 +660,7 @@ pub fn run_file_v(
|
||||
let mut buf = [0u8; 4];
|
||||
std::fs::File::open(input)
|
||||
.and_then(|mut f| f.read_exact(&mut buf))
|
||||
.map(|_| crate::metadata::is_metadata(&buf))
|
||||
.map(|_| senbei_metadata::is_metadata(&buf))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
@@ -801,13 +801,13 @@ fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// If `e`'s chain contains [`crate::metadata::Error::UnsupportedVersion`],
|
||||
/// If `e`'s chain contains [`senbei_metadata::Error::UnsupportedVersion`],
|
||||
/// return the version. Used to apply the folder-mode "leave untouched, don't
|
||||
/// fail the run" policy to metadata versions this build can't de-obfuscate.
|
||||
fn unsupported_version(e: &anyhow::Error) -> Option<u32> {
|
||||
for cause in e.chain() {
|
||||
if let Some(crate::metadata::Error::UnsupportedVersion(v)) =
|
||||
cause.downcast_ref::<crate::metadata::Error>()
|
||||
if let Some(senbei_metadata::Error::UnsupportedVersion(v)) =
|
||||
cause.downcast_ref::<senbei_metadata::Error>()
|
||||
{
|
||||
return Some(*v);
|
||||
}
|
||||
@@ -831,26 +831,20 @@ fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Detect `bytes` and run the right pipeline. The EXE pipeline is invoked
|
||||
/// directly (no DLL-pipeline probe) when the input was spliced from an
|
||||
/// external companion (`spliced`) or when the caller forces it (`force_exe`
|
||||
/// — the web app's recovery path after a DLL-probe trap; see
|
||||
/// [`unpack_bytes_force_exe`]).
|
||||
/// Detect `bytes` and run the right pipeline. Spliced external companions use
|
||||
/// the EXE pipeline directly because that layout is definitionally EXE-style.
|
||||
///
|
||||
/// Routing spliced inputs straight to the EXE pipeline is safe: the
|
||||
/// companion layout is definitionally the EXE-style shell (the runtime
|
||||
/// loader maps the companion and runs the standard shell unpack), so the DLL
|
||||
/// pipeline probe can never be right for it — and probing is not a no-op on
|
||||
/// targets without unwinding (wasm), where the probe's caught panic becomes
|
||||
/// a fatal trap. Output bytes are identical to the dll-first + exe-fallback
|
||||
/// route for every input that route handles.
|
||||
/// pipeline probe can never be right for it. Output bytes are identical to the
|
||||
/// DLL-first + EXE-fallback route for every input that route handles.
|
||||
fn unpack_spliced_or_auto(
|
||||
bytes: &[u8],
|
||||
spliced: bool,
|
||||
force_exe: bool,
|
||||
verbose: bool,
|
||||
) -> Result<(unpacker::Kind, Vec<u8>), unpacker::UnpackError> {
|
||||
if spliced || force_exe {
|
||||
if spliced {
|
||||
let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?;
|
||||
let out = unpacker::unpack_exe_v(bytes, verbose)?;
|
||||
return Ok((detected.kind, out));
|
||||
@@ -880,38 +874,23 @@ pub struct UnpackedImage {
|
||||
/// Unpack in-memory `input` bytes, optionally paired with an external
|
||||
/// companion payload `companion` (the `<input>._` file's contents).
|
||||
///
|
||||
/// This is the I/O-free counterpart of [`unpack_one_v`], used by the
|
||||
/// WebAssembly build: splice (when the companion's first 32 bytes match the
|
||||
/// stub header), unpack, overlay the export table and TLS directory from the
|
||||
/// stub, then run the static integrity check.
|
||||
/// This is the in-memory counterpart of [`unpack_one_v`]: splice a matching
|
||||
/// companion, unpack, overlay the export table and TLS directory from the stub,
|
||||
/// then run the static integrity check.
|
||||
pub fn unpack_bytes(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
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<UnpackedImage, unpacker::UnpackError> {
|
||||
unpack_bytes_impl(input, companion, true)
|
||||
unpack_bytes_impl(input, companion)
|
||||
}
|
||||
|
||||
fn unpack_bytes_impl(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
force_exe: bool,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
let spliced = companion.and_then(|c| splice_companion(input, c));
|
||||
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
||||
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), false)?;
|
||||
if spliced.is_some() {
|
||||
overlay_exports_from_stub(&mut out, input);
|
||||
restore_tls_from_stub(&mut out, input);
|
||||
@@ -933,7 +912,7 @@ pub fn unpack_one_v(
|
||||
verbose: bool,
|
||||
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||
let UnpackerInput { bytes, stub } = read_unpacker_input(input)?;
|
||||
let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), false, verbose)?;
|
||||
let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), verbose)?;
|
||||
// External-companion layout: restore the export table from the stub, which
|
||||
// the encrypted companion does not carry (the loader rebuilds it at runtime).
|
||||
if let Some(stub) = stub {
|
||||
@@ -960,7 +939,7 @@ pub fn unpack_one_v(
|
||||
/// into a sparse, original-metadata-style value; il2cpp expects the contiguous
|
||||
/// per-module index it indexes its codegen tables with, so a statically-unpacked
|
||||
/// il2cpp game assembly reads garbage and crashes during init. This rewrites
|
||||
/// the tokens back to their canonical form (see [`crate::metadata::deobfuscate`]).
|
||||
/// the tokens back to their canonical form (see [`senbei_metadata::deobfuscate`]).
|
||||
///
|
||||
/// The output is written only when something actually changed
|
||||
/// (`report.remapped > 0`); an already-clean metadata is left untouched and no
|
||||
@@ -970,11 +949,11 @@ pub fn deobfuscate_metadata_to(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
verbose: bool,
|
||||
) -> anyhow::Result<crate::metadata::Report> {
|
||||
) -> anyhow::Result<senbei_metadata::Report> {
|
||||
let data = std::fs::read(input)?;
|
||||
// Preserve the metadata::Error in the chain (rather than stringifying it)
|
||||
// so the folder driver can apply its unsupported-version policy.
|
||||
let (out, report) = crate::metadata::deobfuscate(&data)
|
||||
let (out, report) = senbei_metadata::deobfuscate(&data)
|
||||
.map_err(|e| anyhow::Error::new(e).context(format!("{input:?}")))?;
|
||||
if report.remapped > 0 {
|
||||
if let Some(parent) = dest.parent() {
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Filesystem and command-line orchestration.
|
||||
|
||||
pub mod job;
|
||||
pub mod logfile;
|
||||
pub mod metadata;
|
||||
pub mod pause;
|
||||
pub mod scan;
|
||||
pub mod ui;
|
||||
pub mod unpacker;
|
||||
@@ -79,7 +79,7 @@ fn local_parts() -> LocalParts {
|
||||
second: st.wSecond as u32,
|
||||
}
|
||||
}
|
||||
#[cfg(all(not(windows), not(target_arch = "wasm32")))]
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Local wall clock via POSIX localtime_r — same semantics as Windows GetLocalTime.
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -102,13 +102,6 @@ fn local_parts() -> LocalParts {
|
||||
second: tm.tm_sec as u32,
|
||||
}
|
||||
}
|
||||
#[cfg(all(not(windows), target_arch = "wasm32"))]
|
||||
{
|
||||
// wasm has no local timezone database and SystemTime::now() panics
|
||||
// without a JS time source. The run log is a CLI concern — the wasm
|
||||
// build never writes one — so a fixed epoch stamp suffices.
|
||||
utc_parts(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Unix UTC seconds to civil Y-M-D h:m:s (Howard Hinnant).
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::unpacker::detect;
|
||||
use senbei_pe::detect;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
@@ -16,12 +16,12 @@ const DETECT_PREFIX: u64 = 8 * 1024;
|
||||
/// Smallest file that can possibly be a target, so anything shorter is skipped
|
||||
/// without ever being opened.
|
||||
///
|
||||
/// A Crackproof module needs ≥ 4128 bytes for [`crate::unpacker::detect`]'s key
|
||||
/// A Crackproof module needs ≥ 4128 bytes for [`senbei_pe::detect`]'s key
|
||||
/// table (it reads the dword at 4124), so the bound is exact for the unpack
|
||||
/// path. An il2cpp `global-metadata.dat` only needs 4 bytes to match its magic,
|
||||
/// but its header alone runs to offset 0xB0 and the images/types/methods tables
|
||||
/// it indexes make every real one megabytes long — a sub-4 KiB "metadata" could
|
||||
/// only ever fail [`crate::metadata::deobfuscate`] with `Malformed`, so nothing
|
||||
/// only ever fail [`senbei_metadata::deobfuscate`] with `Malformed`, so nothing
|
||||
/// processable is lost.
|
||||
const MIN_SIZE: u64 = 4128;
|
||||
|
||||
@@ -223,7 +223,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
||||
// `Some(Class::None)` means "probed, matched neither detector".
|
||||
let n = paths.len();
|
||||
let mut class: Vec<Option<Class>> = vec![Some(Class::None); n];
|
||||
let workers = crate::unpacker::parallel::thread_cap().clamp(1, n.max(1));
|
||||
let workers = senbei_pe::thread_cap().clamp(1, n.max(1));
|
||||
if workers <= 1 {
|
||||
for (p, c) in paths.iter().zip(class.iter_mut()) {
|
||||
*c = classify(p);
|
||||
@@ -304,7 +304,7 @@ fn classify(path: &Path) -> Option<Class> {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if detect(&head).is_some() {
|
||||
Class::Crackproof
|
||||
} else if crate::metadata::is_metadata(&head) {
|
||||
} else if senbei_metadata::is_metadata(&head) {
|
||||
Class::Metadata
|
||||
} else {
|
||||
Class::None
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::unpacker::{IntegrityReport, Kind};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use owo_colors::OwoColorize;
|
||||
use senbei_pe::{IntegrityReport, Kind};
|
||||
use std::path::Path;
|
||||
|
||||
/// Create a progress bar for `n` items. Hidden when `quiet` is true.
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "senbei-metadata"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Unity il2cpp metadata de-obfuscation for Senbei"
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Unity il2cpp metadata de-obfuscation.
|
||||
|
||||
mod metadata;
|
||||
|
||||
pub use metadata::*;
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "senbei-pe"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "PE detection, unpacking, and validation for Senbei"
|
||||
|
||||
[dependencies]
|
||||
senbei-crypto.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,3 @@
|
||||
mod pipeline;
|
||||
|
||||
pub use pipeline::*;
|
||||
@@ -13,12 +13,12 @@
|
||||
//! CalculateChecksumWithSizeXor -> primitives::calculate_checksum
|
||||
//! CalculateCrc32 -> crc32::compute (via above)
|
||||
|
||||
use super::bytecode::{Op, OpsLut, generate};
|
||||
use super::primitives::{self, *};
|
||||
use super::{
|
||||
use super::super::{
|
||||
BufferOperation, BytecodeStage, DecompressionStage, DescriptorTable, SectionPipeline,
|
||||
UnpackError,
|
||||
};
|
||||
use senbei_crypto::bytecode::{Op, OpsLut, generate};
|
||||
use senbei_crypto::primitives::{self, *};
|
||||
|
||||
/// Read a signed 32-bit little-endian value.
|
||||
fn get_i32(d: &[u8], offset: i32) -> i32 {
|
||||
@@ -291,7 +291,7 @@ fn decrypt_and_decompress_data(
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
super::parallel::parallel_for(d, &spans, 1, do_block)?;
|
||||
super::super::parallel::parallel_for(d, &spans, 1, do_block)?;
|
||||
}
|
||||
|
||||
// Zero-fill loop.
|
||||
@@ -342,7 +342,7 @@ pub fn unpack_dll(input: &[u8]) -> Result<Vec<u8>, UnpackError> {
|
||||
pub fn unpack_dll_v(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
|
||||
// Trap any out-of-bounds panic from a truncated/garbled file and report it
|
||||
// as a clean error so the public API stays panic-free.
|
||||
super::catch_unpack(move || unpack_dll_inner(input, verbose))
|
||||
super::super::catch_unpack(move || unpack_dll_inner(input, verbose))
|
||||
}
|
||||
|
||||
fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
|
||||
@@ -369,7 +369,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError>
|
||||
println!(" keys[6] anchor = 0x{:08X}", keys[6] as u32);
|
||||
}
|
||||
|
||||
if !super::is_supported_magic(keys[1] as u32) {
|
||||
if !super::super::is_supported_magic(keys[1] as u32) {
|
||||
return Err(UnpackError::HeaderMagicMismatch {
|
||||
found: keys[1] as u32,
|
||||
});
|
||||
@@ -395,10 +395,10 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError>
|
||||
});
|
||||
}
|
||||
let size_of_image = get_i32(file_data, pe_offset + 80);
|
||||
if size_of_image <= 0 || size_of_image as u64 > super::MAX_IMAGE_SIZE {
|
||||
if size_of_image <= 0 || size_of_image as u64 > super::super::MAX_IMAGE_SIZE {
|
||||
return Err(UnpackError::InvalidImageSize {
|
||||
size: i64::from(size_of_image),
|
||||
max: super::MAX_IMAGE_SIZE,
|
||||
max: super::super::MAX_IMAGE_SIZE,
|
||||
});
|
||||
}
|
||||
let mut out = vec![0u8; size_of_image as usize];
|
||||
@@ -567,7 +567,7 @@ fn unpack_dll_inner(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError>
|
||||
let crc_val = {
|
||||
let a = crc_data_addr as usize;
|
||||
let n = crc_data_size as usize;
|
||||
super::crc32::compute(&out[a..a + n]) as i32
|
||||
senbei_crypto::crc32::compute(&out[a..a + n]) as i32
|
||||
};
|
||||
let crc_xored = crc_data_size ^ crc_val;
|
||||
let trailing_val = get_i32(&out, crc_data_addr + crc_data_size - 4);
|
||||
@@ -0,0 +1,243 @@
|
||||
pub use senbei_crypto::{BufferOperation, DecompressionFailure};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecompressionStage {
|
||||
ExeStage3,
|
||||
ExeStage3Secondary,
|
||||
ExeStage4,
|
||||
ExeStage5,
|
||||
Pe32FourthStage,
|
||||
Pe32FifthStage,
|
||||
Pe32SeventhStage,
|
||||
DllCodeBlock1,
|
||||
DllCodeBlock2,
|
||||
DllCodeBlock3,
|
||||
DllCodeBlock4,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DecompressionStage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ExeStage3 => "EXE stage3",
|
||||
Self::ExeStage3Secondary => "EXE secondary stage3",
|
||||
Self::ExeStage4 => "EXE stage4",
|
||||
Self::ExeStage5 => "EXE stage5",
|
||||
Self::Pe32FourthStage => "PE32 fourth stage",
|
||||
Self::Pe32FifthStage => "PE32 fifth stage",
|
||||
Self::Pe32SeventhStage => "PE32 seventh stage",
|
||||
Self::DllCodeBlock1 => "DLL code block 1",
|
||||
Self::DllCodeBlock2 => "DLL code block 2",
|
||||
Self::DllCodeBlock3 => "DLL code block 3",
|
||||
Self::DllCodeBlock4 => "DLL code block 4",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BytecodeStage {
|
||||
ExeStage4,
|
||||
ExeStage5,
|
||||
Pe32CustomDecryptor,
|
||||
Pe32FileDecryptor,
|
||||
DllPrimaryDecryptor,
|
||||
DllSectionDecryptor,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BytecodeStage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ExeStage4 => "EXE stage4",
|
||||
Self::ExeStage5 => "EXE stage5",
|
||||
Self::Pe32CustomDecryptor => "PE32 custom decryptor",
|
||||
Self::Pe32FileDecryptor => "PE32 file decryptor",
|
||||
Self::DllPrimaryDecryptor => "DLL primary decryptor",
|
||||
Self::DllSectionDecryptor => "DLL section decryptor",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SectionPipeline {
|
||||
ExePe32Plus,
|
||||
ExePe32,
|
||||
Dll,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SectionPipeline {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ExePe32Plus => "PE32+ EXE",
|
||||
Self::ExePe32 => "PE32 EXE",
|
||||
Self::Dll => "DLL",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DescriptorTable {
|
||||
DllSectionBlocks,
|
||||
DllZeroFill,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DescriptorTable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::DllSectionBlocks => "DLL section-block",
|
||||
Self::DllZeroFill => "DLL zero-fill",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum UnpackError {
|
||||
#[error("input too short (need at least {required} bytes, got {actual})")]
|
||||
InputTooShort { actual: usize, required: usize },
|
||||
|
||||
#[error("decrypted header magic mismatch (got 0x{found:08X})")]
|
||||
HeaderMagicMismatch { found: u32 },
|
||||
|
||||
#[error("anchor field not found — corrupt data or wrong offset")]
|
||||
AnchorNotFound,
|
||||
|
||||
#[error("stage1 descriptor not found near anchor 0x{anchor:08X}")]
|
||||
Stage1DescriptorNotFound { anchor: u32 },
|
||||
|
||||
#[error("stage2 field not found — corrupt data or wrong offset")]
|
||||
Stage2NotFound,
|
||||
|
||||
#[error("chk_src_start not found — corrupt data or wrong offset")]
|
||||
ChkSrcStartNotFound,
|
||||
|
||||
#[error("table_start not found — corrupt data or wrong offset")]
|
||||
TableStartNotFound,
|
||||
|
||||
#[error("{0} bytecode generation failed — corrupt data or wrong offset")]
|
||||
BytecodeGenerationFailed(BytecodeStage),
|
||||
|
||||
#[error("stage5 marker not found — this build's layout is not supported by this unpacker")]
|
||||
Stage5MarkerNotFound,
|
||||
|
||||
#[error("not a Crackproof-protected file")]
|
||||
NotCrackproof,
|
||||
|
||||
#[error("invalid PE header offset {offset} for {input_len}-byte input")]
|
||||
InvalidPeOffset { offset: i64, input_len: usize },
|
||||
|
||||
#[error("DLL pipeline requires PE32+ optional-header magic, got 0x{found:04X}")]
|
||||
UnsupportedDllPeMagic { found: u16 },
|
||||
|
||||
#[error(
|
||||
"DLL primary descriptor address 0x{address:08X} is below layout base 0x{minimum:08X} or outside {image_len}-byte image"
|
||||
)]
|
||||
InvalidDllPrimaryDescriptor {
|
||||
address: u32,
|
||||
minimum: u32,
|
||||
image_len: usize,
|
||||
},
|
||||
|
||||
#[error("invalid SizeOfImage {size}; expected 1..={max}")]
|
||||
InvalidImageSize { size: i64, max: u64 },
|
||||
|
||||
#[error(
|
||||
"{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})"
|
||||
)]
|
||||
BufferRangeOutOfBounds {
|
||||
operation: BufferOperation,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
buffer_len: usize,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"{table} descriptor out of bounds (offset {offset}, size 16, image length {image_len})"
|
||||
)]
|
||||
DescriptorOutOfBounds {
|
||||
table: DescriptorTable,
|
||||
offset: usize,
|
||||
image_len: usize,
|
||||
},
|
||||
|
||||
#[error("PE32 tbl not found — corrupt data or wrong offset")]
|
||||
Pe32TblNotFound,
|
||||
|
||||
#[error("PE32 thirdStage decrypt failed — corrupt data or wrong offset")]
|
||||
Pe32ThirdStageFailed,
|
||||
|
||||
#[error("PE32 customDecryptor not found in sevenStage")]
|
||||
Pe32CustomDecryptorNotFound,
|
||||
|
||||
#[error("PE32 eighthStageKey not found")]
|
||||
Pe32EighthKeyNotFound,
|
||||
|
||||
#[error("PE32 file LFSR not found in eighthStage")]
|
||||
Pe32FileLfsrNotFound,
|
||||
|
||||
#[error("{stage} decompression failed: {reason}")]
|
||||
StageDecompressionFailed {
|
||||
stage: DecompressionStage,
|
||||
reason: DecompressionFailure,
|
||||
},
|
||||
|
||||
#[error("{pipeline} section block {block} decompression failed")]
|
||||
SectionDecompressionFailed {
|
||||
pipeline: SectionPipeline,
|
||||
block: usize,
|
||||
},
|
||||
|
||||
#[error("AES key schedule is outside the image at offset {offset}")]
|
||||
InvalidAesKeySchedule { offset: u32 },
|
||||
|
||||
#[error("Huffman table is outside the image at offset {offset}")]
|
||||
InvalidHuffmanTable { offset: u32 },
|
||||
|
||||
#[error("DLL pipeline failed: {dll}; EXE fallback failed: {exe}")]
|
||||
PipelineFallbackFailed {
|
||||
dll: Box<UnpackError>,
|
||||
exe: Box<UnpackError>,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})"
|
||||
)]
|
||||
Pe32SecondStageRangeInvalid {
|
||||
offset: u32,
|
||||
size: u32,
|
||||
image_len: usize,
|
||||
},
|
||||
|
||||
#[error("PE32 relocation-data descriptor not found")]
|
||||
Pe32RelocationDataNotFound,
|
||||
|
||||
#[error("file decryptor candidate failed structural validation")]
|
||||
FileDecryptorValidationFailed,
|
||||
|
||||
#[error("PE32 memory image could not be rebuilt as a file-layout PE")]
|
||||
Pe32OutputLayoutInvalid,
|
||||
|
||||
#[error("internal panic at {file}:{line}:{column}: {message}")]
|
||||
InternalPanic {
|
||||
message: String,
|
||||
file: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<senbei_crypto::Error> for UnpackError {
|
||||
fn from(error: senbei_crypto::Error) -> Self {
|
||||
match error {
|
||||
senbei_crypto::Error::BufferRangeOutOfBounds {
|
||||
operation,
|
||||
offset,
|
||||
size,
|
||||
buffer_len,
|
||||
} => Self::BufferRangeOutOfBounds {
|
||||
operation,
|
||||
offset,
|
||||
size,
|
||||
buffer_len,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod pipeline;
|
||||
|
||||
pub use pipeline::*;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
//! Internal PE layout discovery and image reconstruction.
|
||||
|
||||
mod dd8;
|
||||
mod discovery;
|
||||
mod image;
|
||||
|
||||
pub(super) use dd8::{select_dd8_formula_pe32, select_dd8_shift};
|
||||
pub(super) use discovery::{
|
||||
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos, find_tbl_pe32,
|
||||
find_v_after_pad, find_v4_offset, get_string_to_null, section_name, trial_decrypt5_u32,
|
||||
};
|
||||
pub(super) use image::{
|
||||
compact_memory_image_to_pe, move_pe32_imports_to_kmiat, pe32_imports_already_match_idata_layout,
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Validation-driven selection for per-page text transforms.
|
||||
|
||||
/// PE32 `.text` dd8 key-formula selection with a skip decision. The packer keys
|
||||
/// the per-page XOR either with `page+1` or `0x8000*(page+1)`; the formula is
|
||||
/// not recorded. Replays the dd8 page pass on a scratch copy of sample pages
|
||||
/// (25/50/75% of `.text`) under each formula and counts how many positions
|
||||
/// decode to `0xCC` (int3 padding).
|
||||
///
|
||||
/// Returns `Some(true)` for the `0x8000*(page+1)` formula, `Some(false)` for
|
||||
/// `page+1`, or `None` when `.text` must NOT be dd8-decrypted at all. The packer
|
||||
/// dd8-encrypts `.text` on EXEs (so unpacking must replay it) but leaves a native
|
||||
/// DLL's `.text` plaintext; replaying dd8 there scrambles ~1 byte per 16-byte
|
||||
/// block. The decision: dd8 only *restores* int3 padding when `.text` was
|
||||
/// genuinely encrypted, so apply it only when the chosen formula's whole-page
|
||||
/// 0xCC count rises *clearly* above the no-dd8 baseline; otherwise skip.
|
||||
///
|
||||
/// "Clearly" matters: dd8 XORs 255 positions per page with pseudo-random bytes,
|
||||
/// so on an already-plaintext `.text` it manufactures ~1 spurious `0xCC` per
|
||||
/// sampled page for free (255/256 expected). A bare `best > baseline` test is
|
||||
/// therefore biased towards *applying* dd8 on exactly the inputs that must skip
|
||||
/// it — and a wrongly-applied dd8 is silent: it scrambles ~1 byte per 16 with no
|
||||
/// error and nothing downstream (not even `integrity::check`, which only reads
|
||||
/// 16 bytes at the entry point) notices. The [`MIN_DD8_NET_GAIN`] floor below is
|
||||
/// the PE32 counterpart of the margin+floor `select_dd8_shift` already applies
|
||||
/// on PE32+ for the same failure mode.
|
||||
pub fn select_dd8_formula_pe32(data: &[u8], text_off: u32, text_size: u32) -> Option<bool> {
|
||||
let num_pages_total = text_size / 0x1000;
|
||||
let mut sample_pages: Vec<u32> = Vec::new();
|
||||
for frac in [0.25f64, 0.5, 0.75] {
|
||||
let pg = (num_pages_total as f64 * frac) as u32;
|
||||
if pg > 0 && pg < num_pages_total {
|
||||
sample_pages.push(pg);
|
||||
}
|
||||
}
|
||||
if sample_pages.is_empty() && num_pages_total > 1 {
|
||||
sample_pages.push(num_pages_total / 2);
|
||||
}
|
||||
let score = |big: bool| -> i64 {
|
||||
let mut total = 0i64;
|
||||
for &sp in &sample_pages {
|
||||
let pg_off = (text_off + sp * 0x1000) as usize;
|
||||
if pg_off + 0x1000 > data.len() {
|
||||
continue;
|
||||
}
|
||||
let mut buf = [0u8; 0x1000];
|
||||
buf.copy_from_slice(&data[pg_off..pg_off + 0x1000]);
|
||||
let pk = if big {
|
||||
0x8000u32.wrapping_mul(sp.wrapping_add(1))
|
||||
} else {
|
||||
sp.wrapping_add(1)
|
||||
};
|
||||
let mut k = pk;
|
||||
let rk = k.rotate_right(15);
|
||||
k = rk;
|
||||
for bi in 1..256u32 {
|
||||
let rk = k.rotate_right(15);
|
||||
let ri = rk.wrapping_add(bi);
|
||||
k = ri.wrapping_add(bi);
|
||||
let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize;
|
||||
if tidx < buf.len() {
|
||||
buf[tidx] ^= k as u8;
|
||||
}
|
||||
}
|
||||
total += buf.iter().filter(|&&b| b == 0xCC).count() as i64;
|
||||
}
|
||||
total
|
||||
};
|
||||
let s_small = score(false);
|
||||
let s_big = score(true);
|
||||
// Baseline: whole-page 0xCC over the same sample pages with NO dd8. dd8 only
|
||||
// rewrites 255 bytes per page, so comparing the chosen formula's whole-page
|
||||
// 0xCC against this baseline reveals whether dd8 *restores* int3 padding
|
||||
// (count rises -> .text was packer-encrypted, apply) or merely scrambles
|
||||
// already-plaintext code (count falls -> native-DLL .text left intact, skip).
|
||||
let mut baseline: i64 = 0;
|
||||
for &sp in &sample_pages {
|
||||
let pg_off = (text_off + sp * 0x1000) as usize;
|
||||
if pg_off + 0x1000 > data.len() {
|
||||
continue;
|
||||
}
|
||||
baseline += data[pg_off..pg_off + 0x1000]
|
||||
.iter()
|
||||
.filter(|&&b| b == 0xCC)
|
||||
.count() as i64;
|
||||
}
|
||||
let big = s_big > s_small;
|
||||
let best = s_small.max(s_big);
|
||||
// Minimum net 0xCC gain over the baseline before dd8 is applied. Noise on an
|
||||
// already-plaintext `.text` is ~1 manufactured 0xCC per sampled page (3 pages
|
||||
// -> ~3); every corpus build that genuinely needs dd8 gains +154 or more
|
||||
// (observed +154 and +312), and the one native DLL that must skip scores -18.
|
||||
// A floor of 32 sits ~10x above the noise and ~5x below the smallest true
|
||||
// positive, so it changes no existing decision.
|
||||
const MIN_DD8_NET_GAIN: i64 = 32;
|
||||
let apply = best.saturating_sub(baseline) >= MIN_DD8_NET_GAIN;
|
||||
if std::env::var("SEL_DIAG").is_ok() {
|
||||
eprintln!(
|
||||
"SEL pe32 dd8 s_small={} s_big={} baseline={} gain={} big={} apply={}",
|
||||
s_small,
|
||||
s_big,
|
||||
baseline,
|
||||
best - baseline,
|
||||
big,
|
||||
apply
|
||||
);
|
||||
}
|
||||
// When no interior pages could be sampled (tiny .text) we cannot measure the
|
||||
// effect; preserve the historical behavior of applying dd8.
|
||||
if sample_pages.is_empty() || apply {
|
||||
Some(big)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dd8 page-XOR shift selection.
|
||||
//
|
||||
// The packer scrambles ~1 byte per 16-byte block of .text via decrypt_data8,
|
||||
// keyed by `page_idx << shift` (absolute page index = text_va >> 12). Observed
|
||||
// shifts are 0 and 15. The shift is NOT stored in any header/config field:
|
||||
// two otherwise-unrelated builds can carry byte-identical config-version stamps
|
||||
// (0x40327253) yet require different shifts, so the only reliable discriminator
|
||||
// is the .text content itself.
|
||||
//
|
||||
// Detection scoring formula: for each candidate shift, replay decrypt_data8
|
||||
// across a few sample pages (25/50/75% of .text) and count how many of the 255
|
||||
// mutated positions become 0xCC — the MSVC int3 padding byte. The correct shift
|
||||
// hits int3 pads disproportionately often (~3-10x the baseline), so the
|
||||
// highest-scoring shift wins. If neither shift clears 2x the baseline, .text
|
||||
// is already plaintext → skip (return 99).
|
||||
//
|
||||
// This replaces an earlier entry-stub oracle that matched the 14 fixed CRT-stub
|
||||
// bytes at the AEP. That oracle false-positived on a newer EXE-64 build: dd8
|
||||
// corrupted only the call rel32 (bytes 5-8, the wildcard region), so the stub
|
||||
// matched under BOTH shifts and the selector defaulted to 0 when the truth was
|
||||
// 15. The 0xCC statistic samples hundreds of positions per page and is not
|
||||
// fooled by a stub whose fixed bytes happen to survive.
|
||||
// ---------------------------------------------------------------------------
|
||||
pub fn select_dd8_shift(data: &[u8], text_va: u32, text_size: u32, _info3: u32) -> u32 {
|
||||
if text_size < 0x1000 {
|
||||
return 0;
|
||||
}
|
||||
let text_off = text_va as usize;
|
||||
let num_pages_total = text_size >> 12;
|
||||
|
||||
// Sample pages at 25/50/75% of .text, falling back to the midpoint for tiny
|
||||
// sections.
|
||||
let mut sample_pages: Vec<u32> = Vec::new();
|
||||
for frac in [0.25f64, 0.5, 0.75] {
|
||||
let pg = (num_pages_total as f64 * frac) as u32;
|
||||
if pg > 0 && pg < num_pages_total {
|
||||
sample_pages.push(pg);
|
||||
}
|
||||
}
|
||||
if sample_pages.is_empty() && num_pages_total > 1 {
|
||||
sample_pages.push(num_pages_total / 2);
|
||||
}
|
||||
if sample_pages.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let none_hits = score_dd8_baseline(data, text_off, &sample_pages);
|
||||
let s0 = score_dd8_shift(data, text_off, text_va, &sample_pages, 0);
|
||||
let s15 = score_dd8_shift(data, text_off, text_va, &sample_pages, 15);
|
||||
let mut best_score = none_hits;
|
||||
let mut best_shift = 99u32; // 99 == skip dd8
|
||||
for (shift, hits) in [(0u32, s0), (15u32, s15)] {
|
||||
if hits > best_score {
|
||||
best_score = hits;
|
||||
best_shift = shift;
|
||||
}
|
||||
}
|
||||
// Require a clear 2x margin over the already-plaintext baseline AND an
|
||||
// absolute floor. The 2x test alone
|
||||
// trips on noise when the counts are tiny: an external-companion DLL whose
|
||||
// .text is already plaintext scores s15=4 vs none=1 — a spurious 4x — and
|
||||
// gets dd8 wrongly applied, corrupting ~1 byte per 16. Across the whole
|
||||
// golden corpus every build that genuinely needs dd8 scores >= 10 (lowest
|
||||
// observed scores at 10-12; up to 107), so a floor of 8 rejects the noise
|
||||
// while keeping every golden's shift selection unchanged.
|
||||
const MIN_DD8_HITS: u32 = 8;
|
||||
if best_shift != 99 && (best_score < none_hits * 2 || best_score < MIN_DD8_HITS) {
|
||||
best_shift = 99;
|
||||
}
|
||||
if std::env::var("SEL_DIAG").is_ok() {
|
||||
eprintln!(
|
||||
"SEL dd8 best_shift={} s0={} s15={} none_hits={} samples={:?}",
|
||||
best_shift, s0, s15, none_hits, sample_pages
|
||||
);
|
||||
}
|
||||
best_shift
|
||||
}
|
||||
|
||||
// Baseline: count int3 pads already present at the first byte of each 16-byte
|
||||
// block, i.e. the positions dd8 would target if its in-block offset were 0.
|
||||
fn score_dd8_baseline(data: &[u8], text_off: usize, sample_pages: &[u32]) -> u32 {
|
||||
let mut hits = 0u32;
|
||||
for &sp in sample_pages {
|
||||
let pg_off = text_off + (sp as usize) * 0x1000;
|
||||
if pg_off + 0x1000 > data.len() {
|
||||
continue;
|
||||
}
|
||||
for bi in 1..256usize {
|
||||
if data[pg_off + bi * 16] == 0xCC {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
// Replay decrypt_data8 on each sample page under `shift` and count how many of
|
||||
// the 255 mutated positions decode to 0xCC.
|
||||
fn score_dd8_shift(
|
||||
data: &[u8],
|
||||
text_off: usize,
|
||||
text_va: u32,
|
||||
sample_pages: &[u32],
|
||||
shift: u32,
|
||||
) -> u32 {
|
||||
let abs_base = text_va >> 12;
|
||||
let mut hits = 0u32;
|
||||
for &sp in sample_pages {
|
||||
let pg_off = text_off + (sp as usize) * 0x1000;
|
||||
if pg_off + 0x1000 > data.len() {
|
||||
continue;
|
||||
}
|
||||
let abs_page = abs_base.wrapping_add(sp);
|
||||
let mut key = abs_page << shift;
|
||||
for bi in 0..256u32 {
|
||||
let mixed = key.rotate_right(15).wrapping_add(bi);
|
||||
key = mixed.wrapping_add(bi);
|
||||
if bi == 0 {
|
||||
continue;
|
||||
}
|
||||
let tidx = (bi.wrapping_mul(16).wrapping_add(mixed & 0xF)) as usize;
|
||||
if tidx < 0x1000 {
|
||||
let mutated = data[pg_off + tidx] ^ (key as u8);
|
||||
if mutated == 0xCC {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Seed the first `count` dd8-targeted positions of each sampled page with
|
||||
/// the byte that decodes to `0xCC` under the `page+1` formula — i.e. an
|
||||
/// encrypted `.text` whose plaintext is int3 padding. Positions whose key
|
||||
/// byte would make the *ciphertext* itself `0xCC` are skipped so the
|
||||
/// fixture contains no `0xCC` at all and every post-dd8 `0xCC` is a genuine
|
||||
/// gain over a zero baseline.
|
||||
fn seed_dd8_int3(data: &mut [u8], text_off: u32, pages: &[u32], count: u32) {
|
||||
for &sp in pages {
|
||||
let pg_off = (text_off + sp * 0x1000) as usize;
|
||||
let mut k = sp.wrapping_add(1);
|
||||
k = k.rotate_right(15);
|
||||
let mut planted = 0u32;
|
||||
for bi in 1..256u32 {
|
||||
let ri = k.rotate_right(15).wrapping_add(bi);
|
||||
k = ri.wrapping_add(bi);
|
||||
if planted >= count {
|
||||
continue;
|
||||
}
|
||||
let ct = 0xCCu8 ^ (k as u8);
|
||||
if ct == 0xCC {
|
||||
continue;
|
||||
}
|
||||
let tidx = (bi.wrapping_mul(16).wrapping_add(ri & 0xF)) as usize;
|
||||
data[pg_off + tidx] = ct;
|
||||
planted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Review regression: a near-plaintext `.text` must NOT be dd8-decrypted.
|
||||
/// dd8 XORs 255 positions per page with pseudo-random bytes, so it
|
||||
/// manufactures a few `0xCC` for free — under the old bare
|
||||
/// `best > baseline` test any positive gain was enough to "apply" dd8 and
|
||||
/// scramble ~1 byte per 16 of a native DLL's already-plaintext code,
|
||||
/// silently (nothing downstream, including the integrity check, notices).
|
||||
/// Here the gain is real but small; the floor must still reject it.
|
||||
#[test]
|
||||
fn pe32_dd8_skips_text_whose_gain_is_only_noise_sized() {
|
||||
let text_off: u32 = 0x1000;
|
||||
let text_size: u32 = 8 * 0x1000;
|
||||
let mut data = vec![0u8; (text_off + text_size) as usize];
|
||||
seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 5);
|
||||
assert!(
|
||||
!data.contains(&0xCC),
|
||||
"fixture must have a zero 0xCC baseline"
|
||||
);
|
||||
assert_eq!(
|
||||
select_dd8_formula_pe32(&data, text_off, text_size),
|
||||
None,
|
||||
"a gain this small is indistinguishable from dd8's own noise"
|
||||
);
|
||||
}
|
||||
|
||||
/// Control for the above: a `.text` whose dd8 pass restores a large amount
|
||||
/// of int3 padding clears the floor and is decrypted. Same fixture shape,
|
||||
/// only the amount of restored padding differs.
|
||||
#[test]
|
||||
fn pe32_dd8_applies_when_padding_is_restored() {
|
||||
let text_off: u32 = 0x1000;
|
||||
let text_size: u32 = 8 * 0x1000;
|
||||
let mut data = vec![0u8; (text_off + text_size) as usize];
|
||||
seed_dd8_int3(&mut data, text_off, &[2, 4, 6], 255);
|
||||
assert_eq!(
|
||||
select_dd8_formula_pe32(&data, text_off, text_size),
|
||||
Some(false),
|
||||
"encrypted .text must be decrypted with the page+1 formula"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Structural locators for protected PE stages.
|
||||
|
||||
use senbei_crypto::primitives::{get_u32, lfsr_keystream};
|
||||
|
||||
/// Find the 4-byte v_val that follows the LAST occurrence of `48 EB 01 B9`
|
||||
/// (REX.W jmp+1; mov ecx,imm32) plus any 0xCC padding. Used to locate
|
||||
/// stage4's accum2 seed. Works across builds even when API-name anchors are
|
||||
/// absent.
|
||||
pub fn find_v_after_pad(data: &[u8], base: u32, len: u32) -> Option<u32> {
|
||||
let start = base as usize;
|
||||
let end = (base.saturating_add(len)) as usize;
|
||||
if end > data.len() {
|
||||
return None;
|
||||
}
|
||||
let sig = [0x48u8, 0xEB, 0x01, 0xB9];
|
||||
let slice = &data[start..end];
|
||||
// last occurrence
|
||||
let mut last = None;
|
||||
let mut i = 0usize;
|
||||
while i + sig.len() <= slice.len() {
|
||||
if slice[i..i + sig.len()] == sig {
|
||||
last = Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let pos = last?;
|
||||
// skip CCs after the `48 EB 01 B9`
|
||||
let mut after = pos + sig.len();
|
||||
while after < slice.len() && slice[after] == 0xCC {
|
||||
after += 1;
|
||||
}
|
||||
if after + 4 > slice.len() {
|
||||
return None;
|
||||
}
|
||||
Some((start + after) as u32)
|
||||
}
|
||||
|
||||
/// Predict the 4 bytes that DecryptData5(va, size) would produce at va+0..va+4
|
||||
/// without mutating the buffer. The cipher's per-byte transform depends only
|
||||
/// on the byte itself and the low 8 bits of (va+i), with no cross-byte state,
|
||||
/// so each byte can be decrypted in isolation. Used to detect the EP/DD layout
|
||||
/// offset before committing to the actual call.
|
||||
pub fn trial_decrypt5_u32(data: &[u8], va: u32) -> u32 {
|
||||
let mut out = [0u8; 4];
|
||||
for i in 0..4u32 {
|
||||
let b3 = data[(va + i) as usize];
|
||||
let b = (va + i) as u8;
|
||||
let b2 = b.wrapping_add(1);
|
||||
let b4 = b3.rotate_left(2) ^ b2;
|
||||
let b5 = b4.rotate_left(2) ^ b;
|
||||
out[i as usize] = b5.rotate_left(2);
|
||||
}
|
||||
u32::from_le_bytes(out)
|
||||
}
|
||||
|
||||
/// Scan stage4/stage5 for the encrypted custom-decryptor bytecode block. The
|
||||
/// raw byte at p+95 is used by decrypt_data6 as the iteration count. We trial-
|
||||
/// decrypt that many bytes with the LFSR keystream and accept the first
|
||||
/// position where the byte stream parses as a valid opcode sequence ending in
|
||||
/// 195 (ret).
|
||||
pub fn find_bytecode_offset(data: &[u8], base: u32, len: u32) -> Option<u32> {
|
||||
let start = base as usize;
|
||||
let end = (base.saturating_add(len)) as usize;
|
||||
if end > data.len() {
|
||||
return None;
|
||||
}
|
||||
let mut ks = [0u8; 256];
|
||||
lfsr_keystream(&mut ks);
|
||||
// Scan forward from `start+16` on 16-byte boundaries relative to `start`.
|
||||
// The bytecode block is positioned a fixed offset into stage4/stage5; the
|
||||
// lowest parseable candidate is the real one (later ones are coincidental
|
||||
// parses of trailing filler bytes that happen to map to valid opcodes).
|
||||
// The enclosing buffer isn't necessarily 16-aligned to its absolute
|
||||
// address in newer builds, so we anchor the stride to `start`.
|
||||
let mut p = start + 16;
|
||||
while p + 96 <= end {
|
||||
let count = data[p + 95] as usize;
|
||||
if count >= 8 && p + count <= end {
|
||||
let mut buf = [0u8; 256];
|
||||
let take = count.min(256);
|
||||
for i in 0..take {
|
||||
buf[i] = data[p + i] ^ ks[i];
|
||||
}
|
||||
if let Some(nops) = parse_bytecode_check(&buf[..take])
|
||||
&& nops >= 4
|
||||
{
|
||||
return Some(p as u32);
|
||||
}
|
||||
}
|
||||
p += 16;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate bytecode structure without allocating a `Vec` of ops. Returns
|
||||
/// `Some(non_nop_op_count)` if the byte stream parses successfully as a valid
|
||||
/// opcode sequence ending in 195 (ret), `None` otherwise. Allows non-trivial
|
||||
/// bytecode filtering by op count.
|
||||
pub fn parse_bytecode_check(buf: &[u8]) -> Option<usize> {
|
||||
let mut i = 0usize;
|
||||
let mut nops: usize = 0;
|
||||
while i < buf.len() {
|
||||
let b = buf[i];
|
||||
i += 1;
|
||||
match b {
|
||||
4 | 44 | 52 => {
|
||||
if i >= buf.len() {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
nops += 1;
|
||||
}
|
||||
144 => {}
|
||||
192 | 254 => {
|
||||
if i >= buf.len() {
|
||||
return None;
|
||||
}
|
||||
let mb = buf[i];
|
||||
i += 1;
|
||||
let rm = mb & 7;
|
||||
let mod_ = (mb >> 6) & 3;
|
||||
let reg = (mb >> 3) & 7;
|
||||
if mod_ != 3 || rm != 0 {
|
||||
return None;
|
||||
}
|
||||
if reg > 1 {
|
||||
return None;
|
||||
}
|
||||
if b == 192 {
|
||||
if i >= buf.len() {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
nops += 1;
|
||||
}
|
||||
195 => return Some(nops),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Locate stage3's v4_val: the last non-zero dword in the buffer, anchored
|
||||
/// by the `C3 CC CC CC` (ret + 3 int3) immediately before it.
|
||||
pub fn find_v4_offset(data: &[u8], base: u32, len: u32) -> Option<u32> {
|
||||
let start = base as usize;
|
||||
let end = (base.saturating_add(len)) as usize;
|
||||
if end > data.len() || end < start + 4 {
|
||||
return None;
|
||||
}
|
||||
// walk backwards looking for the first non-zero byte
|
||||
let mut i = end;
|
||||
while i > start && data[i - 1] == 0 {
|
||||
i -= 1;
|
||||
}
|
||||
if i < start + 4 {
|
||||
return None;
|
||||
}
|
||||
// v_val occupies the 4 bytes ending at i (rounded up to dword boundary)
|
||||
let v_end = i;
|
||||
let v_start = ((v_end + 3) & !3).saturating_sub(4);
|
||||
// require that the 4 bytes preceding v_val match `C3 CC CC CC`
|
||||
if v_start < start + 4 || data[v_start - 4..v_start] != [0xC3, 0xCC, 0xCC, 0xCC] {
|
||||
return None;
|
||||
}
|
||||
Some(v_start as u32)
|
||||
}
|
||||
|
||||
/// Scan a sub-buffer for an ASCII needle; return its absolute position.
|
||||
pub fn find_str_pos(data: &[u8], base: u32, len: u32, needle: &[u8]) -> Option<u32> {
|
||||
let start = base as usize;
|
||||
let end = (base.saturating_add(len)) as usize;
|
||||
if end > data.len() || needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
data[start..end]
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
.map(|rel| (start + rel) as u32)
|
||||
}
|
||||
|
||||
pub fn get_string_to_null(data: &[u8], offset: u32) -> String {
|
||||
let start = offset as usize;
|
||||
if start >= data.len() {
|
||||
return String::new();
|
||||
}
|
||||
// Bounded: an unterminated run must never walk off the end of the buffer
|
||||
// (panic) or scan unboundedly into unrelated data.
|
||||
let limit = start.saturating_add(4096).min(data.len());
|
||||
let mut i = start;
|
||||
while i < limit && data[i] != 0 {
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&data[start..i]).into_owned()
|
||||
}
|
||||
|
||||
/// Read a PE section-name field: exactly 8 bytes, NOT necessarily
|
||||
/// NUL-terminated (a full-width name like `.textbss` has no NUL at all).
|
||||
/// Returns the name with trailing NULs stripped. Using `get_string_to_null`
|
||||
/// here would run past the field into the VirtualSize/VirtualAddress dwords.
|
||||
pub fn section_name(data: &[u8], offset: u32) -> String {
|
||||
let start = offset as usize;
|
||||
let Some(field) = data.get(start..start + 8) else {
|
||||
return String::new();
|
||||
};
|
||||
let end = field.iter().position(|&b| b == 0).unwrap_or(8);
|
||||
String::from_utf8_lossy(&field[..end]).into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PE32 (32-bit) helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// PE32 shell-table locator. Walks the shell region (`info[6]`) for a dword
|
||||
/// equal to `info[6]` followed by a plausible shell size, returning the table
|
||||
/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer.
|
||||
pub fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option<u32> {
|
||||
let shell = info[6];
|
||||
if (data.len() as u64) < 0x100 {
|
||||
return None;
|
||||
}
|
||||
let hi = (shell as u64)
|
||||
.saturating_add(0x3000)
|
||||
.min(data.len() as u64 - 0x100) as u32;
|
||||
let mut off = shell;
|
||||
while off < hi {
|
||||
if off as usize + 8 <= data.len() {
|
||||
let candidate = off.wrapping_sub(0x88);
|
||||
if candidate >= shell && get_u32(data, off) == info[6] {
|
||||
let shell_size_val = get_u32(data, off.wrapping_add(4));
|
||||
if shell_size_val > 0x1000 && shell_size_val < 0x100000 {
|
||||
let v58_off = candidate.wrapping_add(0x58);
|
||||
if (v58_off as usize + 4) <= data.len() {
|
||||
let v58 = get_u32(data, v58_off);
|
||||
if v58 > 0 && (v58 as usize) < data.len() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
off = off.wrapping_add(4);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region.
|
||||
/// `start_off` is the byte offset to begin scanning at, `scan_backward`
|
||||
/// controls direction. Returns the relative offset of the block. Includes full
|
||||
/// opcode-walk validation of candidate blocks.
|
||||
pub fn find_lfsr_block(
|
||||
data: &[u8],
|
||||
base: u32,
|
||||
size: u32,
|
||||
start_off: u32,
|
||||
scan_backward: bool,
|
||||
) -> Option<u32> {
|
||||
if size < 96 {
|
||||
return None;
|
||||
}
|
||||
let mut ks = [0u8; 128];
|
||||
lfsr_keystream(&mut ks);
|
||||
let check = |scan_off: u32| -> bool {
|
||||
let abs_off = base.wrapping_add(scan_off) as usize;
|
||||
if abs_off + 96 > data.len() {
|
||||
return false;
|
||||
}
|
||||
let sz = data[abs_off + 95] as usize;
|
||||
if !(10..=95).contains(&sz) {
|
||||
return false;
|
||||
}
|
||||
let mut decoded = [0u8; 95];
|
||||
for bi in 0..sz {
|
||||
decoded[bi] = data[abs_off + bi] ^ ks[bi];
|
||||
}
|
||||
// Full bytecode validation (shared with the stage4/5 locator): every
|
||||
// opcode must decode with a valid ModR/M and the stream must REACH a
|
||||
// RET (0xC3) as an opcode. The previous check only required a 0xC3
|
||||
// byte *anywhere* in the window and accepted a walk that ran off the
|
||||
// end without hitting RET — a `0x04 0xC3` (ADD 0xC3) tail passed, so
|
||||
// coincidental LFSR-shaped garbage was accepted as a decryptor block.
|
||||
parse_bytecode_check(&decoded[..sz]).is_some()
|
||||
};
|
||||
if scan_backward {
|
||||
let hi = size - 96;
|
||||
if hi >= start_off {
|
||||
let mut scan_off = hi;
|
||||
loop {
|
||||
if check(scan_off) {
|
||||
return Some(scan_off);
|
||||
}
|
||||
if scan_off == start_off {
|
||||
break;
|
||||
}
|
||||
scan_off -= 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let hi = size - 95;
|
||||
let mut scan_off = start_off;
|
||||
while scan_off < hi {
|
||||
if check(scan_off) {
|
||||
return Some(scan_off);
|
||||
}
|
||||
scan_off += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Slots discovered in the eighthStage for the marker-less layout.
|
||||
pub struct EighthSlots {
|
||||
/// Absolute address of the file-data decryptor LFSR bytecode block. The
|
||||
/// fileCS chain pointer is derived downstream as `file_lfsr - 0x58`.
|
||||
pub file_lfsr: u32,
|
||||
/// Absolute address of the compressedInfo (ptr,size) table pointer slot.
|
||||
pub compressed_info_ptr: u32,
|
||||
}
|
||||
|
||||
/// Marker-independent eighthStage slot discovery (PE32+ branch).
|
||||
///
|
||||
/// Newer Crackproof builds (e.g. some native/managed DLLs) omit the
|
||||
/// `pm\0\0cm\0\0` and `00 00 00 40 01 00 00 00` markers that the older layout's
|
||||
/// walk3/walk4/walk5 slot derivation relies on. Instead this discovers the
|
||||
/// slots structurally:
|
||||
/// * Scan the eighthStage for every LFSR (decrypt_data6) bytecode block.
|
||||
/// * The file decryptor is the LFSR block whose `fileCS = lfsr - 0x58` holds
|
||||
/// a pointer sitting just past `info[3]` (smallest positive distance).
|
||||
/// * `compressedInfo` is the pointer slot whose 16-byte target, after a
|
||||
/// trial `decrypt_data5`, parses as a plausible (src,sSize,dst,dSize)
|
||||
/// descriptor.
|
||||
///
|
||||
/// Returns `None` if no plausible file LFSR is found. `eighth_start`/`eighth_dsz`
|
||||
/// bound the search region; `info3` is `info[3]`; `compress_data_offset` is
|
||||
/// `(!u32(file_data,0x1080)) + 0x1000`; `file_data_len` is the protected file
|
||||
/// length.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn discover_eighth_slots(
|
||||
data: &[u8],
|
||||
eighth_start: u32,
|
||||
eighth_dsz: u32,
|
||||
info3: u32,
|
||||
compress_data_offset: u32,
|
||||
file_data_len: u32,
|
||||
) -> Option<EighthSlots> {
|
||||
// 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<u32> = 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<u32> = None;
|
||||
let mut best_dist: Option<u32> = 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<u32> = None;
|
||||
let mut doff = scan_from;
|
||||
while doff < off_file_lfsr {
|
||||
if doff == off_file_cs {
|
||||
doff += 4;
|
||||
continue;
|
||||
}
|
||||
let ptr_val = get_u32(data, eighth_start.wrapping_add(doff));
|
||||
if !(0x1000 < ptr_val && (ptr_val as usize) < data.len().saturating_sub(16)) {
|
||||
doff += 4;
|
||||
continue;
|
||||
}
|
||||
// Predict decrypt_data5(ptr_val, 16) without mutating: each dword is
|
||||
// position-keyed and independent, so trial_decrypt5_u32 per dword.
|
||||
let src2 = trial_decrypt5_u32(data, ptr_val);
|
||||
let s_sz2 = trial_decrypt5_u32(data, ptr_val + 4);
|
||||
let dst2 = trial_decrypt5_u32(data, ptr_val + 8);
|
||||
let d_sz2 = trial_decrypt5_u32(data, ptr_val + 12);
|
||||
let src_file_off = src2.wrapping_add(compress_data_offset);
|
||||
let valid = s_sz2 > 0
|
||||
&& s_sz2 < 0x200000
|
||||
&& (src_file_off as u64 + s_sz2 as u64) <= file_data_len as u64
|
||||
&& dst2 >= 0x1000
|
||||
&& (dst2 as u64 + d_sz2 as u64) <= data.len() as u64
|
||||
&& d_sz2 >= s_sz2
|
||||
&& d_sz2 < 0x200000;
|
||||
if valid {
|
||||
off_compressed_info = Some(doff);
|
||||
break;
|
||||
}
|
||||
doff += 4;
|
||||
}
|
||||
let off_compressed_info = off_compressed_info?;
|
||||
|
||||
Some(EighthSlots {
|
||||
file_lfsr: eighth_start.wrapping_add(off_file_lfsr),
|
||||
compressed_info_ptr: eighth_start.wrapping_add(off_compressed_info),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Task 4.1 regression: build a synthetic buffer whose valid bytecode block
|
||||
/// sits PAST `len` but within `len*2`. Assert that the smaller window misses
|
||||
/// it and the doubled window finds it.
|
||||
#[test]
|
||||
fn bytecode_locate_double_window_retry() {
|
||||
// We place the block at offset (base + len + 16) which is inside
|
||||
// the len*2 window but outside the len window.
|
||||
let base: u32 = 0;
|
||||
let len: u32 = 256;
|
||||
// Block sits at base + len + 16 = 272, aligned to 16.
|
||||
let block_pos: usize = (base + len + 16) as usize; // 272
|
||||
|
||||
// The buffer must be large enough for the block (block_pos + 96 bytes).
|
||||
let buf_len = block_pos + 256;
|
||||
let mut buf = vec![0u8; buf_len];
|
||||
|
||||
// Build a valid plaintext op stream:
|
||||
// [4, 0, 4, 0, 4, 0, 4, 0, 195] (4 ADD-AL ops then RET)
|
||||
// Padded to 10 bytes total; count >= 8.
|
||||
let count: usize = 10;
|
||||
let mut plain = [0u8; 256];
|
||||
plain[0] = 4;
|
||||
plain[1] = 0;
|
||||
plain[2] = 4;
|
||||
plain[3] = 0;
|
||||
plain[4] = 4;
|
||||
plain[5] = 0;
|
||||
plain[6] = 4;
|
||||
plain[7] = 0;
|
||||
plain[8] = 195; // ret
|
||||
|
||||
// Compute the LFSR keystream and XOR the first `count` bytes to get the
|
||||
// encrypted representation that the scanner would decrypt back.
|
||||
let mut ks = [0u8; 256];
|
||||
lfsr_keystream(&mut ks);
|
||||
for i in 0..count {
|
||||
buf[block_pos + i] = plain[i] ^ ks[i];
|
||||
}
|
||||
// Raw count byte at block_pos+95 (outside the XOR range since count=10 < 95).
|
||||
buf[block_pos + 95] = count as u8;
|
||||
|
||||
// Verify our construction: find_bytecode_offset with len should NOT find it.
|
||||
assert_eq!(
|
||||
find_bytecode_offset(&buf, base, len),
|
||||
None,
|
||||
"smaller window should not find the block"
|
||||
);
|
||||
|
||||
// The doubled window should find it at block_pos.
|
||||
assert_eq!(
|
||||
find_bytecode_offset(&buf, base, len.saturating_mul(2)),
|
||||
Some(block_pos as u32),
|
||||
"doubled window should locate the block"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! PE import reconstruction and memory-image compaction.
|
||||
|
||||
use senbei_crypto::primitives::{get_u16, get_u32, write_u16, write_u32};
|
||||
|
||||
use super::super::MAX_IMAGE_SIZE;
|
||||
|
||||
/// Read a NUL-terminated byte string starting at `off`, bounded to 512 bytes.
|
||||
/// Returns the raw bytes up to the terminator (excluding it).
|
||||
fn read_cstr_bounded(data: &[u8], off: u32) -> Vec<u8> {
|
||||
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<u8>),
|
||||
}
|
||||
|
||||
struct ImportDesc {
|
||||
time_date: u32,
|
||||
fwd_chain: u32,
|
||||
dll_name: Vec<u8>,
|
||||
iat_rva: u32,
|
||||
functions: Vec<ImportFunc>,
|
||||
}
|
||||
|
||||
/// Return true when PE32 imports already sit in the original `.idata` layout
|
||||
/// (so no relocation to `.kmiat` is needed). May write the IAT data directory
|
||||
/// (pe+0xD8).
|
||||
pub fn pe32_imports_already_match_idata_layout(data: &mut [u8], pe_header: u32) -> bool {
|
||||
let opt_hdr_size = get_u16(data, pe_header.wrapping_add(20)) as u32;
|
||||
let sec_table = pe_header.wrapping_add(24).wrapping_add(opt_hdr_size);
|
||||
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
|
||||
let import_rva = get_u32(data, pe_header.wrapping_add(0x80));
|
||||
let import_size = get_u32(data, pe_header.wrapping_add(0x84));
|
||||
let len = data.len() as u32;
|
||||
if !(import_rva > 0 && import_size > 0) {
|
||||
return false;
|
||||
}
|
||||
for idx in 0..num_sections {
|
||||
let sec_off = sec_table.wrapping_add(idx * 40);
|
||||
if (sec_off as usize + 40) > data.len() {
|
||||
return false;
|
||||
}
|
||||
if &data[sec_off as usize..sec_off as usize + 6] != b".idata" {
|
||||
continue;
|
||||
}
|
||||
let sec_va = get_u32(data, sec_off.wrapping_add(12));
|
||||
let sec_size =
|
||||
get_u32(data, sec_off.wrapping_add(8)).max(get_u32(data, sec_off.wrapping_add(16)));
|
||||
let sec_end = sec_va.wrapping_add(sec_size);
|
||||
if !(sec_va <= import_rva
|
||||
&& import_rva < sec_end
|
||||
&& import_rva.wrapping_add(import_size) <= sec_end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let first_oft = get_u32(data, import_rva);
|
||||
let first_name = get_u32(data, import_rva.wrapping_add(12));
|
||||
let first_iat = get_u32(data, import_rva.wrapping_add(16));
|
||||
if !(sec_va <= first_oft
|
||||
&& first_oft < sec_end
|
||||
&& sec_va <= first_iat
|
||||
&& first_iat < sec_end)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !(0x1000 < first_name && first_name < len) {
|
||||
return false;
|
||||
}
|
||||
let dll_name = read_cstr_bounded(data, first_name);
|
||||
let lower: Vec<u8> = dll_name.iter().map(|b| b.to_ascii_lowercase()).collect();
|
||||
if !lower.ends_with(b".dll") {
|
||||
return false;
|
||||
}
|
||||
let mut iat_min = first_iat;
|
||||
let mut iat_max = first_iat;
|
||||
let mut idt_pos = import_rva;
|
||||
while idt_pos.wrapping_add(20) <= len {
|
||||
let oft_rva = get_u32(data, idt_pos);
|
||||
let name_rva = get_u32(data, idt_pos.wrapping_add(12));
|
||||
let iat_rva = get_u32(data, idt_pos.wrapping_add(16));
|
||||
if oft_rva == 0 && name_rva == 0 && iat_rva == 0 {
|
||||
break;
|
||||
}
|
||||
if !(sec_va <= oft_rva && oft_rva < sec_end && sec_va <= iat_rva && iat_rva < sec_end) {
|
||||
return false;
|
||||
}
|
||||
let mut thunk = iat_rva;
|
||||
while thunk.wrapping_add(4) <= sec_end {
|
||||
let tv = get_u32(data, thunk);
|
||||
thunk = thunk.wrapping_add(4);
|
||||
if tv == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
iat_min = iat_min.min(iat_rva);
|
||||
iat_max = iat_max.max(thunk);
|
||||
idt_pos = idt_pos.wrapping_add(20);
|
||||
}
|
||||
if iat_max > iat_min {
|
||||
write_u32(data, pe_header.wrapping_add(0xD8), iat_min);
|
||||
write_u32(data, pe_header.wrapping_add(0xDC), iat_max - iat_min);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Rebuild PE32 import metadata (descriptors, lookup tables, names) into the
|
||||
/// last section as `.kmiat`, leaving the loader-written IAT in place. Mutates
|
||||
/// `data` (may grow it).
|
||||
pub fn move_pe32_imports_to_kmiat(data: &mut Vec<u8>, 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<ImportDesc> = 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<ImportFunc> = 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<u8> = desc
|
||||
.dll_name
|
||||
.iter()
|
||||
.map(|b| b.to_ascii_lowercase())
|
||||
.collect();
|
||||
if lower.starts_with(b"api-ms-win-crt-") {
|
||||
desc.dll_name = b"ucrtbase.dll".to_vec();
|
||||
} else {
|
||||
desc.dll_name = lower;
|
||||
}
|
||||
}
|
||||
descriptors.sort_by_key(|d| d.iat_rva);
|
||||
|
||||
let last_sec = sec_table.wrapping_add((num_sections - 1) * 40);
|
||||
let kmiat_rva = get_u32(data, last_sec.wrapping_add(12));
|
||||
// A zero last-section VA means a corrupt section table: building .kmiat at
|
||||
// RVA 0 would zero the DOS/PE headers and emit a structurally broken image
|
||||
// with no error. Bail and keep the original import table.
|
||||
if kmiat_rva == 0 {
|
||||
return;
|
||||
}
|
||||
// Grow the image when .kmiat overruns it, but cap the growth: a corrupt VA
|
||||
// could otherwise request a multi-gigabyte allocation, which aborts the
|
||||
// process (uncatchable). Use u64 math so a near-u32::MAX VA cannot wrap the
|
||||
// end calculation the way the previous wrapping/plain-add mix could.
|
||||
let kmiat_end = kmiat_rva as u64 + SECTION_SIZE as u64;
|
||||
if kmiat_end > MAX_IMAGE_SIZE {
|
||||
return;
|
||||
}
|
||||
if kmiat_end > data.len() as u64 {
|
||||
data.resize(kmiat_end as usize, 0);
|
||||
}
|
||||
// Zero the .kmiat region.
|
||||
for b in &mut data[kmiat_rva as usize..kmiat_end as usize] {
|
||||
*b = 0;
|
||||
}
|
||||
|
||||
let idt_size = (descriptors.len() as u32 + 1) * 20;
|
||||
let oft_start = kmiat_rva;
|
||||
let mut idt_rva = oft_start;
|
||||
for desc in &descriptors {
|
||||
idt_rva = idt_rva.wrapping_add((desc.functions.len() as u32 + 1) * 4);
|
||||
}
|
||||
idt_rva = align_up_u32(idt_rva.wrapping_add(0x2C), 4);
|
||||
|
||||
// Size check: compute the final name_pos and bail if it overruns .kmiat.
|
||||
let mut name_pos_check = idt_rva.wrapping_add(idt_size);
|
||||
for desc in &descriptors {
|
||||
name_pos_check = name_pos_check.wrapping_add(desc.dll_name.len() as u32 + 1);
|
||||
for func in &desc.functions {
|
||||
if let ImportFunc::Name(_, fname) = func {
|
||||
name_pos_check = name_pos_check.wrapping_add(2 + fname.len() as u32 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if name_pos_check > kmiat_rva.wrapping_add(SECTION_SIZE) {
|
||||
// Section too small; keep existing import table untouched.
|
||||
return;
|
||||
}
|
||||
|
||||
let mut oft_pos = oft_start;
|
||||
let mut name_pos = idt_rva.wrapping_add(idt_size);
|
||||
for (idx, desc) in descriptors.iter().enumerate() {
|
||||
let idt_entry = idt_rva.wrapping_add(idx as u32 * 20);
|
||||
let current_oft = oft_pos;
|
||||
write_u32(data, idt_entry, current_oft);
|
||||
write_u32(data, idt_entry.wrapping_add(4), desc.time_date);
|
||||
write_u32(data, idt_entry.wrapping_add(8), desc.fwd_chain);
|
||||
let dll_name_pos = name_pos;
|
||||
write_u32(data, idt_entry.wrapping_add(12), dll_name_pos);
|
||||
write_u32(data, idt_entry.wrapping_add(16), desc.iat_rva);
|
||||
|
||||
let dnp = dll_name_pos as usize;
|
||||
data[dnp..dnp + desc.dll_name.len()].copy_from_slice(&desc.dll_name);
|
||||
data[dnp + desc.dll_name.len()] = 0;
|
||||
name_pos = name_pos.wrapping_add(desc.dll_name.len() as u32 + 1);
|
||||
|
||||
for func in &desc.functions {
|
||||
match func {
|
||||
ImportFunc::Ordinal(ord) => {
|
||||
write_u32(data, oft_pos, 0x8000_0000 | ord);
|
||||
}
|
||||
ImportFunc::Name(hint, fname) => {
|
||||
let hint_name_rva = name_pos;
|
||||
write_u32(data, oft_pos, hint_name_rva);
|
||||
write_u16(data, hint_name_rva, *hint as u32);
|
||||
let fp = (hint_name_rva + 2) as usize;
|
||||
data[fp..fp + fname.len()].copy_from_slice(fname);
|
||||
data[fp + fname.len()] = 0;
|
||||
name_pos = name_pos.wrapping_add(2 + fname.len() as u32 + 1);
|
||||
}
|
||||
}
|
||||
oft_pos = oft_pos.wrapping_add(4);
|
||||
}
|
||||
write_u32(data, oft_pos, 0);
|
||||
oft_pos = oft_pos.wrapping_add(4);
|
||||
}
|
||||
// Null-terminator IDT entry (20 zero bytes) after the last descriptor.
|
||||
let term = idt_rva.wrapping_add(descriptors.len() as u32 * 20) as usize;
|
||||
for b in &mut data[term..term + 20] {
|
||||
*b = 0;
|
||||
}
|
||||
|
||||
let ls = last_sec as usize;
|
||||
data[ls..ls + 8].copy_from_slice(b".kmiat\x00\x00");
|
||||
write_u32(data, last_sec.wrapping_add(8), SECTION_SIZE);
|
||||
write_u32(data, last_sec.wrapping_add(16), SECTION_SIZE);
|
||||
write_u32(data, last_sec.wrapping_add(36), 0xE000_0060);
|
||||
write_u32(data, pe_header.wrapping_add(0x80), idt_rva);
|
||||
write_u32(data, pe_header.wrapping_add(0x84), idt_size);
|
||||
write_u32(
|
||||
data,
|
||||
pe_header.wrapping_add(80),
|
||||
kmiat_rva.wrapping_add(SECTION_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert the unpacked RVA-addressed image back to a compact PE file layout
|
||||
/// (headers at 0x400, sections packed consecutively, FileAlignment 0x200).
|
||||
/// Returns `None` if the accumulated output size wraps or exceeds
|
||||
/// [`MAX_IMAGE_SIZE`]: the final allocation is sized from header-derived
|
||||
/// section data, and an uncapped `vec![0; n]` from a corrupt header would abort
|
||||
/// the process (which `catch_unpack` cannot trap).
|
||||
pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>> {
|
||||
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<SecLayout> = Vec::new();
|
||||
for idx in 0..num_sections {
|
||||
let sec_off = sec_table.wrapping_add(idx * 40);
|
||||
let vsize = get_u32(data, sec_off.wrapping_add(8));
|
||||
let va = get_u32(data, sec_off.wrapping_add(12));
|
||||
let sd_start = va as usize;
|
||||
let sd_end = if (va.wrapping_add(vsize) as usize) <= data.len() {
|
||||
va.wrapping_add(vsize) as usize
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
let section_data: &[u8] = if sd_start <= sd_end && sd_start <= data.len() {
|
||||
&data[sd_start..sd_end]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
let mut last_nonzero: i64 = -1;
|
||||
for pos in (0..section_data.len()).rev() {
|
||||
if section_data[pos] != 0 {
|
||||
last_nonzero = pos as i64;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let meaningful = if last_nonzero >= 0 {
|
||||
(last_nonzero + 1) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut raw_size = if meaningful != 0 {
|
||||
align_up_u32(meaningful, FILE_ALIGNMENT)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if vsize != 0 && raw_size == 0 {
|
||||
raw_size = FILE_ALIGNMENT;
|
||||
}
|
||||
raw_size = raw_size.min(align_up_u32(section_data.len() as u32, FILE_ALIGNMENT));
|
||||
|
||||
let raw_ptr = if raw_size != 0 { raw_cursor as u32 } else { 0 };
|
||||
raw_layout.push(SecLayout {
|
||||
sec_off,
|
||||
va,
|
||||
vsize,
|
||||
raw_ptr,
|
||||
raw_size,
|
||||
});
|
||||
if raw_size != 0 {
|
||||
// Accumulate in u64 and cap: section sizes are header-derived, and
|
||||
// a corrupt table could otherwise wrap raw_cursor (small alloc,
|
||||
// huge recorded raw_ptrs → OOB panic) or request an abort-sized
|
||||
// allocation.
|
||||
raw_cursor = align_up_u64(raw_cursor + raw_size as u64, FILE_ALIGNMENT as u64);
|
||||
if raw_cursor > MAX_IMAGE_SIZE {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut compact = vec![0u8; raw_cursor as usize];
|
||||
let hdr_copy = (HEADER_SIZE as usize).min(data.len());
|
||||
compact[..hdr_copy].copy_from_slice(&data[..hdr_copy]);
|
||||
write_u32(&mut compact, opt_hdr.wrapping_add(36), FILE_ALIGNMENT);
|
||||
write_u32(&mut compact, opt_hdr.wrapping_add(60), HEADER_SIZE);
|
||||
|
||||
for sl in &raw_layout {
|
||||
write_u32(&mut compact, sl.sec_off.wrapping_add(16), sl.raw_size);
|
||||
write_u32(&mut compact, sl.sec_off.wrapping_add(20), sl.raw_ptr);
|
||||
if sl.raw_size != 0 {
|
||||
let sd_start = sl.va as usize;
|
||||
let sd_end = if (sl.va.wrapping_add(sl.vsize) as usize) <= data.len() {
|
||||
sl.va.wrapping_add(sl.vsize) as usize
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
let section_data: &[u8] = if sd_start <= sd_end {
|
||||
&data[sd_start..sd_end]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let copy_size = (sl.raw_size as usize).min(section_data.len());
|
||||
let rp = sl.raw_ptr as usize;
|
||||
compact[rp..rp + copy_size].copy_from_slice(§ion_data[..copy_size]);
|
||||
}
|
||||
}
|
||||
Some(compact)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Review regression: a zero last-section VA (corrupt section table) must
|
||||
/// bail instead of building .kmiat at RVA 0 — the old code zeroed
|
||||
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
|
||||
/// as a success. A near-2 GiB VA must likewise refuse to grow the image
|
||||
/// past [`MAX_IMAGE_SIZE`].
|
||||
#[test]
|
||||
fn kmiat_bogus_section_va_bails_without_wiping_headers() {
|
||||
for last_sec_va in [0u32, 0x5000_0000] {
|
||||
let pe: u32 = 0x80;
|
||||
let mut data = vec![0xAAu8; 0x8000];
|
||||
// COFF header: 1 section, optional header size 0xE0 (PE32).
|
||||
write_u16(&mut data, pe + 6, 1);
|
||||
write_u16(&mut data, pe + 20, 0xE0);
|
||||
// Import directory at pe+0x80: one descriptor + null terminator.
|
||||
write_u32(&mut data, pe + 0x80, 0x1100);
|
||||
write_u32(&mut data, pe + 0x84, 0x28);
|
||||
write_u32(&mut data, 0x1100, 0x1200); // OFT rva
|
||||
write_u32(&mut data, 0x1100 + 12, 0x1300); // name rva
|
||||
write_u32(&mut data, 0x1100 + 16, 0x1400); // IAT rva
|
||||
for b in &mut data[0x1100 + 20..0x1100 + 40] {
|
||||
*b = 0; // null terminator descriptor
|
||||
}
|
||||
data[0x1300..0x1300 + 13].copy_from_slice(b"KERNEL32.dll\0");
|
||||
write_u32(&mut data, 0x1200, 0x1500); // thunk -> hint/name
|
||||
write_u32(&mut data, 0x1204, 0); // thunk terminator
|
||||
data[0x1500..0x1502].copy_from_slice(&0u16.to_le_bytes());
|
||||
data[0x1502..0x1502 + 12].copy_from_slice(b"ExitProcess\0");
|
||||
// Section table at pe+24+0xE0 = 0x178; VA field at +12.
|
||||
write_u32(&mut data, 0x178 + 12, last_sec_va);
|
||||
|
||||
let head_before: Vec<u8> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
//! Pure, panic-free Crackproof unpacker core. No file I/O lives here.
|
||||
//! PE detection, unpacking, and structural validation.
|
||||
|
||||
mod bytecode;
|
||||
mod crc32;
|
||||
pub mod dll;
|
||||
mod error;
|
||||
pub mod exe;
|
||||
pub mod integrity;
|
||||
mod layout;
|
||||
pub(crate) mod parallel;
|
||||
pub(crate) mod primitives;
|
||||
mod tables;
|
||||
|
||||
use senbei_crypto::primitives;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use dll::{unpack_dll, unpack_dll_v};
|
||||
pub use exe::{
|
||||
BufferOperation, BytecodeStage, DecompressionFailure, DecompressionStage, DescriptorTable,
|
||||
SectionPipeline, UnpackError, unpack as unpack_exe, unpack_v as unpack_exe_v,
|
||||
};
|
||||
pub use error::*;
|
||||
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
||||
pub use integrity::{IntegrityReport, check as check_integrity};
|
||||
pub use parallel::thread_cap;
|
||||
|
||||
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
||||
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
||||
/// (or, as a sign-extended negative `i32`, multi-exabyte) allocation, which
|
||||
/// would abort the process — an abort that `catch_unpack` below cannot trap.
|
||||
/// Real protected binaries are far below this.
|
||||
pub(crate) const MAX_IMAGE_SIZE: u64 = 1 << 30; // 1 GiB
|
||||
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
|
||||
@@ -56,7 +54,6 @@ impl PanicCapture {
|
||||
Self(Arc::new(Mutex::new(None)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
|
||||
let location = info.location();
|
||||
let details = PanicDetails {
|
||||
@@ -123,7 +120,6 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn install_panic_capture_hook() {
|
||||
static INSTALL: std::sync::Once = std::sync::Once::new();
|
||||
INSTALL.call_once(|| {
|
||||
@@ -142,9 +138,6 @@ fn install_panic_capture_hook() {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn install_panic_capture_hook() {}
|
||||
|
||||
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
|
||||
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
|
||||
}
|
||||
@@ -166,8 +159,6 @@ pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
|
||||
where
|
||||
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||
{
|
||||
// Hook capture is skipped on wasm: the prebuilt std cannot unwind there,
|
||||
// so a panic traps immediately. The Web Worker boundary reports that trap.
|
||||
install_panic_capture_hook();
|
||||
let capture = PanicCapture::new();
|
||||
let r = with_panic_capture(Some(capture.clone()), || {
|
||||
@@ -213,11 +204,8 @@ fn key_table(input: &[u8]) -> Option<[u32; 8]> {
|
||||
if input.len() < 4128 {
|
||||
return None;
|
||||
}
|
||||
// Validate PE signature. `checked_add`, not `+`: `usize` is 32-bit on
|
||||
// wasm32, where an `e_lfanew` of 0xFFFF_FFFC..=0xFFFF_FFFF wraps the bound
|
||||
// check, and the slice below then panics with start > end. `detect` runs on
|
||||
// the folder-scan threads and (in the web app) on the main thread outside
|
||||
// the disposable-worker isolation, so it must not panic on any input.
|
||||
// Validate the PE signature with checked arithmetic so a crafted offset
|
||||
// cannot wrap the bounds check on a narrower target.
|
||||
let e_lfanew = primitives::get_u32(input, 0x3C);
|
||||
let pe_start = e_lfanew as usize;
|
||||
if pe_start.checked_add(4).is_none_or(|end| end > input.len()) {
|
||||
@@ -349,7 +337,6 @@ pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), Unp
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn caught_panic_reports_location_and_message() {
|
||||
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
||||
@@ -366,12 +353,14 @@ mod tests {
|
||||
panic!("unexpected error: {error}");
|
||||
};
|
||||
assert_eq!(message, "test panic");
|
||||
assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs"));
|
||||
assert!(
|
||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
||||
);
|
||||
assert!(line > 0);
|
||||
assert!(column > 0);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn worker_panic_keeps_the_worker_source_location() {
|
||||
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
||||
@@ -396,7 +385,10 @@ mod tests {
|
||||
panic!("unexpected error: {error}");
|
||||
};
|
||||
assert_eq!(message, "worker panic");
|
||||
assert!(file.ends_with("src/unpacker/mod.rs") || file.ends_with("src\\unpacker\\mod.rs"));
|
||||
assert!(
|
||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
||||
);
|
||||
assert!(line > 0);
|
||||
assert!(column > 0);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Worker-thread cap. `SENBEI_THREADS` overrides it (`1` forces the sequential
|
||||
/// path); otherwise the host's available parallelism; otherwise 1.
|
||||
pub(crate) fn thread_cap() -> usize {
|
||||
pub fn thread_cap() -> usize {
|
||||
if let Ok(v) = std::env::var("SENBEI_THREADS")
|
||||
&& let Ok(n) = v.trim().parse::<usize>()
|
||||
&& n >= 1
|
||||
@@ -0,0 +1,5 @@
|
||||
//! PE detection, unpacking, and structural validation.
|
||||
|
||||
mod engine;
|
||||
|
||||
pub use engine::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
//! Shared test fixtures.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Path to `senbei/samples` — the user-managed corpus dropped in by hand.
|
||||
/// Git-ignored except its README; tests here run against whatever is present.
|
||||
pub fn samples_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples")
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
use senbei::job::{default_out_root_for_file, out_name};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn out_name_inserts_unpack_before_last_dot() {
|
||||
assert_eq!(out_name(Path::new("foo.exe")), Path::new("foo.unpack.exe"));
|
||||
assert_eq!(
|
||||
out_name(Path::new("a/b/bar.dll")),
|
||||
Path::new("a/b/bar.unpack.dll")
|
||||
);
|
||||
assert_eq!(out_name(Path::new("x.y.dll")), Path::new("x.y.unpack.dll"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_name_no_dot_appends_unpack() {
|
||||
assert_eq!(out_name(Path::new("nodot")), Path::new("nodot.unpack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_out_root_for_file_is_parent_unpack() {
|
||||
assert_eq!(
|
||||
default_out_root_for_file(Path::new("a/b/foo.exe")),
|
||||
Path::new("a/b/unpack")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_out_root_for_file_cwd_when_no_parent() {
|
||||
let p = default_out_root_for_file(Path::new("foo.exe"));
|
||||
assert_eq!(p, Path::new(".").join("unpack"));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use senbei::logfile::{Log, local_stamp_compact, local_stamp_display};
|
||||
|
||||
#[test]
|
||||
fn local_stamp_compact_matches_shape() {
|
||||
let s = local_stamp_compact();
|
||||
// YYYYMMDD-HHMMSS → 15 chars, digit groups around dash
|
||||
assert_eq!(s.len(), 15, "got {s}");
|
||||
assert_eq!(&s[8..9], "-");
|
||||
assert!(s.as_bytes().iter().enumerate().all(|(i, b)| {
|
||||
if i == 8 {
|
||||
*b == b'-'
|
||||
} else {
|
||||
b.is_ascii_digit()
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_stamp_display_matches_shape() {
|
||||
let s = local_stamp_display();
|
||||
// YYYY-MM-DD HH:MM:SS → 19 chars
|
||||
assert_eq!(s.len(), 19, "got {s}");
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[7..8], "-");
|
||||
assert_eq!(&s[10..11], " ");
|
||||
assert_eq!(&s[13..14], ":");
|
||||
assert_eq!(&s[16..17], ":");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_writes_timestamped_file_in_target_dir() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let log = Log::create(td.path()).unwrap();
|
||||
log.step("hello");
|
||||
let path = log.path().to_path_buf();
|
||||
drop(log);
|
||||
assert!(path.starts_with(td.path()));
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
name.starts_with("senbei-") && name.ends_with(".log"),
|
||||
"unexpected log name: {name}"
|
||||
);
|
||||
// senbei-YYYYMMDD-HHMMSS.log
|
||||
let core = name.trim_start_matches("senbei-").trim_end_matches(".log");
|
||||
assert_eq!(core.len(), 15, "stamp in name: {name}");
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("hello"));
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use senbei::job;
|
||||
use std::path::Path;
|
||||
|
||||
fn list_logs(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
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:"));
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
//! Corpus test over the user-managed `senbei/samples` folder.
|
||||
//!
|
||||
//! Drop real Crackproof `*.exe` / `*.dll` inputs in there (and/or il2cpp
|
||||
//! `*.dat` metadata blobs), optionally alongside a byte-exact golden named
|
||||
//! `<base>.golden.<ext>`. 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 `<name>._` 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 `<input>._` sibling in the
|
||||
//! samples folder is picked up automatically, exactly as it is on disk.
|
||||
//!
|
||||
//! An input whose bytes carry the il2cpp metadata magic is routed through
|
||||
//! [`senbei::metadata::deobfuscate`] instead, giving the method-token remap
|
||||
//! real-world coverage (its unit tests only build synthetic layouts).
|
||||
//!
|
||||
//! The folder is git-ignored (see `senbei/samples/README.md`), so the set of
|
||||
//! samples is whatever happens to be on the machine. An empty/absent folder is
|
||||
//! a no-op pass.
|
||||
|
||||
mod common;
|
||||
use common::samples_dir;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// An input is a `.exe`/`.dll`/`.dat` whose name doesn't carry the `.golden.`
|
||||
/// marker — those are goldens, not inputs. External companions (`<name>._`)
|
||||
/// 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: `<base>.golden.<ext>` 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: `<full file name>._` next to it,
|
||||
/// matching what the CLI looks for on disk.
|
||||
fn companion_for(input: &Path) -> Option<std::path::PathBuf> {
|
||||
let name = input.file_name()?;
|
||||
let mut n = name.to_os_string();
|
||||
n.push("._");
|
||||
let p = input.with_file_name(n);
|
||||
p.is_file().then_some(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_unpack_against_goldens() {
|
||||
let dir = samples_dir();
|
||||
// An absent/empty corpus fails only when explicitly required — a green
|
||||
// run that unpacked nothing hides every unpack regression, but on public
|
||||
// CI there is no corpus at all (binaries are never committed), so the
|
||||
// gate is opt-in via SENBEI_REQUIRE_SAMPLES rather than implied by CI.
|
||||
// Locally the corpus is the user-managed samples/ folder (see
|
||||
// samples/README.md).
|
||||
let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some();
|
||||
if !dir.is_dir() {
|
||||
assert!(
|
||||
!require,
|
||||
"samples: {} does not exist — corpus required (CI)",
|
||||
dir.display()
|
||||
);
|
||||
eprintln!("samples: {} does not exist, nothing to test", dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut inputs: Vec<_> = WalkDir::new(&dir)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| {
|
||||
entry.depth() == 0
|
||||
|| !entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("unpack"))
|
||||
})
|
||||
.map(|entry| entry.unwrap_or_else(|e| panic!("walk {}: {e}", dir.display())))
|
||||
.filter(|entry| entry.file_type().is_file() && is_input(entry.path()))
|
||||
.map(|entry| entry.into_path())
|
||||
.collect();
|
||||
inputs.sort();
|
||||
|
||||
if inputs.is_empty() {
|
||||
assert!(
|
||||
!require,
|
||||
"samples: no .exe/.dll inputs in {} — corpus required (CI)",
|
||||
dir.display()
|
||||
);
|
||||
eprintln!("samples: no .exe/.dll inputs in {}", dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut passed = 0usize;
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
for input in &inputs {
|
||||
let name = input
|
||||
.strip_prefix(&dir)
|
||||
.unwrap_or(input)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let got = if senbei::metadata::is_metadata(&bytes) {
|
||||
// il2cpp metadata: method-token de-obfuscation, no PE pipeline and
|
||||
// no integrity check (the output is not a PE image).
|
||||
match senbei::metadata::deobfuscate(&bytes) {
|
||||
Ok((out, _report)) => out,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: de-obfuscation failed: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Splice in the external companion when one sits next to the input,
|
||||
// then run the CLI's routing (which also overlays the stub's export
|
||||
// table and TLS directory for spliced inputs).
|
||||
let companion = match companion_for(input) {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: companion read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let image = match senbei::job::unpack_bytes(&bytes, companion.as_deref()) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: unpack failed: {e:?}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The static integrity check is a second, golden-independent gate:
|
||||
// it catches an output that is structurally plausible but would
|
||||
// crash at runtime (0xC0000005) even when a stale golden still
|
||||
// byte-matches. (Goldens are byte comparisons only — "matches
|
||||
// golden" ≠ runs.)
|
||||
if !image.integrity.ok() {
|
||||
failures.push(format!(
|
||||
"{name}: integrity check failed: {}",
|
||||
image.integrity.issues.join("; ")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
image.bytes
|
||||
};
|
||||
|
||||
let golden = golden_for(input);
|
||||
if !golden.exists() {
|
||||
warnings.push(format!(
|
||||
"{name}: unpacked OK ({} bytes) but no golden ({}) — MANUAL CHECK",
|
||||
got.len(),
|
||||
golden.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let want = match std::fs::read(&golden) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: golden read error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if got.len() != want.len() {
|
||||
failures.push(format!(
|
||||
"{name}: length differs: got {} want {}",
|
||||
got.len(),
|
||||
want.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some((i, (a, b))) = got.iter().zip(&want).enumerate().find(|(_, (a, b))| a != b) {
|
||||
failures.push(format!(
|
||||
"{name}: first diff at 0x{i:X}: got {a:02X} want {b:02X}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
passed += 1;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"samples: {} input(s) — {} pass, {} warning(s), {} failure(s)",
|
||||
inputs.len(),
|
||||
passed,
|
||||
warnings.len(),
|
||||
failures.len()
|
||||
);
|
||||
for w in &warnings {
|
||||
eprintln!(" WARN {w}");
|
||||
}
|
||||
for f in &failures {
|
||||
eprintln!(" FAIL {f}");
|
||||
}
|
||||
|
||||
assert!(failures.is_empty(), "{} sample(s) failed", failures.len());
|
||||
}
|
||||
Generated
-441
@@ -1,441 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indicatif",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-web"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"senbei",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "senbei-web"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "WebAssembly browser frontend for senbei"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
senbei = { path = ".." }
|
||||
wasm-bindgen = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
-662
@@ -1,662 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Senbei web
|
||||
|
||||
Senbei running in the browser: the unpacker core compiled to WebAssembly,
|
||||
wrapped in a small static page. Everything is client-side — files are read
|
||||
into the page, unpacked locally, and offered back as downloads. Nothing is
|
||||
uploaded; there is no server component.
|
||||
|
||||
## Features
|
||||
|
||||
- A legal notice is shown as a blocking dialog on page open; the tool is
|
||||
unusable until it is acknowledged.
|
||||
- Dropped files land in a file list, not unpacked immediately: review the
|
||||
batch, remove mistakes, then press **Unpack**. A module and its `._`
|
||||
companion can be dropped in any order (or in separate drops) — companions
|
||||
auto-pair by name (`Foo.dll._` → `Foo.dll`) and show as a badge on the
|
||||
module's row; removing a module removes its companion too.
|
||||
- Rows show state at a glance: black while staged, an animated blue bar
|
||||
while unpacking, green on success (with a download button) and red on
|
||||
failure.
|
||||
- Drop one or more protected `.exe` / `.dll` modules → get `<name>.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)
|
||||
```
|
||||
-392
@@ -1,392 +0,0 @@
|
||||
import init, { detect, deobfuscate_metadata } from './pkg/senbei_web.js';
|
||||
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const picker = document.getElementById('picker');
|
||||
const fileList = document.getElementById('files');
|
||||
const actions = document.getElementById('actions');
|
||||
const unpackBtn = document.getElementById('unpack-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const legalOverlay = document.getElementById('legal-overlay');
|
||||
const legalAccept = document.getElementById('legal-accept');
|
||||
const legalLink = document.getElementById('legal-link');
|
||||
|
||||
await init();
|
||||
|
||||
// --- Legal gate: the page is unusable until the notice is acknowledged. ---
|
||||
legalAccept.addEventListener('click', () => legalOverlay.remove());
|
||||
legalLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!document.getElementById('legal-overlay')) {
|
||||
document.body.appendChild(legalOverlay);
|
||||
}
|
||||
});
|
||||
|
||||
// --- File list: one row per module. Companions (`X._`) never get their own ---
|
||||
// --- row once their base module `X` is present — they show as a badge on ---
|
||||
// --- the base row. Rows are black while staged, show an animated blue ---
|
||||
// --- progress bar while unpacking, and turn green (success) or red ---
|
||||
// --- (failure) at the end; success rows gain a download button. ---
|
||||
|
||||
// --- Row DOM is updated INCREMENTALLY: rows are created once and patched ---
|
||||
// --- in place. Rebuilding the list on every change would restart the ---
|
||||
// --- entrance animation of every row and reset the unpack shimmer. ---
|
||||
|
||||
/** name -> {
|
||||
* file: File,
|
||||
* kind: string|undefined, // detect() result; undefined for `._` files
|
||||
* state: 'staged'|'working'|'ok'|'err',
|
||||
* bytes: Uint8Array|null, // unpacked output (state 'ok')
|
||||
* note: string, // status line (kind, suspect issues, error)
|
||||
* suspect: boolean,
|
||||
* } */
|
||||
const files = new Map();
|
||||
|
||||
/** name -> row <li> 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 =
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
|
||||
'<path d="M6.5 9.5a3 3 0 0 0 4.24 0l2-2a3 3 0 1 0-4.24-4.24l-1 1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
|
||||
'<path d="M9.5 6.5a3 3 0 0 0-4.24 0l-2 2a3 3 0 1 0 4.24 4.24l1-1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
||||
|
||||
const DOWNLOAD_SVG =
|
||||
'<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">' +
|
||||
'<path d="M8 1v9m0 0L4.5 6.5M8 10l3.5-3.5M2 12.5V14h12v-1.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></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();
|
||||
}
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Senbei — static Crackproof unpacker</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="legal-overlay" role="dialog" aria-modal="true" aria-labelledby="legal-title">
|
||||
<div class="legal-box">
|
||||
<h2 id="legal-title">Legal notice — read before use</h2>
|
||||
<p>
|
||||
Senbei is a research and interoperability tool for lawful reverse
|
||||
engineering, security research, and preservation of software you already
|
||||
legitimately possess. <strong>Only process binaries you own or are
|
||||
explicitly authorized to analyze.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Circumventing technological protection measures may be restricted in
|
||||
your jurisdiction (for example under DMCA §1201 in the United States,
|
||||
which contains exemptions for security research and interoperability).
|
||||
Ensuring your use is lawful is <strong>your</strong> responsibility.
|
||||
</p>
|
||||
<p>
|
||||
Senbei performs a purely static transformation of files on your device:
|
||||
it bypasses no access control by itself, ships no keys and no vendor
|
||||
code, and uploads nothing — everything runs locally in your browser.
|
||||
Do not redistribute decrypted outputs. This software is provided
|
||||
“as is”, without warranty of any kind; the authors accept no liability
|
||||
for misuse. “Crackproof” is a trademark of its owner; this project is
|
||||
not affiliated with or endorsed by the vendor or any publisher.
|
||||
</p>
|
||||
<button id="legal-accept" type="button">I understand and accept</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>Senbei <span class="tag">web</span></h1>
|
||||
<a class="github-link" href="https://github.com/Momoko-Ayase/Senbei" target="_blank" rel="noopener"
|
||||
title="Senbei on GitHub" aria-label="Senbei on GitHub">
|
||||
<svg viewBox="0 0 16 16" width="22" height="22" aria-hidden="true">
|
||||
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</header>
|
||||
<p class="lede">
|
||||
Static unpacker for Crackproof-protected PE files, running entirely in
|
||||
your browser. <strong>No file ever leaves your device.</strong>
|
||||
</p>
|
||||
|
||||
<div id="dropzone" tabindex="0" role="button"
|
||||
aria-label="Drop protected files here or press Enter to browse">
|
||||
<p><strong>Drop files here</strong> or click to browse</p>
|
||||
<p class="hint">
|
||||
Protected <code>.exe</code> / <code>.dll</code> modules, optional
|
||||
<code>._</code> companions, or an il2cpp
|
||||
<code>global-metadata.dat</code>.
|
||||
</p>
|
||||
<input type="file" id="picker" multiple hidden>
|
||||
</div>
|
||||
|
||||
<ul id="files"></ul>
|
||||
|
||||
<div class="actions" id="actions" hidden>
|
||||
<button id="unpack-btn" type="button">Unpack</button>
|
||||
<button id="clear-btn" type="button" class="secondary">Clear</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p><a href="#" id="legal-link">Legal notice</a> ·
|
||||
Senbei is free software under the AGPL-3.0 license.</p>
|
||||
</footer>
|
||||
</main>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
//! WebAssembly bindings for the senbei unpacker core.
|
||||
//!
|
||||
//! Everything here is I/O-free: the browser hands in file bytes and gets
|
||||
//! unpacked file bytes back. No network, no filesystem, no uploads.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Install a panic hook that forwards Rust panic messages to the browser
|
||||
/// console (and to the JS error), instead of a bare `unreachable` trap.
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init_panic_hook() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Result of unpacking one protected module.
|
||||
#[wasm_bindgen]
|
||||
pub struct UnpackResult {
|
||||
kind: String,
|
||||
bytes: Vec<u8>,
|
||||
suspect: bool,
|
||||
issues: Vec<String>,
|
||||
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<u8> {
|
||||
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<String> {
|
||||
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<u8>,
|
||||
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<u8> {
|
||||
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<String> {
|
||||
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
|
||||
/// `<input>._` 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<Vec<u8>>,
|
||||
) -> Result<UnpackResult, JsError> {
|
||||
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<MetadataResult, JsError> {
|
||||
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<Vec<u8>>,
|
||||
) -> Result<UnpackResult, JsError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
-369
@@ -1,369 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14161a;
|
||||
--panel: #1d2026;
|
||||
--black: #0c0e11;
|
||||
--border: #2e323b;
|
||||
--text: #e4e7ec;
|
||||
--dim: #9aa3b0;
|
||||
--accent: #e8b64c;
|
||||
--blue: #3b82f6;
|
||||
--blue-deep: #1d4ed8;
|
||||
--ok: #1e6b34;
|
||||
--ok-bright: #6fcf7c;
|
||||
--err: #7a2828;
|
||||
--err-bright: #e06c6c;
|
||||
--warn: #e8b64c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* display rules below (inline-flex etc.) would otherwise beat the hidden
|
||||
attribute's UA display:none — the badge/download icons must stay hidden. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.55 system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h1 { margin-bottom: 0.25rem; }
|
||||
|
||||
.tag {
|
||||
font-size: 0.45em;
|
||||
vertical-align: super;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
color: var(--dim);
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
color: var(--text);
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.lede { color: var(--dim); }
|
||||
.lede strong { color: var(--text); }
|
||||
|
||||
/* --- legal modal --- */
|
||||
|
||||
#legal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
background: rgba(10, 11, 13, 0.82);
|
||||
backdrop-filter: blur(3px);
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
.legal-box {
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.75rem 2rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
animation: popIn 0.3s cubic-bezier(0.2, 1.4, 0.4, 1);
|
||||
}
|
||||
|
||||
.legal-box h2 { margin-top: 0; }
|
||||
.legal-box p { color: var(--dim); font-size: 0.95rem; }
|
||||
.legal-box strong { color: var(--text); }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.55rem 1.2rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #14161a;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
button:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 12px rgba(232, 182, 76, 0.25);
|
||||
}
|
||||
|
||||
button:not(:disabled):active { transform: translateY(0); }
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.secondary:not(:disabled):hover {
|
||||
box-shadow: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#legal-accept { width: 100%; margin-top: 0.5rem; }
|
||||
|
||||
/* --- dropzone --- */
|
||||
|
||||
#dropzone {
|
||||
margin: 1.5rem 0;
|
||||
padding: 2.25rem 1.5rem;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
#dropzone:hover, #dropzone:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#dropzone.over {
|
||||
border-color: var(--accent);
|
||||
background: #232730;
|
||||
transform: scale(1.01);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#dropzone p { margin: 0.25rem 0; }
|
||||
.hint { color: var(--dim); font-size: 0.9rem; }
|
||||
code {
|
||||
background: var(--bg);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* --- file list --- */
|
||||
|
||||
#files {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file {
|
||||
position: relative;
|
||||
margin-bottom: 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--black); /* staged */
|
||||
transition: background-color 0.5s ease;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
.file.leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
margin-bottom: 0;
|
||||
border-width: 0;
|
||||
transition:
|
||||
opacity 0.13s ease-in,
|
||||
transform 0.13s ease-in,
|
||||
max-height 0.17s ease-in 0.03s,
|
||||
margin-bottom 0.17s ease-in 0.03s,
|
||||
border-width 0.17s ease-in 0.03s;
|
||||
}
|
||||
|
||||
.file .bar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.file.working { background: var(--black); }
|
||||
|
||||
/* The gradient's left and right edge colors match, so the 200%-sized image
|
||||
tiles seamlessly and the position loop has no visible restart. */
|
||||
.file.working .bar {
|
||||
opacity: 1;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
var(--blue-deep) 0%,
|
||||
var(--blue) 25%,
|
||||
#7fb3ff 50%,
|
||||
var(--blue) 75%,
|
||||
var(--blue-deep) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
.file.ok { background: var(--ok); }
|
||||
.file.ok.suspect { background: #6b5a1e; }
|
||||
.file.err { background: var(--err); }
|
||||
|
||||
.file .row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.file .name {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file.working .name { color: #fff; }
|
||||
.file.ok .name, .file.err .name { color: #fff; }
|
||||
|
||||
.badge.companion {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 5px;
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #7fb3ff;
|
||||
}
|
||||
|
||||
.file.ok .badge.companion,
|
||||
.file.err .badge.companion,
|
||||
.file.working .badge.companion {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file .status {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
color: var(--dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file.ok .status { color: #cfe9d5; }
|
||||
.file.ok.suspect .status { color: #f0e3b2; }
|
||||
.file.err .status { color: #f0c8c8; }
|
||||
.file.working .status { color: #dbe7ff; }
|
||||
|
||||
.file .dl {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
animation: popIn 0.3s cubic-bezier(0.2, 1.4, 0.4, 1);
|
||||
}
|
||||
|
||||
.file .dl:hover {
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.file .remove {
|
||||
flex: none;
|
||||
padding: 0.1rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.file .remove:hover {
|
||||
color: var(--err-bright);
|
||||
border-color: var(--err-bright);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file.ok .remove,
|
||||
.file.err .remove,
|
||||
.file.working .remove {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.file.ok .remove:hover,
|
||||
.file.err .remove:hover {
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer a { color: var(--dim); }
|
||||
|
||||
/* --- animations --- */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(232, 182, 76, 0.25); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(232, 182, 76, 0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// One-shot unpack worker: each unpack runs in a fresh worker with its own
|
||||
// wasm instance. Two reasons:
|
||||
//
|
||||
// 1. UI stays responsive — unpacking a 100 MB+ module blocks for seconds.
|
||||
// 2. Trap isolation — the senbei DLL-first routing probe relies on
|
||||
// catch_unwind to reject EXE-shell-layout DLLs, and panics cannot be
|
||||
// caught in WebAssembly: the probe traps the whole call. A trap kills
|
||||
// this worker's message handler, which the main thread observes and
|
||||
// retries with the forced-EXE pipeline in a NEW worker (the trapped
|
||||
// instance is never reused). That reproduces the CLI's
|
||||
// dll-first/exe-fallback routing without a catchable panic.
|
||||
|
||||
import init, { unpack_file, unpack_file_force_exe } from './pkg/senbei_web.js';
|
||||
|
||||
let ready = null;
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { input, companion, forceExe } = e.data;
|
||||
try {
|
||||
ready ??= init();
|
||||
await ready;
|
||||
const r = forceExe
|
||||
? unpack_file_force_exe(input, companion ?? undefined)
|
||||
: unpack_file(input, companion ?? undefined);
|
||||
const bytes = r.bytes;
|
||||
self.postMessage(
|
||||
{
|
||||
ok: true,
|
||||
kind: r.kind,
|
||||
suspect: r.suspect,
|
||||
issues: r.issues,
|
||||
companion: r.companion,
|
||||
bytes,
|
||||
},
|
||||
[bytes.buffer],
|
||||
);
|
||||
} catch (err) {
|
||||
const trap = err instanceof WebAssembly.RuntimeError;
|
||||
self.postMessage({ ok: false, trap, message: String(err?.message ?? err) });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user