Update and supply chain

Does obfuscation break software update checks?

An auto-updater is the most privileged code you ship: it takes bytes off the network and runs them as the user. It is configured by narrowing defaults that exist for the person who has not set anything up yet - which channel to follow, whether a signature is required at all, which key to check it against, whether an older version counts as an update, and a floor beneath which nothing installs. Those are property names on an options object, read by an updater that was installed rather than built. We protected a file that configures one and renamed the names a group at a time.

What the sample actually does

The file configures an updater the way a vendor who has thought about it configures one. It follows the stable channel rather than whatever the feed calls newest. A signature is required. The key it is checked against is pinned in the bundle. The application supplies its own verifier, which recomputes the digest over the payload and compares it to the signature. Downgrades are refused. And a floor keeps out everything below a version that was recalled.

Four builds are then offered to it, each constructed so that exactly one guard can refuse it. The tampered build is version 3.4.0 - above the floor and not a downgrade - so only the signature check can stop it, and it carries a real signature copied from a different release, so a check that only asks whether a signature is present will pass it. The rollback is correctly signed, so only the downgrade rule can stop it. The recalled build is correctly signed and newer than what is running, so only the floor can stop it. A legitimate update is offered last so the safe path is measured too.

The updater is copied in unprotected. Its defaults are the ones this family ships for a caller who has configured nothing: follow whatever is newest, require no signature, hold no key, treat any version as an update, enforce no floor, and - if asked to verify at all without being given a verifier - confirm only that a signature field is present and non-empty.

Protection alone was applied first on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file.

A build nobody signed, installed, while the verifier reported itself present

Renaming the single boolean that says a signature is required reverted it to the default, which is that no signature is required. The tampered build installed. The run's own summary line went from false to true on "unsigned code installed".

The detail that makes this the headline of the pass is the line beside it. The application also prints which verifier is in force, and in this arm it still read caller-supplied, checks the bytes against the key. That reading is completely accurate. The strong verifier is still in the bundle, still bound to the options object under a name the updater does not read, still perfectly capable of rejecting the build. It was never consulted, because the flag that decides whether to consult it is the flag that was lost.

So the honest telemetry says the guard is installed, and the guard is installed, and a build the vendor never signed is now running on every machine that took the update. Any audit that asks "is signature verification configured?" and reads the answer from the application gets a true answer to the wrong question.

Renaming the option that carries the verifier itself produces the same install with a more honest report: the verifier line moves to the library's presence-only builtin. That is the fourth library in this pass and the eighth across this series in which a caller-supplied implementation of a guard was downgraded to a weaker builtin rather than removed.

The alarm that never rang, because it sits behind the switch that failed

Renaming the pinned key alone fails in the opposite direction, and this is the result worth carrying away from this article. With the key gone, the verifier compares against nothing and every build is rejected - including the legitimate one. The client stays on the version it was running. Updates simply stop.

That is a loud failure. Nobody is compromised, the fleet stops moving, and somebody notices inside a day because the release did not land. It is the outcome an auditor is implicitly relying on when they reason that a broken security control will announce itself.

Now rename both names together, which is what a single pattern over an options object actually does. The result is not the loud one. It is identical to renaming the flag alone: the tampered build installs, silently. The key check never runs, because the flag that gates it is checked first and is now false. The half that would have screamed is switched off by the half that fails quietly.

That is a failure shape worth naming, and it is new in this series. A guard is only loud if it is still reached. When a strict control sits behind an enabling boolean, and both can be lost by the same edit, the system does not fail in the noisy direction and the noisy direction never gets a chance to happen. Any reasoning of the form "if this were broken we would know" is worth checking against that: it is only true when the thing that would tell you is not downstream of the thing that broke.

Downgrades, channels, and the recalled build

Renaming the downgrade rule reverted it to the default, which treats any version as an update. A correctly signed 3.2.5 - the build whose authentication bypass was fixed in 3.3.0 - installed cleanly over 3.3.0. Nothing about that build is forged. The signature is real, the vendor did publish it, and every check that looks at authenticity passes, because authenticity was never the question. The question was whether this version may come back, and that question is asked by an option that no longer exists.

