5 Commits
Author SHA1 Message Date
Momoko-Ayase d6224e639c chore: bump version to 1.3.1 2026-09-10 20:58:44 +08:00
Momoko-Ayase 9b8024b132 fix(windows): re-arm neutralized TLS field relocations for PE32 DLLs
The packer demotes the four base-relocation entries covering the TLS
directory's VA fields to IMAGE_REL_BASED_ABSOLUTE padding, because its
own loader fixes TLS up by hand. Restored verbatim, a DLL mapped off its
preferred base keeps stale VAs in its TLS directory and the OS loader
faults in LdrpAllocateTlsEntry while writing the TLS slot index through
the unrelocated AddressOfIndex (observed as a 0xc0000005 startup
failure).

Walk the restored BaseReloc blocks and promote those entries back to
IMAGE_REL_BASED_HIGHLOW, gated on is_dll so EXE output stays
byte-identical.
2026-09-10 20:42:39 +08:00
Momoko-Ayase 512a627066 web: recognize Android targets and point users at the CLI
Dropping an .apk/.apks/.xapk package or a .so library reported
"not recognized — will be skipped", which reads as "not protected"
when the real gap is that the Android pipeline (filesystem
orchestration in senbei-io) has no wasm build. These files now get an
explicit android pseudo-kind at staging time: the row explains that
the CLI handles them, and they neither enable the Unpack button nor
error out mid-run.
2026-09-10 09:32:58 +08:00
Momoko-Ayase a230c37281 web: stack file-row status below the name on narrow screens
On phone-width viewports the right-aligned status column shared a flex
line with a long file name and wrapped one word per line. Below 560px
the row now wraps: name and action icons keep the first line and the
status takes a full-width line underneath.
2026-09-10 09:29:23 +08:00
Momoko-Ayase 4f59e25652 ci: declare rustfmt/clippy components in rust-toolchain.toml
The 1.98.1 pin installs without rustfmt and clippy, so the fmt and
clippy jobs fail at the shim before running. Listing the components in
the toolchain file makes rustup auto-install them on every host,
including the CI runners that invoke cargo with no setup action.
2026-09-10 09:28:50 +08:00
9 changed files with 126 additions and 20 deletions
Generated
+7 -7
View File
@@ -402,7 +402,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-cli" name = "senbei-cli"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"senbei-engine", "senbei-engine",
"senbei-io", "senbei-io",
@@ -413,7 +413,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-crypto" name = "senbei-crypto"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"aes", "aes",
"thiserror", "thiserror",
@@ -421,7 +421,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-elf" name = "senbei-elf"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"goblin", "goblin",
"thiserror", "thiserror",
@@ -429,7 +429,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-engine" name = "senbei-engine"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"memmap2", "memmap2",
"senbei-crypto", "senbei-crypto",
@@ -444,7 +444,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-io" name = "senbei-io"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indicatif", "indicatif",
@@ -465,7 +465,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-metadata" name = "senbei-metadata"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
@@ -473,7 +473,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-pe" name = "senbei-pe"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
+1 -1
View File
@@ -15,7 +15,7 @@ exclude = ["senbei-wasm"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "1.3.0" version = "1.3.1"
edition = "2024" edition = "2024"
rust-version = "1.98.1" rust-version = "1.98.1"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+4
View File
@@ -1,3 +1,7 @@
[toolchain] [toolchain]
channel = "1.98.1" channel = "1.98.1"
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
# the pinned toolchain must declare its components here — the runner images
# only preinstall them for their default toolchain.
components = ["rustfmt", "clippy"]
targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"] targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"]
@@ -976,6 +976,58 @@ impl<'a> Unpacker<'a> {
} }
} }
// ---- Re-arm TLS field base relocations (PE32 DLL) ----
// The packer neutralizes the four relocations covering the TLS
// directory's VA fields (Start/EndAddressOfRawData, AddressOfIndex,
// AddressOfCallBacks) by demoting them to IMAGE_REL_BASED_ABSOLUTE
// padding, because its own loader fixes TLS up by hand. Restored
// verbatim, a DLL mapped off its preferred base keeps stale VAs in its
// TLS directory, and the OS loader faults in LdrpAllocateTlsEntry when
// it writes the TLS slot index through the unrelocated AddressOfIndex.
// Promote those entries back to IMAGE_REL_BASED_HIGHLOW. EXE output
// is untouched: its BaseReloc directory is zeroed above, and its
// goldens must stay byte-identical.
if is_dll && tls_dir_rva > 0 {
let reloc_rva = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA0));
let reloc_size = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA4));
let reloc_end = reloc_rva.wrapping_add(reloc_size);
let dlen = self.decompressed.len() as u32;
if reloc_rva > 0 && reloc_size >= 8 && reloc_end <= dlen {
let mut rearmed = 0u32;
let mut block = reloc_rva;
while block.wrapping_add(8) <= reloc_end {
let page = get_u32(&self.decompressed, block);
let block_size = get_u32(&self.decompressed, block.wrapping_add(4));
if block_size < 8
|| !block_size.is_multiple_of(2)
|| block.wrapping_add(block_size) > reloc_end
{
break;
}
let entries = (block_size - 8) / 2;
for i in 0..entries {
let entry_off = block.wrapping_add(8).wrapping_add(i.wrapping_mul(2));
let entry = get_u16(&self.decompressed, entry_off);
let entry_rva = page.wrapping_add((entry & 0x0FFF) as u32);
// IMAGE_REL_BASED_ABSOLUTE with a real offset is
// neutralized, not padding (padding keeps offset 0).
if entry >> 12 == 0
&& entry & 0x0FFF != 0
&& entry_rva >= tls_dir_rva
&& entry_rva < tls_dir_rva.wrapping_add(16)
{
write_u16(&mut self.decompressed, entry_off, (entry | 0x3000) as u32);
rearmed = rearmed.wrapping_add(1);
}
}
block = block.wrapping_add(block_size);
}
if verbose && rearmed > 0 {
println!(" re-armed {} TLS field relocations", rearmed);
}
}
}
// ---- Import table (PE32, 4-byte thunks) ---- // ---- Import table (PE32, 4-byte thunks) ----
if verbose { if verbose {
println!("[8/9] Decrypting import strings (PE32)..."); println!("[8/9] Decrypting import strings (PE32)...");
+7 -7
View File
@@ -412,7 +412,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-crypto" name = "senbei-crypto"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"aes", "aes",
"thiserror", "thiserror",
@@ -420,7 +420,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-elf" name = "senbei-elf"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"goblin", "goblin",
"thiserror", "thiserror",
@@ -428,7 +428,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-engine" name = "senbei-engine"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"memmap2", "memmap2",
"senbei-crypto", "senbei-crypto",
@@ -443,7 +443,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-io" name = "senbei-io"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indicatif", "indicatif",
@@ -464,7 +464,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-metadata" name = "senbei-metadata"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
@@ -472,14 +472,14 @@ dependencies = [
[[package]] [[package]]
name = "senbei-pe" name = "senbei-pe"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
[[package]] [[package]]
name = "senbei-wasm" name = "senbei-wasm"
version = "1.3.0" version = "1.3.1"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"senbei-engine", "senbei-engine",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "senbei-wasm" name = "senbei-wasm"
version = "1.3.0" version = "1.3.1"
edition = "2024" edition = "2024"
rust-version = "1.98.1" rust-version = "1.98.1"
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)" description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
+31 -3
View File
@@ -50,6 +50,22 @@ const KIND_LABEL = {
'native-dll': 'protected native DLL', 'native-dll': 'protected native DLL',
'managed-dll': 'protected managed DLL', 'managed-dll': 'protected managed DLL',
metadata: 'il2cpp metadata', metadata: 'il2cpp metadata',
'android-package':
'Android app package — the web build cannot unpack these yet; use the senbei CLI',
'android-so':
'Android AArch64 library — the web build cannot unpack these yet; use the senbei CLI',
};
// Android targets are recognized by extension so the row can explain the
// situation instead of reporting a protected file as unrecognized: the
// Android pipeline is filesystem orchestration (senbei-io) with no wasm
// build, so these files need the CLI. The pseudo-kinds are labels only —
// they never reach the worker.
const ANDROID_KIND = {
apk: 'android-package',
apks: 'android-package',
xapk: 'android-package',
so: 'android-so',
}; };
const COMPANION_SVG = const COMPANION_SVG =
@@ -91,8 +107,13 @@ async function stageFiles(list) {
// the PE header fields); read a small slice, not the whole file. // the PE header fields); read a small slice, not the whole file.
const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer()); const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer());
// Companions are ciphertext fragments; detect() only makes sense on the // Companions are ciphertext fragments; detect() only makes sense on the
// base module, so skip it for `._` files. // base module, so skip it for `._` files. Android targets short-circuit
const kind = file.name.endsWith('._') ? undefined : detect(head); // detect() as well: an ELF/zip never classifies as a protected PE, and
// the row must carry the android pseudo-kind for its status line.
const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase();
const kind = file.name.endsWith('._')
? undefined
: (ANDROID_KIND[ext] ?? detect(head));
const old = files.get(file.name); const old = files.get(file.name);
files.set(file.name, { files.set(file.name, {
file, file,
@@ -284,7 +305,10 @@ function render() {
const unpackable = [...files].some( const unpackable = [...files].some(
([name, e]) => ([name, e]) =>
!name.endsWith('._') && e.kind !== undefined && e.state === 'staged', !name.endsWith('._') &&
e.kind !== undefined &&
!e.kind.startsWith('android-') &&
e.state === 'staged',
); );
unpackBtn.disabled = !unpackable; unpackBtn.disabled = !unpackable;
actions.hidden = files.size === 0; actions.hidden = files.size === 0;
@@ -364,6 +388,10 @@ unpackBtn.addEventListener('click', async () => {
continue; continue;
} }
// Android rows are informational only (no wasm pipeline); their staged
// status line already says to use the CLI.
if (entry.kind.startsWith('android-')) continue;
entry.state = 'working'; entry.state = 'working';
render(); render();
try { try {
+3 -1
View File
@@ -56,7 +56,9 @@
<p class="hint"> <p class="hint">
Protected <code>.exe</code> / <code>.dll</code> modules, optional Protected <code>.exe</code> / <code>.dll</code> modules, optional
<code>._</code> companions, or an il2cpp <code>._</code> companions, or an il2cpp
<code>global-metadata.dat</code>. <code>global-metadata.dat</code>. Android packages
(<code>.apk</code> / <code>.apks</code> / <code>.xapk</code>) and
<code>.so</code> libraries are recognized but need the CLI.
</p> </p>
<input type="file" id="picker" multiple hidden> <input type="file" id="picker" multiple hidden>
</div> </div>
+20
View File
@@ -333,6 +333,26 @@ footer {
} }
footer a { color: var(--dim); } footer a { color: var(--dim); }
/* --- narrow screens --- */
/* On a narrow viewport the status column (flex: 1, right-aligned) is
squeezed by a long file name into a one-word-per-line vertical strip.
Stack the row instead: name + icons on the first line, the status on its
own full-width line below, left-aligned. */
@media (max-width: 560px) {
main { padding: 1.5rem 0.9rem 2.5rem; }
#dropzone { padding: 1.75rem 1rem; }
.file .row { flex-wrap: wrap; }
.file .name { flex: 1; min-width: 0; }
.file .status {
order: 6; /* after the download/remove icons */
flex-basis: 100%;
text-align: left;
}
}
/* --- animations --- */ /* --- animations --- */
@keyframes fadeIn { @keyframes fadeIn {