Measured Behaviour

Does obfuscation break travel distance limits?

An escape-route model has obvious failures everybody tests for: a segment that will not parse, a room with no exit, a distance that comes back negative. It has one almost nobody tests for, because the number it produces stays plausible - the LIMIT the distance is compared against. We measured what a rename does to it, and found a case where the wider rename pattern was safer than the narrower one.

A travel distance is a sum, and the limit is a choice

A travel distance is not written on any record. It is assembled: you add up the legs of a route from the furthest point somebody can stand to the place they are safe, and you compare the total with a published figure. In an office that figure is eighteen metres where there is one direction of escape and forty-five where there is more than one. Neither the total nor the choice between the two figures exists anywhere until the code builds them.

We built the calculation as it actually looks. Six routes in one building, every leg measured off the approved drawings. An open-plan floor multiplies its measured line by one and a half, because the furniture layout is not fixed. A dead-end pocket is capped at seven and a half metres on its own, whatever the overall figure says. The flight down to the final exit counts. A sprinklered route gets whatever relaxation the approving authority allows, which here is none.

The correct run refuses four of the six. A dead-end open-plan floor measures thirty metres against eighteen. A two-direction corridor is inside forty-five but has a nine metre dead-end pocket. A storage mezzanine measures forty-eight against forty-five. A basement plant room measures twenty-two against eighteen once the stair is counted. Two routes are accepted, forty-two people stand behind them, and nobody is behind a route the strategy refuses.

That is the baseline. Every number in it came off a drawing somebody measured by hand, and every one of those measurements survives every arm below intact.

The boolean that picks which published figure applies

Here is the part that decides everything else. No record carries a travel limit. The route record carries an identifier, a room, a storey, an occupant count, a list of segments with their lengths, and one boolean saying whether there is a single direction of escape. The limit is what you get when that boolean picks a row out of a two-row table.

Rename that boolean and the library sees an absent flag. An absent flag is falsy, and falsy reads as "there is more than one way out" - which is true of most routes in most buildings, and true of every route the reviewer is likely to have in mind. The eighteen metre limit becomes forty-five. Both numbers are genuine published figures.

Protected on the ES5 target and again on the modern target with member renaming scoped to that one field, the counters are the same on both. Routes stayed at six. Accepted went from 2 to 4. Refused went from 4 to 2. Occupants behind an accepted route went from 42 to 107. And the counter we keep outside the bundle - people standing behind a route the approved fire strategy does not accept - went from 0 to 65.

The fire engineer's own route check passed in both runs. It has to. It asserts that every route was either accepted or refused and that the occupant numbers reconcile, and both of those are statements about the distances THIS RUN measured. A run that compares every route against the generous limit accepts more, refuses fewer, and satisfies the check on every one of them.

The result we did not predict: the wider pattern was the safer one

We pre-registered a second experiment before measuring, and the half of it that failed is the more useful half.

A segment counts unless it belongs to a category the configuration excludes. Two options admit a category: one lets the leg inside the room count, the other lets the leg inside the stair count. Two record flags put a segment in those categories. Our prediction was that losing the OPTION would drop a term from the sum, that losing the RECORD FLAG would do nothing, and that losing BOTH would be identical to losing the option.

The first two held exactly. Renaming the option that admits the in-room leg took accepted from 2 to 4 and the counter from 0 to 105 - a bigger number than the headline arm, because the two routes it tips over are the ones with more people behind them. Renaming the record flag was inert on every counter: six segment labels changed in the audit line and not one distance moved, because a segment that has lost its category is not excluded from anything - it falls through to the clause that always counts.

The third cell came out wrong, and that is the finding. Renaming BOTH names together was byte-identical to the CORRECT RUN. Accepted 2, refused 4, counter 0. The two losses act on the same term of the same sum in opposite directions and they cancel: with no segment in the in-room category, it no longer matters whether the category is admitted. The same thing happened for the stair pair.

We have measured wide-versus-narrow contrasts many times in this series and the usual result is that the wide pattern is QUIETER than one of its halves - it produces the loud failure and hides which half was load-bearing. This is different, and it is worse for anybody auditing by sampling: here the wide pattern produces the RIGHT ANSWER while one of its halves produces a wrong one. A reviewer who tests the broad, realistic "rename everything on the route object" pattern sees a clean run and concludes the area is safe. The dangerous cell is reachable only under a narrow, careful, well-intentioned pattern.

Four more ways to shorten or stretch the same measurement

Because the distance is assembled and the limit is chosen, we measured every part on its own.

The open-plan multiplier: renaming the option that holds it reverts it to the vendor's 1.0, and three routes shrink by a third - accepted 3, counter 80. Renaming the record flag that says a route is open-plan produces counters that are IDENTICAL, because a route that is not open-plan and a multiplier of one are the same arithmetic. The difference is what an audit can see: the option arm flips a printed configuration line, and the record arm changes nothing in the configuration at all.

