[GH-ISSUE #7212] Explain ASAR integrity mismatches caused by signing unpacked addons after packing #4302

Open
opened 2026-10-31 00:48:41 +00:00 by bsakaguchi · 6 comments
Owner

Originally created by @KJLavender on GitHub (Oct 26, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/7212

Before submitting

  • I searched the open and closed issues and pull requests for a related proposal.
  • I understand the contribution scope must be reviewed before integration; open questions are welcome here.
  • This request does not include credentials, private samples, or sensitive target data.

Area

Evidence / Electron ASAR integrity

Current limitation

I ran analyze_javascript_application on Razer's desktop app (Razer App Engine 4.0.827, Electron) and got artifact_integrity_mismatch on every unpacked native addon in app.asar.unpacked, all 12 of them. With integrity_policy: "record-and-continue" the analysis finishes, but all 12 come back as "observed bytes are untrusted".

They aren't tampered with. Each file has a valid Authenticode signature from Razer USA Ltd., and each is exactly 11,976 bytes bigger than the size in the asar header. If I strip the signature (cut the file at the certificate table, zero the security data directory, zero the PE checksum), the SHA-256 matches the integrity.hash in the asar header. That holds for all 12 files:

MATCH  size asar=393728 file=405704 cert=11976  node_modules/node-rz-hid/build/Release/HID.node
MATCH  size asar=709120 file=721096 cert=11976  node_modules/usb/prebuilds/win32-x64/node.napi.node
MATCH  size asar=899584 file=911560 cert=11976  node_modules/node-ble-rz/build/Release/binding.node
... (12/12)

So the build packed the asar first and signed the unpacked binaries afterwards. I'd guess #112 (Linzumi, Apple-signed, mismatch on an unpacked .node) is the same thing on macOS.

Right now REA can't tell "signed after packing" apart from "someone swapped this binary". Both show up as the same untrusted contradiction. In practice I think most apps that sign their native modules will hit this.

Use cases

  • Analyzing any signed Electron app with native addons on Windows. These are usually the interesting ones: device control, system hooks.
  • Version comparison. Today a re-signed but otherwise identical addon counts as a contradiction instead of unchanged.
  • Triage. A real mismatch, where stripping the signature doesn't restore the hash, is worth flagging. Right now it gets lost among the benign ones.

Desired outcome and success criteria

When an unpacked PE entry mismatches, REA checks whether removing its Authenticode signature restores the declared hash. If it does, the record says that, separately from the plain mismatch, for example signed-after-packing with the signer subject and the hash of the unsigned bytes. The bytes stay observed; REA only adds a verifiable explanation. If it doesn't restore the hash, the current contradiction stays as it is.

Success: on an app like the one above, the 12 entries are classified as signed-after-packing, and a hand-modified addon is still reported as a mismatch.

Expected interaction with targets

Read existing files only.

Verification available so far

Tested locally with a small script (asar header parse + PE certificate-table strip) against the installed app. Happy to share the script. I didn't attach the binaries since they're Razer's.

Alternatives considered

  • Treating any validly signed file as trusted. Too loose: a valid signature doesn't prove it's the file that was packed.
  • Leaving it to the user. That works, but everyone hitting this has to work it out again, and the evidence record never says why.

Possible direction

PE is the easy case: certificate table offset and size come from data directory 4, plus the checksum field. Mach-O (LC_CODE_SIGNATURE plus __LINKEDIT growth) is harder, and could stay unknown at first.

Originally created by @KJLavender on GitHub (Oct 26, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7212 ### Before submitting - [x] I searched the open and closed issues and pull requests for a related proposal. - [x] I understand the contribution scope must be reviewed before integration; open questions are welcome here. - [x] This request does not include credentials, private samples, or sensitive target data. ### Area Evidence / Electron ASAR integrity ### Current limitation I ran `analyze_javascript_application` on Razer's desktop app (Razer App Engine 4.0.827, Electron) and got `artifact_integrity_mismatch` on every unpacked native addon in `app.asar.unpacked`, all 12 of them. With `integrity_policy: "record-and-continue"` the analysis finishes, but all 12 come back as "observed bytes are untrusted". They aren't tampered with. Each file has a valid Authenticode signature from Razer USA Ltd., and each is exactly 11,976 bytes bigger than the size in the asar header. If I strip the signature (cut the file at the certificate table, zero the security data directory, zero the PE checksum), the SHA-256 matches the `integrity.hash` in the asar header. That holds for all 12 files: ``` MATCH size asar=393728 file=405704 cert=11976 node_modules/node-rz-hid/build/Release/HID.node MATCH size asar=709120 file=721096 cert=11976 node_modules/usb/prebuilds/win32-x64/node.napi.node MATCH size asar=899584 file=911560 cert=11976 node_modules/node-ble-rz/build/Release/binding.node ... (12/12) ``` So the build packed the asar first and signed the unpacked binaries afterwards. I'd guess #112 (Linzumi, Apple-signed, mismatch on an unpacked `.node`) is the same thing on macOS. Right now REA can't tell "signed after packing" apart from "someone swapped this binary". Both show up as the same untrusted contradiction. In practice I think most apps that sign their native modules will hit this. ### Use cases - Analyzing any signed Electron app with native addons on Windows. These are usually the interesting ones: device control, system hooks. - Version comparison. Today a re-signed but otherwise identical addon counts as a contradiction instead of unchanged. - Triage. A real mismatch, where stripping the signature doesn't restore the hash, is worth flagging. Right now it gets lost among the benign ones. ### Desired outcome and success criteria When an unpacked PE entry mismatches, REA checks whether removing its Authenticode signature restores the declared hash. If it does, the record says that, separately from the plain mismatch, for example `signed-after-packing` with the signer subject and the hash of the unsigned bytes. The bytes stay observed; REA only adds a verifiable explanation. If it doesn't restore the hash, the current contradiction stays as it is. Success: on an app like the one above, the 12 entries are classified as signed-after-packing, and a hand-modified addon is still reported as a mismatch. ### Expected interaction with targets Read existing files only. ### Verification available so far Tested locally with a small script (asar header parse + PE certificate-table strip) against the installed app. Happy to share the script. I didn't attach the binaries since they're Razer's. ### Alternatives considered - Treating any validly signed file as trusted. Too loose: a valid signature doesn't prove it's the file that was packed. - Leaving it to the user. That works, but everyone hitting this has to work it out again, and the evidence record never says why. ### Possible direction PE is the easy case: certificate table offset and size come from data directory 4, plus the checksum field. Mach-O (`LC_CODE_SIGNATURE` plus `__LINKEDIT` growth) is harder, and could stay unknown at first.
Author
Owner

@XinwangTao commented on GitHub (Oct 26, 2026):

"先打包后签名"确实是 Electron 应用常见的发布流程,这个场景下把"签名后打包"与"文件被替换"区分开很有价值——剥离证书表后 hash 能对上,说明判别逻辑是可验证的,比单纯信任签名或完全交给用户都更可靠。

两点建议供参考:1) 这个剥离检查建议只在出现 mismatch 时才触发,避免对每个 unpacked 文件都做额外解析;2) Authenticode 解析时注意 certificate table 不一定严格贴尾、以及带 countersignature 的多层签名情况,校验通过后再记录 signer subject 和剥离后的 hash 会更稳。PE 先行、Mach-O 后补的方向合理,记录里留个 platform 字段方便后续扩展即可。

<!-- gh-comment-id:6411150102 --> @XinwangTao commented on GitHub (Oct 26, 2026): "先打包后签名"确实是 Electron 应用常见的发布流程,这个场景下把"签名后打包"与"文件被替换"区分开很有价值——剥离证书表后 hash 能对上,说明判别逻辑是可验证的,比单纯信任签名或完全交给用户都更可靠。 两点建议供参考:1) 这个剥离检查建议只在出现 mismatch 时才触发,避免对每个 unpacked 文件都做额外解析;2) Authenticode 解析时注意 certificate table 不一定严格贴尾、以及带 countersignature 的多层签名情况,校验通过后再记录 signer subject 和剥离后的 hash 会更稳。PE 先行、Mach-O 后补的方向合理,记录里留个 platform 字段方便后续扩展即可。
Author
Owner