Renaming the version floor let the recalled build in by the same route. Renaming both let both in.

Renaming the channel is the quietest of the three. The client moved from the stable channel to the default, which is whatever the feed calls newest, and was offered a beta build. It is signed, it is genuine, it is not meant for production users, and it will install. The reported channel changes on one line, and the version number in the about box is the only other symptom.

The manifest, and why that arm failed closed

One arm renamed the manifest's own field names - version, digest, signature - rather than any option. The manifest is JSON the server wrote; those names are text on the wire, and this file only reads them.

Every read returned undefined, and the result was a refusal rather than an install: with no version to compare, the floor check rejected everything, including the legitimate update. Loud, findable, and safe.

It is worth contrasting that with the same shape in other areas of this series, where renaming a data field name has been the most dangerous arm rather than the safest. The difference is what the code does with the value. A field that is READ and then compared, as here, produces undefined and a comparison that fails closed. A field that is WRITTEN into a store is accepted, lands beside the real one, and the store keeps its old value - which is silent and much worse. Reading a name that moved tends to be loud; writing one tends to be silent.

The arms that rename the updater's own result fields also failed loudly, and one option pinned to a value identical to the library default measured no change at all, as predicted, for the tenth consecutive pass.

What to do about it

None of this is a defect in the obfuscator, and none of it is specific to any one updater. Member renaming rewrites property names inside the code it is given; an installed updater is not inside that code, and it applies the defaults it ships for callers who configured nothing. In this area those defaults are permissive by necessity, because an updater that refused to run without a pinned key would fail on first install for everyone.

Scope the renaming. RenameMembers takes a MemberRegexp, and the updater's options object belongs outside it, along with the shape of the manifest you parse. If you would rather not maintain an exclusion list, build the options with quoted string keys and read them with bracket access using literal strings you wrote.

Then verify from the other side. Ask the updater what it is configured with - is a signature required, which key, what floor, which channel - and refuse to start if any answer differs from what you intended. Read those answers from the updater, not from the object you wrote, because your own object reads back correctly in every arm in this article.

The check that catches the headline arm is a negative test, and it belongs in the release pipeline rather than in a unit suite: feed the built application an update manifest that is deliberately signed with the wrong key, and assert that it refuses to install. Any arm in this article that installs unsigned code fails that test immediately. A positive test - does a real update install? - passes in every arm except the two that fail closed, which is precisely backwards from what you want a test to tell you.

Frequently asked questions

Does protecting my JavaScript break auto-updates on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.

What was the worst result?

Renaming the boolean that requires a signature. A build carrying a signature from a different release installed cleanly, and the application still reported its strong verifier as the one in force - accurately, because the verifier was present and was simply never called.

Is the signature verifier removed from the bundle?

No. It is still there and still correct. The updater looks for it under a name that no longer exists. In one arm it falls back to a builtin that only checks whether a signature field is present; in another the flag that would have invoked it is false, so nothing is checked at all.

Would we not notice a broken update system?

Only if the broken part is the part that fails closed. Losing the pinned key alone stops all updates, which is loud. Losing the required-signature flag as well produces a silent install instead, because the key check sits behind that flag and is never reached. A guard is only loud if it is still reached.

Can obfuscation enable a downgrade attack?

In this measurement, renaming the downgrade rule let a correctly signed older build install over a newer one, including a version whose authentication bypass had been fixed. Nothing about that build is forged, which is why every authenticity check still passes.

Why did renaming the manifest field names fail safely?

Because those fields are read and then compared. The reads returned undefined and the comparison refused everything. Renaming a field that is written into a store is the dangerous direction: the write is accepted and lands beside the real field.

What test would have caught this before release?

A negative one. Offer the built application an update signed with the wrong key and assert it refuses. Every arm here that installs unsigned code fails that immediately, while a test that only checks whether a legitimate update installs passes on the dangerous arms and fails on the safe ones.

Related reading