Summary
A bundled copy of the TweenMax animation library copied attacker-influenceable vars.cycle property names straight onto a tween configuration object using an unguarded for...in loop, so a key named __proto__, constructor, or prototype was written through to the object's prototype chain. The fix adds an explicit key denylist to both copies of the _applyCycle helper so those three names are skipped during the merge. No CVE or GHSA is assigned; the issue is tracked as CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes).
Introduction
TweenMax's stagger APIs accept a single configuration object and fan it out across N targets. The mechanism that makes each target's animation slightly different is the cycle property: you hand staggerTo() a vars object whose cycle sub-object maps property names to either an array of per-index values or a function that returns one. Internally, a small helper — _applyCycle(vars, targets, timeline) — walks that cycle object and writes the resolved value back onto the tween's own vars.
That walk was a bare for...in loop with a direct dynamic assignment:
p,
val;
for (p in alt) {
val = alt[p];
vars[p] =
typeof val === 'function'
? /* resolve per-target value */
: /* index into the array */;
}
alt is the user-supplied cycle object. p is a key name that came from outside the library. vars[p] = … is a computed-property write with no allowlist, no denylist, and no hasOwnProperty filter. In JavaScript, vars['__proto__'] = someObject does not create an own property — it invokes the inherited __proto__ setter and replaces the prototype of vars. vars['constructor'] = x shadows constructor for every later type check that reads it. That is CWE-1321 in three lines of library code, and because _applyCycle runs once per target in a stagger, a single call applies the attacker's key to every configuration object the stagger creates.
If you have ever written a "just copy the options the caller gave me" loop, this is the exact shape to recognize.
Affected Versions
| Affected | not applicable (first-party code) — a vendored TweenMax build in this repository |
| Fixed in | not applicable (first-party code) — resolved by the hardening commit described below |
| Ecosystem | unknown (vendored JavaScript bundle, not resolved through a package manager) |
| CVE / GHSA | not assigned |
| CWE | CWE-1321 — Improperly Controlled Modification of Object Prototype Attributes |
Because the library is checked in as a bundled asset rather than pulled from a registry, there is no version range to upgrade past. The exposure is determined by whether your application passes externally-influenced data into a vars.cycle object.
The Vulnerability Explained
The vulnerable pattern
Here is the pre-fix loop, verbatim from the patched helper:
p,
val;
for (p in alt) {
val = alt[p];
vars[p] =
typeof val === 'function'
Three distinct problems stack up in those lines:
pis fully attacker-chosen. It is whatever key appears oncycle. The library's contract is "any tween property name," which is implicitly a wildcard.vars[p] = …is a sink, not a copy. Forp === '__proto__', the assignment goes through the accessor defined onObject.prototype, which mutates the[[Prototype]]ofvars. Forp === 'constructor'orp === 'prototype', it shadows a name that other code reads for type dispatch.for...inenumerates inherited properties too. If anything else in the runtime has already pollutedObject.prototype, this loop will faithfully pick those keys up and copy them intovars— so the helper is both a pollution sink and a pollution amplifier.
The same helper is inlined twice in the shipped bundle: once in the embedded TweenLite core and once in the TweenMax layer that defines staggerTo. Both copies had the identical unguarded loop.
Attack scenario
Consider a dashboard, page builder, or themeable widget where motion settings are data. A saved layout, a CMS field, or a plugin manifest supplies the animation descriptor, and the app does something like:
// `motion` is deserialized from stored/untrusted configuration
TweenMax.staggerTo(cards, 0.5, motion, 0.1);
An attacker-authored descriptor:
{
"y": 0,
"cycle": {
"__proto__": [{ "overwrite": "all", "immediateRender": true }],
"constructor": ["not-a-function"]
}
}
When _applyCycle runs:
vars['__proto__'] = { overwrite: 'all', immediateRender: true }swaps the prototype of the tween config. From that moment,vars.overwriteandvars.immediateRenderread as truthy even though neither was ever declared by the application. GSAP reads a long list of properties offvars— lifecycle hooks (onStart,onUpdate,onComplete),callbackScope,paused,delay,overwrite,immediateRender— and simply checks whether they are present. Inherited properties satisfy that check exactly like own properties. Where the configuration source can carry functions rather than pure JSON (a JS-authored theme file, a plugin module, a deserializer that rehydrates callables), this is a direct route to getting a callback invoked that the application never registered.vars['constructor'] = 'not-a-function'is the quieter half. Enormous amounts of JavaScript decide "is this a plain object?" withobj.constructor === Objectorobj.constructor.name. Flipping that answer on a config object that then gets passed to a deep-merge, a cloner, or a serializer can turn a safe shallow copy into a recursive one — and a recursive merge over an object that still has__proto__keys in its payload is how local prototype hijacking becomes process-wideObject.prototypepollution.
Real-world impact
For a service rendering untrusted animation configuration, the realistic outcomes are: animation semantics silently overridden (including overwrite: "all", which kills other tweens on the same targets), inherited properties appearing on objects that should have had none, constructor-based type guards returning the wrong answer, and — if a downstream nested write occurs through the hijacked chain — polluted Object.prototype that affects every object literal in the page, which is the classic setup for authorization-flag spoofing and DOM-based script injection in the surrounding application.
Note that this fix is hardening applied by review; no automated test suite in the repository exercised the stagger path, so it was not validated by a reproduction harness.
The Fix
The change adds a three-name denylist at the top of the loop body, in both inlined copies of the helper.
Before:
```js
for (p in alt) {
val = alt[