@KJLavender commented on GitHub (Oct 26, 2026):

Thanks, both points make sense.

  1. Agreed. The check would only run on an entry that already failed the hash comparison, so clean apps pay nothing extra.

  2. Good call. I went back and checked the Razer files. In all 12, the certificate table ends exactly at EOF and holds one WIN_CERTIFICATE. Each signature has an RFC 3161 timestamp countersignature and none has a nested (dual) signature. So the simple "cut at the table" case covered everything here, but you're right that it shouldn't be assumed. A safer version: remove exactly the [offset, offset+size) range from data directory 4 instead of truncating, walk every WIN_CERTIFICATE entry (8-byte aligned), and only accept the result if the remaining bytes hash to the declared value. Nested signatures and countersignatures sit inside the PKCS#7 blob, so removing the table takes them along. For the record I'd parse them anyway, to list every signer rather than just the first.

A platform field (pe-authenticode now, macho-codesign later) sounds right to me.

<!-- gh-comment-id:6411305374 --> @KJLavender commented on GitHub (Oct 26, 2026): Thanks, both points make sense. 1. Agreed. The check would only run on an entry that already failed the hash comparison, so clean apps pay nothing extra. 2. Good call. I went back and checked the Razer files. In all 12, the certificate table ends exactly at EOF and holds one `WIN_CERTIFICATE`. Each signature has an RFC 3161 timestamp countersignature and none has a nested (dual) signature. So the simple "cut at the table" case covered everything here, but you're right that it shouldn't be assumed. A safer version: remove exactly the `[offset, offset+size)` range from data directory 4 instead of truncating, walk every `WIN_CERTIFICATE` entry (8-byte aligned), and only accept the result if the remaining bytes hash to the declared value. Nested signatures and countersignatures sit inside the PKCS#7 blob, so removing the table takes them along. For the record I'd parse them anyway, to list every signer rather than just the first. A `platform` field (`pe-authenticode` now, `macho-codesign` later) sounds right to me.
Author
Owner