The dead-end cap behaves the same way. Rename the option and the cap reverts to none; rename the number on the record and the comparison is against a value that is not a number, so it is false and the cap is skipped. Both give accepted 3 and a counter of 60, and only one of them is visible in a configuration dump.

The sprinkler relaxation is the one where the vendor default does the damage. The library doubles every limit on a sprinklered route; this building's approving authority allows no relaxation, so the multiplier is pinned at one. Rename it and the mezzanine's limit goes from forty-five to ninety - accepted 3, counter 80. Rename the record flag that says the route is sprinklered and nothing moves at all, because a relaxation of one is the same as no relaxation. Same asymmetry, different mechanism.

And the two blunt instruments: losing the limits table takes accepted to 5 and the counter to 145, losing the enforcement flag takes them to 6 and 205, and losing both is byte-identical to losing the enforcement flag alone. The union tells you nothing the loud half did not already tell you.

What the test everybody writes actually catches

We ran a negative control alongside every arm: the same six rooms modelled the way a competent tester models them, as one corridor each, straight from the room door to the final exit. No in-room leg, no stair leg, no open-plan multiplier, no dead-end pocket.

That control caught the headline flag. With every route reduced to a single leg, the boolean that chooses between eighteen and forty-five still decides a verdict, and the arm shows up. It caught nothing else. Every one of the six arms that operates on how the SUM is assembled is invisible in it, for a reason that has nothing to do with the obfuscator: a sum with one term has no term to lose.

That is the shape of the risk in this kind of code, and it is the same shape we found when we measured occupant load and egress capacity, where the permitted number is the lesser of two computed limits and only the binding one is ever visible. A fixture built from the simplest true instance of the rule passes in the arm where the rule has quietly stopped applying.

What to do about it

None of this is an argument against protecting an escape-route model. It is an argument for scoping member renaming and for testing it against the fixtures that can actually see it.

Keep a member regexp that names the properties you intend to rename rather than one that sweeps a whole object, and keep the record fields that a control's MEANING depends on - the flag that picks a limit, the number that a cap is compared against - out of it entirely. Our RenameMembers documentation covers the regexp form; the practical rule is that a name whose loss changes which RULE applies is worth more than the few bytes renaming it saves.

Then hold one number outside the bundle and assert on it. Every figure in this article came from a register of what the approved strategy actually accepts, kept under flat string keys where no transformation has a property name to move. That register is what made the difference between "the schedule reconciles" and "65 people are behind a route the strategy refuses", and it is four lines of code.

And build the fixture that can see the failure. A test that measures one corridor against one number will pass in the arm where four routes have quietly lost a leg. A test with a real route in it - an in-room leg, a stair, a dead-end - will not.

Frequently asked questions

Does obfuscation change how long an escape route is?

Not by itself. Across five presets, protecting the model without member renaming produced byte-identical output on every area we measured this pass. What changed the measured distances was member renaming applied to the names the calculation reads - and even then the segment lengths on the records stayed exact; what moved was which segments entered the sum.

Which name was the dangerous one?

The boolean saying a route has a single direction of escape. Losing it swapped an 18-metre limit for a 45-metre one on two routes, took accepted routes from 2 to 4, and left 65 occupants behind routes the approved fire strategy refuses - with every segment length and occupant count still exact.

Why was renaming two names safer than renaming one?

Because the two losses act on the same term of the same sum in opposite directions. The option admits a category into the total; the record flag puts a segment in that category. Lose the option and the term is dropped. Lose the flag as well and the segment is uncategorised, so it is counted again - and the total returns to the correct value.

Would a configuration audit have caught this?

It catches about half of it. Every arm that moves an OPTION flips a printed configuration line, so dumping the effective settings and diffing them against policy finds those. The arms that move a RECORD FIELD change nothing in the configuration, and two of them produced counters identical to their option twins.

Did the application's own checks notice?

No, and they could not have. The route check asserts that every route was accepted or refused and that occupant numbers reconcile. Both are statements about the distances the run measured, so a run that measures short satisfies them on every route.

Is this specific to JavaScript Obfuscator?

No. Any transformation that rewrites property names - a minifier with property mangling, a bundler, a hand-written codemod - moves the same names. It also generalises past renaming: a version bump that drops a field, a serialisation hop that omits undefined keys, or a hand-written mock produce the same absent flag.

What is the smallest change that would have made this loud?

Requiring the classification to be present rather than inferring it from a falsy value. A route whose direction-of-escape flag cannot be read is not a route with two directions of escape; it is a route that has not been assessed, and refusing it costs nothing at design time.

Related reading