Fixed unicode (\uXXXX, \u{XXXX}) and hex (\xXX) escape sequences of string literals being un-escaped into their literal characters during obfuscation

This commit is contained in:
sanex3339
2026-07-09 23:30:39 +04:00
parent a3df841839
commit 3f0dd6af59
12 changed files with 557 additions and 45 deletions
+4
View File
@@ -1,5 +1,9 @@
Change Log
v5.4.6
---
* Fixed unicode (`\uXXXX`, `\u{XXXX}`) and hex (`\xXX`) escape sequences of string literals being un-escaped into their literal characters during obfuscation. Fixes https://github.com/javascript-obfuscator/javascript-obfuscator/issues/345
v5.4.5
---
* Fixed `controlFlowFlattening` intermittently dropping arguments of a spread call (e.g. `foo(...args)`) when it reused a control flow wrapper of a same-arity plain call. Fixes https://github.com/javascript-obfuscator/javascript-obfuscator/issues/1423
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "javascript-obfuscator",
"version": "5.4.5",
"version": "5.4.6",
"description": "JavaScript obfuscator",
"keywords": [
"obfuscator",
@@ -5,11 +5,14 @@ import { IStringArrayStorageItemData } from './IStringArrayStorageItem';
export interface ILiteralNodesCacheStorage extends IMapStorage<string, ESTree.Node> {
/**
* @param {string} literalValue
* @param {Literal} literalNode
* @param {IStringArrayStorageItemData | undefined} stringArrayStorageItemData
* @returns {string}
*/
buildKey(literalValue: string, stringArrayStorageItemData: IStringArrayStorageItemData | undefined): string;
buildKey(
literalNode: ESTree.Literal,
stringArrayStorageItemData: IStringArrayStorageItemData | undefined
): string;
/**
* @param {string} key
@@ -5,4 +5,12 @@ export interface IEscapeSequenceEncoder {
* @returns {string}
*/
encode(string: string, encodeAllSymbols: boolean): string;
/**
* @param {string} value
* @param {string | undefined} rawValue
* @param {boolean} encodeAllSymbols
* @returns {string}
*/
encodeLiteral(value: string, rawValue: string | undefined, encodeAllSymbols: boolean): string;
}
@@ -75,8 +75,9 @@ export class EscapeSequenceTransformer extends AbstractNodeTransformer {
return literalNode;
}
const encodedValue: string = this.escapeSequenceEncoder.encode(
const encodedValue: string = this.escapeSequenceEncoder.encodeLiteral(
literalNode.value,
literalNode.raw,
this.options.unicodeEscapeSequence
);
const newLiteralNode: ESTree.Literal = NodeFactory.literalNode(encodedValue);
@@ -184,11 +184,9 @@ export class StringArrayTransformer extends AbstractNodeTransformer {
return literalNode;
}
const literalValue: ESTree.SimpleLiteral['value'] = literalNode.value;
const stringArrayStorageItemData: IStringArrayStorageItemData | undefined =
this.stringArrayStorageAnalyzer.getItemDataForLiteralNode(literalNode);
const cacheKey: string = this.literalNodesCacheStorage.buildKey(literalValue, stringArrayStorageItemData);
const cacheKey: string = this.literalNodesCacheStorage.buildKey(literalNode, stringArrayStorageItemData);
const useCachedValue: boolean = this.literalNodesCacheStorage.shouldUseCachedValue(
cacheKey,
stringArrayStorageItemData
@@ -27,12 +27,17 @@ export class LiteralNodesCacheStorage extends MapStorage<string, ESTree.Node> im
}
/**
* @param {string} literalValue
* @param {Literal} literalNode
* @param {IStringArrayStorageItemData | undefined} stringArrayStorageItemData
* @returns {string}
*/
public buildKey(literalValue: string, stringArrayStorageItemData: IStringArrayStorageItemData | undefined): string {
return `${literalValue}-${Boolean(stringArrayStorageItemData)}`;
public buildKey(
literalNode: ESTree.Literal,
stringArrayStorageItemData: IStringArrayStorageItemData | undefined
): string {
// `raw` value is a part of the key to keep literals that share the same decoded value but
// have a different source representation (e.g. `'😃'` and `'😃'`) separate
return `${String(literalNode.value)}-${String(literalNode.raw)}-${Boolean(stringArrayStorageItemData)}`;
}
/**
+286 -23
View File
@@ -24,6 +24,16 @@ export class EscapeSequenceEncoder implements IEscapeSequenceEncoder {
*/
private static readonly replaceRegExp: RegExp = /[\s\S]/g;
/**
* @type {RegExp}
*/
private static readonly hexDigitRegExp: RegExp = /[0-9a-fA-F]/;
/**
* @type {RegExp}
*/
private static readonly octalDigitRegExp: RegExp = /[0-7]/;
/**
* @type {Map<string, string>}
*/
@@ -41,33 +51,286 @@ export class EscapeSequenceEncoder implements IEscapeSequenceEncoder {
return <string>this.stringsCache.get(cacheKey);
}
const radix: number = 16;
let prefix: string;
let template: string;
const result: string = string.replace(EscapeSequenceEncoder.replaceRegExp, (character: string): string => {
const shouldEncodeCharacter: boolean =
encodeAllSymbols || EscapeSequenceEncoder.forceEscapeCharactersRegExp.test(character);
if (!shouldEncodeCharacter) {
return character;
}
if (EscapeSequenceEncoder.ASCIICharactersRegExp.test(character)) {
prefix = '\\x';
template = '00';
} else {
prefix = '\\u';
template = '0000';
}
return `${prefix}${(template + character.charCodeAt(0).toString(radix)).slice(-template.length)}`;
});
const result: string = string.replace(
EscapeSequenceEncoder.replaceRegExp,
(character: string): string => this.encodeCharacter(character, encodeAllSymbols)
);
this.stringsCache.set(cacheKey, result);
this.stringsCache.set(`${result}-${String(encodeAllSymbols)}`, result);
return result;
}
/**
* @param {string} value - decoded literal value
* @param {string | undefined} rawValue - original source representation of the literal (with quotes)
* @param {boolean} encodeAllSymbols
* @returns {string}
*/
public encodeLiteral(value: string, rawValue: string | undefined, encodeAllSymbols: boolean): string {
// when all symbols are being encoded every character becomes an escape sequence anyway,
// so there is nothing to preserve
if (encodeAllSymbols || rawValue === undefined) {
return this.encode(value, encodeAllSymbols);
}
const cacheKey: string = `literal-${value}-${rawValue}-${String(encodeAllSymbols)}`;
if (this.stringsCache.has(cacheKey)) {
return <string>this.stringsCache.get(cacheKey);
}
const preservedResult: string | null = this.encodePreservingUnicodeEscapes(value, rawValue);
const result: string = preservedResult ?? this.encode(value, encodeAllSymbols);
this.stringsCache.set(cacheKey, result);
return result;
}
/**
* @param {string} character
* @param {boolean} encodeAllSymbols
* @returns {string}
*/
private encodeCharacter(character: string, encodeAllSymbols: boolean): string {
const shouldEncodeCharacter: boolean =
encodeAllSymbols || EscapeSequenceEncoder.forceEscapeCharactersRegExp.test(character);
if (!shouldEncodeCharacter) {
return character;
}
const radix: number = 16;
let prefix: string;
let template: string;
if (EscapeSequenceEncoder.ASCIICharactersRegExp.test(character)) {
prefix = '\\x';
template = '00';
} else {
prefix = '\\u';
template = '0000';
}
return `${prefix}${(template + character.charCodeAt(0).toString(radix)).slice(-template.length)}`;
}
/**
* @param {string} value
* @param {string} rawValue
* @returns {string | null}
*/
private encodePreservingUnicodeEscapes(value: string, rawValue: string): string | null {
const rawBody: string = rawValue.slice(1, -1);
let result: string = '';
let decoded: string = '';
let index: number = 0;
while (index < rawBody.length) {
const character: string = rawBody[index];
if (character !== '\\') {
result += this.encodeCharacter(character, false);
decoded += character;
index++;
continue;
}
const nextCharacter: string | undefined = rawBody[index + 1];
if (nextCharacter === undefined) {
// dangling backslash - malformed, cannot trust the raw value
return null;
}
if (nextCharacter === 'u' || nextCharacter === 'x') {
const unicodeEscape: { raw: string; decoded: string } | null = this.readUnicodeEscapeSequence(
rawBody,
index
);
if (!unicodeEscape) {
return null;
}
// keep the unicode/hex escape sequence exactly as it was in the source
result += unicodeEscape.raw;
decoded += unicodeEscape.decoded;
index += unicodeEscape.raw.length;
continue;
}
// any other escape sequence is decoded and re-encoded as a regular character
const simpleEscape: { raw: string; decoded: string } = this.readSimpleEscapeSequence(rawBody, index);
for (const decodedCharacter of simpleEscape.decoded) {
result += this.encodeCharacter(decodedCharacter, false);
}
decoded += simpleEscape.decoded;
index += simpleEscape.raw.length;
}
return decoded === value ? result : null;
}
/**
* @param {string} rawBody
* @param {number} startIndex - index of the leading backslash
* @returns {{raw: string, decoded: string} | null}
*/
private readUnicodeEscapeSequence(
rawBody: string,
startIndex: number
): { raw: string; decoded: string } | null {
// `\xXX`
if (rawBody[startIndex + 1] === 'x') {
return this.readFixedLengthHexEscapeSequence(rawBody, startIndex, 2);
}
// `\u{XXXX}`
if (rawBody[startIndex + 2] === '{') {
return this.readCodePointEscapeSequence(rawBody, startIndex);
}
// `\uXXXX`
return this.readFixedLengthHexEscapeSequence(rawBody, startIndex, 4);
}
/**
* @param {string} rawBody
* @param {number} startIndex - index of the leading backslash
* @param {number} hexLength - amount of the hex digits (2 for `\xXX`, 4 for `\uXXXX`)
* @returns {{raw: string, decoded: string} | null}
*/
private readFixedLengthHexEscapeSequence(
rawBody: string,
startIndex: number,
hexLength: number
): { raw: string; decoded: string } | null {
const hexDigits: string = rawBody.slice(startIndex + 2, startIndex + 2 + hexLength);
if (hexDigits.length !== hexLength || !this.isHexString(hexDigits)) {
return null;
}
return {
raw: rawBody.slice(startIndex, startIndex + 2 + hexLength),
decoded: String.fromCharCode(parseInt(hexDigits, 16))
};
}
/**
* @param {string} rawBody
* @param {number} startIndex - index of the leading backslash
* @returns {{raw: string, decoded: string} | null}
*/
private readCodePointEscapeSequence(
rawBody: string,
startIndex: number
): { raw: string; decoded: string } | null {
const closingBraceIndex: number = rawBody.indexOf('}', startIndex + 3);
if (closingBraceIndex === -1) {
return null;
}
const hexDigits: string = rawBody.slice(startIndex + 3, closingBraceIndex);
const codePoint: number = parseInt(hexDigits, 16);
const maxCodePoint: number = 0x10_ff_ff;
if (!hexDigits.length || !this.isHexString(hexDigits) || codePoint > maxCodePoint) {
return null;
}
return {
raw: rawBody.slice(startIndex, closingBraceIndex + 1),
decoded: String.fromCodePoint(codePoint)
};
}
/**
* @param {string} rawBody
* @param {number} startIndex - index of the leading backslash
* @returns {{raw: string, decoded: string}}
*/
// eslint-disable-next-line complexity
private readSimpleEscapeSequence(rawBody: string, startIndex: number): { raw: string; decoded: string } {
const escapeCharacter: string = rawBody[startIndex + 1];
switch (escapeCharacter) {
case 'n':
return { raw: '\\n', decoded: '\n' };
case 'r':
return { raw: '\\r', decoded: '\r' };
case 't':
return { raw: '\\t', decoded: '\t' };
case 'b':
return { raw: '\\b', decoded: '\b' };
case 'f':
return { raw: '\\f', decoded: '\f' };
case 'v':
return { raw: '\\v', decoded: '\v' };
case '\r': {
// line continuation (`\` followed by a line terminator)
const isCarriageReturnLineFeed: boolean = rawBody[startIndex + 2] === '\n';
return { raw: isCarriageReturnLineFeed ? '\\\r\n' : '\\\r', decoded: '' };
}
case '\n':
return { raw: '\\\n', decoded: '' };
default:
break;
}
// legacy octal escape sequence (e.g. `\0`, `\12`, `\101`)
if (EscapeSequenceEncoder.octalDigitRegExp.test(escapeCharacter)) {
let octalDigits: string = escapeCharacter;
const maxOctalLength: number = escapeCharacter <= '3' ? 3 : 2;
while (
octalDigits.length < maxOctalLength &&
EscapeSequenceEncoder.octalDigitRegExp.test(rawBody[startIndex + 1 + octalDigits.length] ?? '')
) {
octalDigits += rawBody[startIndex + 1 + octalDigits.length];
}
return {
raw: `\\${octalDigits}`,
decoded: String.fromCharCode(parseInt(octalDigits, 8))
};
}
// identity escape (`\\`, `\'`, `\"`, `` \` ``, `\/`, etc.)
return { raw: `\\${escapeCharacter}`, decoded: escapeCharacter };
}
/**
* @param {string} string
* @returns {boolean}
*/
private isHexString(string: string): boolean {
for (const character of string) {
if (!EscapeSequenceEncoder.hexDigitRegExp.test(character)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,4 @@
console.log("\ud83d\ude03");
console.log("😃");
console.log("\x41\x42\x43");
console.log("\u{1F604}");
@@ -0,0 +1,78 @@
import { assert } from 'chai';
import { NO_ADDITIONAL_NODES_PRESET } from '../../../src/options/presets/NoCustomNodes';
import { readFileAsString } from '../../helpers/readFileAsString';
import { JavaScriptObfuscator } from '../../../src/JavaScriptObfuscatorFacade';
//
// https://github.com/javascript-obfuscator/javascript-obfuscator/issues/345
//
describe('Issue #345', () => {
describe('Escape sequences of string literals should be preserved', () => {
let obfuscatedCode: string;
before(() => {
const code: string = readFileAsString(__dirname + '/fixtures/issue345.js');
obfuscatedCode = JavaScriptObfuscator.obfuscate(code, {
...NO_ADDITIONAL_NODES_PRESET
}).getObfuscatedCode();
});
it('match #1: should keep the unicode escape sequence untouched', () => {
assert.match(obfuscatedCode, /console\['log'\]\('\\ud83d\\ude03'\);/);
});
it('match #2: should keep the un-escaped character untouched', () => {
assert.match(obfuscatedCode, /console\['log'\]\('😃'\);/);
});
it('match #3: should keep the hex escape sequence untouched', () => {
assert.match(obfuscatedCode, /console\['log'\]\('\\x41\\x42\\x43'\);/);
});
it('match #4: should keep the unicode code point escape sequence untouched', () => {
assert.match(obfuscatedCode, /console\['log'\]\('\\u{1F604}'\);/);
});
});
describe('Escaped and un-escaped variants of the same value should stay distinguishable', () => {
let obfuscatedCode: string;
before(() => {
const code: string = 'var a = "\\ud83d\\ude03"; var b = "😃";';
obfuscatedCode = JavaScriptObfuscator.obfuscate(code, {
...NO_ADDITIONAL_NODES_PRESET
}).getObfuscatedCode();
});
it('should keep the escaped variant escaped', () => {
assert.match(obfuscatedCode, /var a *= *'\\ud83d\\ude03';/);
});
it('should keep the un-escaped variant un-escaped', () => {
assert.match(obfuscatedCode, /var b *= *'😃';/);
});
});
describe('Obfuscated code should be runnable and produce the original values', () => {
let result: string;
before(() => {
const code: string = 'return "\\ud83d\\ude03" + "😃" + "\\x41";';
const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, {
...NO_ADDITIONAL_NODES_PRESET
}).getObfuscatedCode();
result = new Function(obfuscatedCode)();
});
it('should evaluate to the correct string', () => {
assert.equal(result, '😃😃A');
});
});
});
@@ -39,24 +39,45 @@ const getStorageInstance = (options: TInputOptions = {}): ILiteralNodesCacheStor
describe('LiteralNodesCacheStorage', () => {
describe('buildKey', () => {
const expectedCacheKey: string = 'foo-true';
describe('Variant #1: base', () => {
const expectedCacheKey: string = "foo-'foo'-true";
let cacheKey: string;
let cacheKey: string;
before(() => {
const literalNodesCacheStorage: ILiteralNodesCacheStorage = getStorageInstance();
before(() => {
const literalNodesCacheStorage: ILiteralNodesCacheStorage = getStorageInstance();
cacheKey = literalNodesCacheStorage.buildKey('foo', {
index: 1,
value: '_0x123abc',
encoding: StringArrayEncoding.Rc4,
encodedValue: 'encoded_value',
decodeKey: 'key'
cacheKey = literalNodesCacheStorage.buildKey(NodeFactory.literalNode('foo'), {
index: 1,
value: '_0x123abc',
encoding: StringArrayEncoding.Rc4,
encodedValue: 'encoded_value',
decodeKey: 'key'
});
});
it('should build a key for the storage', () => {
assert.equal(cacheKey, expectedCacheKey);
});
});
it('should build a key for the storage', () => {
assert.equal(cacheKey, expectedCacheKey);
describe('Variant #2: literals with the same value but different `raw` representation', () => {
let firstCacheKey: string;
let secondCacheKey: string;
before(() => {
const literalNodesCacheStorage: ILiteralNodesCacheStorage = getStorageInstance();
firstCacheKey = literalNodesCacheStorage.buildKey(
NodeFactory.literalNode('😃', '"\\ud83d\\ude03"'),
undefined
);
secondCacheKey = literalNodesCacheStorage.buildKey(NodeFactory.literalNode('😃', '"😃"'), undefined);
});
it('should build different keys for literals with a different `raw` representation', () => {
assert.notEqual(firstCacheKey, secondCacheKey);
});
});
});
@@ -81,4 +81,131 @@ describe('EscapeSequenceEncoder', () => {
});
});
});
describe('encodeLiteral', () => {
let escapeSequenceEncoder: IEscapeSequenceEncoder;
before(() => {
const inversifyContainerFacade: IInversifyContainerFacade = new InversifyContainerFacade();
inversifyContainerFacade.load('', '', {});
escapeSequenceEncoder = inversifyContainerFacade.get<IEscapeSequenceEncoder>(
ServiceIdentifiers.IEscapeSequenceEncoder
);
});
describe('Variant #1: preserve unicode escape sequences from the raw value', () => {
// eslint-disable-next-line no-useless-escape
const value: string = '😃';
const rawValue: string = '"\\ud83d\\ude03"';
const expectedString: string = '\\ud83d\\ude03';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should keep the escape sequence untouched', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #2: preserve hex escape sequences from the raw value', () => {
const value: string = 'ABC';
const rawValue: string = '"\\x41\\x42\\x43"';
const expectedString: string = '\\x41\\x42\\x43';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should keep the hex escape sequence untouched', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #3: preserve unicode code point escape sequences from the raw value', () => {
const value: string = '\u{1F604}';
const rawValue: string = '"\\u{1F604}"';
const expectedString: string = '\\u{1F604}';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should keep the unicode code point escape sequence untouched', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #4: keep an un-escaped character un-escaped', () => {
const value: string = '😃';
const rawValue: string = '"😃"';
const expectedString: string = '😃';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should keep the character un-escaped', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #5: non unicode escape sequences are encoded as regular characters', () => {
const value: string = 'a\tb';
const rawValue: string = '"a\\tb"';
const expectedString: string = 'a\\x09b';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should encode the escape sequence as a regular character', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #6: untrusted raw value falls back to value-based encoding', () => {
// synthesized `raw` (e.g. `'a\b'`) does not faithfully represent the value `a\b`
const value: string = 'a\\b';
const rawValue: string = "'a\\b'";
const expectedString: string = 'a\\x5cb';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, false);
});
it('should encode the value with the safe value-based encoding', () => {
assert.equal(actualString, expectedString);
});
});
describe('Variant #7: `encodeAllSymbols` escapes everything regardless of the raw value', () => {
const value: string = '😃';
const rawValue: string = '"😃"';
const expectedString: string = '\\ud83d\\ude03';
let actualString: string;
before(() => {
actualString = escapeSequenceEncoder.encodeLiteral(value, rawValue, true);
});
it('should encode all symbols', () => {
assert.equal(actualString, expectedString);
});
});
});
});