@gongfpp commented on GitHub (Oct 26, 2026):

I would like to implement this, but the shape below is a contract change, so I need a scope decision before writing code. Two things that make it cheaper than the issue implies:

The PE half already exists. src/native/pe/PeCertificateParser.ts decodes IMAGE_DIRECTORY_ENTRY_SECURITY as a file-offset WIN_CERTIFICATE table, and src/domain/native/peCertificates.ts exposes a bounded domain inspection over it. The missing step is only "rebuild the pre-signing bytes and re-hash".

The decision point is one line. src/artifacts/inventory/scanReader.ts:328 computes mismatched = claim.digest !== observed. That is the only place a mismatch is decided today.

Proposed rule

For an entry that is already mismatched, unpacked, and a PE with a present certificate table: truncate at the certificate-table offset, zero IMAGE_OPTIONAL_HEADER.CheckSum, zero the security data-directory entry, re-hash, compare to claim.digest.

  • Matches → record the explanation. Bytes stay observed; nothing becomes trusted.
  • Does not match → keep today's observed-untrusted contradiction, unchanged.
  • No tolerant match. No heuristic offset search, no "close enough" digest. A wrong match turns this into a way to launder a swapped binary, which is worse than the current dead end.

@XinwangTao's two points are already the design: the check runs only on an entry that already failed the hash comparison, and a certificate table that is not flush with EOF simply fails the reconstruction and falls back to today's behaviour.

Three questions I cannot answer myself

  1. Record shape. A new hash_status value (signed-after-packing), or keep mismatched and attach an explanation object ({kind, signer_subject, unsigned_sha256, certificate_table_bytes})? hash_status is a four-value enum at src/domain/artifactGraph.ts:153, and integrityContradictionBaseSchema pins trust: "observed-untrusted" plus a single provenance literal, so either option is a schema change. I only need to know which one you want.

  2. Comparison semantics. classifyIntegrity (src/domain/artifactComparison.ts:352) currently turns any mismatched into classification: "contradiction". Should an explained entry stay a contradiction, or downgrade to unchanged / a distinct class? That changes what compare_artifacts asserts and what the correlation layer will withhold, so I will not pick it on my own.

  3. Cost and scope. The re-hash is a second full pass over each mismatched unpacked PE, per entry rather than per archive. Acceptable under the existing resource budgets, or should it sit behind integrity_policy? And is PE-first with Mach-O left unknown acceptable?

What the 12/12 evidence does not settle

Reconstruction succeeding on all twelve Razer addons proves that build chain appended the certificate table and touched only the checksum and the security directory. It does not prove the same for countersigned signtool output, osslsigncode, or multi-signature files. If a signer rewrites anything else, reconstruction fails and the rule stays silent. I would rather explain twelve files and stay quiet on the thirteenth than guess.

Tests would use a synthetic fixture (a PE with a structurally valid WIN_CERTIFICATE appended and the checksum and directory patched), so no third-party binary is needed.

If that shape is acceptable, reply and I will send the PE-only path.

<!-- gh-comment-id:6415952676 --> @gongfpp commented on GitHub (Oct 26, 2026): I would like to implement this, but the shape below is a contract change, so I need a scope decision before writing code. Two things that make it cheaper than the issue implies: **The PE half already exists.** `src/native/pe/PeCertificateParser.ts` decodes `IMAGE_DIRECTORY_ENTRY_SECURITY` as a file-offset `WIN_CERTIFICATE` table, and `src/domain/native/peCertificates.ts` exposes a bounded domain inspection over it. The missing step is only "rebuild the pre-signing bytes and re-hash". **The decision point is one line.** `src/artifacts/inventory/scanReader.ts:328` computes `mismatched = claim.digest !== observed`. That is the only place a mismatch is decided today. ## Proposed rule For an entry that is already `mismatched`, `unpacked`, and a PE with a present certificate table: truncate at the certificate-table offset, zero `IMAGE_OPTIONAL_HEADER.CheckSum`, zero the security data-directory entry, re-hash, compare to `claim.digest`. - Matches → record the explanation. Bytes stay observed; nothing becomes trusted. - Does not match → keep today's `observed-untrusted` contradiction, unchanged. - No tolerant match. No heuristic offset search, no "close enough" digest. A wrong match turns this into a way to launder a swapped binary, which is worse than the current dead end. `@XinwangTao`'s two points are already the design: the check runs only on an entry that already failed the hash comparison, and a certificate table that is not flush with EOF simply fails the reconstruction and falls back to today's behaviour. ## Three questions I cannot answer myself 1. **Record shape.** A new `hash_status` value (`signed-after-packing`), or keep `mismatched` and attach an explanation object (`{kind, signer_subject, unsigned_sha256, certificate_table_bytes}`)? `hash_status` is a four-value enum at `src/domain/artifactGraph.ts:153`, and `integrityContradictionBaseSchema` pins `trust: "observed-untrusted"` plus a single `provenance` literal, so either option is a schema change. I only need to know which one you want. 2. **Comparison semantics.** `classifyIntegrity` (`src/domain/artifactComparison.ts:352`) currently turns any `mismatched` into `classification: "contradiction"`. Should an explained entry stay a contradiction, or downgrade to `unchanged` / a distinct class? That changes what `compare_artifacts` asserts and what the correlation layer will withhold, so I will not pick it on my own. 3. **Cost and scope.** The re-hash is a second full pass over each mismatched unpacked PE, per entry rather than per archive. Acceptable under the existing resource budgets, or should it sit behind `integrity_policy`? And is PE-first with Mach-O left `unknown` acceptable? ## What the 12/12 evidence does not settle Reconstruction succeeding on all twelve Razer addons proves that build chain appended the certificate table and touched only the checksum and the security directory. It does not prove the same for countersigned `signtool` output, `osslsigncode`, or multi-signature files. If a signer rewrites anything else, reconstruction fails and the rule stays silent. I would rather explain twelve files and stay quiet on the thirteenth than guess. Tests would use a synthetic fixture (a PE with a structurally valid `WIN_CERTIFICATE` appended and the checksum and directory patched), so no third-party binary is needed. If that shape is acceptable, reply and I will send the PE-only path.
Author
Owner

@XinwangTao commented on GitHub (Oct 27, 2026):

Agree with the strict reconstruction rule — running it only on already-mismatched entries and refusing any heuristic "close enough" match is the right call; a tolerant match really would turn this into a laundering path, which is worse than the current dead end. One defensive detail for the implementation: before rebuilding, validate the security data directory itself — Size must stay within the file, and the WIN_CERTIFICATE table must be 8-byte aligned and fully in-bounds. A malformed table (truncated/corrupt) should just fail reconstruction and fall back to today's observed-untrusted behaviour, rather than risking an out-of-bounds read or a wrong digest. The PE-first / Mach-O-later split keeps the scope tight, so this can land as a standalone slice.

<!-- gh-comment-id:6431374609 --> @XinwangTao commented on GitHub (Oct 27, 2026): Agree with the strict reconstruction rule — running it only on already-mismatched entries and refusing any heuristic "close enough" match is the right call; a tolerant match really would turn this into a laundering path, which is worse than the current dead end. One defensive detail for the implementation: before rebuilding, validate the security data directory itself — Size must stay within the file, and the WIN_CERTIFICATE table must be 8-byte aligned and fully in-bounds. A malformed table (truncated/corrupt) should just fail reconstruction and fall back to today's observed-untrusted behaviour, rather than risking an out-of-bounds read or a wrong digest. The PE-first / Mach-O-later split keeps the scope tight, so this can land as a standalone slice.
Author
Owner

@gongfpp commented on GitHub (Oct 28, 2026):

Withdrawing the offer above. This is your design and your issue, and I should have asked whether you were already building it before writing "I would like to implement this" — you had already specified the algorithm in your 08:13 reply, so the polite move was a question, not a claim.

Two corrections I owe you:

  • I did not read your 08:13 reply carefully enough. You already proposed removing exactly the [offset, offset+size) range from data directory 4 and walking every WIN_CERTIFICATE entry, which is what covers the "table not flush with EOF" case. My line about "a certificate table that is not flush with EOF simply fails the reconstruction" argued against a case you had already solved.
  • @XinwangTao's two points were already answered by you at that time. I presented them as open design questions.

What I can hand over, if any of it saves you time — ignore whatever does not:

  • The mismatch decision is one site: src/artifacts/inventory/scanReader.ts:328, mismatched = claim.digest !== observed; with integrity_policy: "record-and-continue" it pushes into context.pendingContradictions instead of throwing.
  • PE certificate-table parsing already exists and is reusable: src/native/pe/PeCertificateParser.ts decodes IMAGE_DIRECTORY_ENTRY_SECURITY as a file-offset WIN_CERTIFICATE table, and src/domain/native/peCertificates.ts exposes a bounded domain inspection over it. The missing step is only strip-and-rehash.
  • Three decisions still belong to the maintainer and will block whoever lands this: the record shape (a new hash_status value versus an explanation object), the comparison semantics (classifyIntegrity currently turns any mismatched into a contradiction), and whether a second full pass over each mismatched unpacked PE fits the existing resource budgets.

I am not opening a PR on this. If you would rather hand it off at some point, say so and I will take it then.

<!-- gh-comment-id:6458671853 --> @gongfpp commented on GitHub (Oct 28, 2026): Withdrawing the offer above. This is your design and your issue, and I should have asked whether you were already building it before writing "I would like to implement this" — you had already specified the algorithm in your 08:13 reply, so the polite move was a question, not a claim. Two corrections I owe you: - I did not read your 08:13 reply carefully enough. You already proposed removing exactly the `[offset, offset+size)` range from data directory 4 and walking every `WIN_CERTIFICATE` entry, which is what covers the "table not flush with EOF" case. My line about "a certificate table that is not flush with EOF simply fails the reconstruction" argued against a case you had already solved. - `@XinwangTao`'s two points were already answered by you at that time. I presented them as open design questions. What I can hand over, if any of it saves you time — ignore whatever does not: - The mismatch decision is one site: `src/artifacts/inventory/scanReader.ts:328`, `mismatched = claim.digest !== observed`; with `integrity_policy: "record-and-continue"` it pushes into `context.pendingContradictions` instead of throwing. - PE certificate-table parsing already exists and is reusable: `src/native/pe/PeCertificateParser.ts` decodes `IMAGE_DIRECTORY_ENTRY_SECURITY` as a file-offset `WIN_CERTIFICATE` table, and `src/domain/native/peCertificates.ts` exposes a bounded domain inspection over it. The missing step is only strip-and-rehash. - Three decisions still belong to the maintainer and will block whoever lands this: the record shape (a new `hash_status` value versus an explanation object), the comparison semantics (`classifyIntegrity` currently turns any `mismatched` into a contradiction), and whether a second full pass over each mismatched unpacked PE fits the existing resource budgets. I am not opening a PR on this. If you would rather hand it off at some point, say so and I will take it then.
Author
Owner

@XinwangTao commented on GitHub (Oct 28, 2026):

Thanks @gongfpp — the reusable PeCertificateParser and the single decision site at scanReader.ts:328 are very helpful. Maintainer decisions on the three open questions:

  1. Record shape: keep mismatched and attach an explanation object ({kind, signer_subject, unsigned_sha256, certificate_table_bytes}) rather than widening the hash_status enum.
  2. Comparison semantics: the entry stays a contradiction (observed bytes still don't match the declared digest — we just know why now). No downgrade to unchanged; a distinct sub-class can come later if compare_artifacts needs it.
  3. Cost/scope: acceptable — the re-hash only runs on already-mismatched unpacked PEs, so clean apps pay nothing. PE-first with Mach-O unknown is fine; keep the platform field.

@gongfpp the offer stands if you want to take the PE-only path under this shape.

<!-- gh-comment-id:6460379766 --> @XinwangTao commented on GitHub (Oct 28, 2026): Thanks @gongfpp — the reusable PeCertificateParser and the single decision site at scanReader.ts:328 are very helpful. Maintainer decisions on the three open questions: 1. Record shape: keep mismatched and attach an explanation object ({kind, signer_subject, unsigned_sha256, certificate_table_bytes}) rather than widening the hash_status enum. 2. Comparison semantics: the entry stays a contradiction (observed bytes still don't match the declared digest — we just know why now). No downgrade to unchanged; a distinct sub-class can come later if compare_artifacts needs it. 3. Cost/scope: acceptable — the re-hash only runs on already-mismatched unpacked PEs, so clean apps pay nothing. PE-first with Mach-O unknown is fine; keep the platform field. @gongfpp the offer stands if you want to take the PE-only path under this shape.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#4302
No description provided.