refactor to allow objects and references in phpserialised data

This commit is contained in:
Jamie (Bear) Murphy 2025-05-13 21:01:52 +01:00
parent 411f78d27a
commit 208df066a0

View file

@ -20,7 +20,7 @@ class PHPDeserialize extends Operation {
this.name = "PHP Deserialize"; this.name = "PHP Deserialize";
this.module = "Default"; this.module = "Default";
this.description = "Deserializes PHP serialized data, outputting keyed arrays as JSON.<br><br>This function does not support <code>object</code> tags.<br><br>Example:<br><code>a:2:{s:1:&quot;a&quot;;i:10;i:0;a:1:{s:2:&quot;ab&quot;;b:1;}}</code><br>becomes<br><code>{&quot;a&quot;: 10,0: {&quot;ab&quot;: true}}</code><br><br><u>Output valid JSON:</u> JSON doesn't support integers as keys, whereas PHP serialization does. Enabling this will cast these integers to strings. This will also escape backslashes."; this.description = "Deserializes PHP serialized data, outputting keyed arrays as JSON.<br><br>Example:<br><code>a:2:{s:1:&quot;a&quot;;i:10;i:0;a:1:{s:2:&quot;ab&quot;;b:1;}}</code><br>becomes<br><code>{&quot;a&quot;: 10,0: {&quot;ab&quot;: true}}</code><br><br><u>Output valid JSON:</u> JSON doesn't support integers as keys, whereas PHP serialization does. Enabling this will cast these integers to strings. This will also escape backslashes.";
this.infoURL = "http://www.phpinternalsbook.com/classes_objects/serialization.html"; this.infoURL = "http://www.phpinternalsbook.com/classes_objects/serialization.html";
this.inputType = "string"; this.inputType = "string";
this.outputType = "string"; this.outputType = "string";
@ -39,6 +39,8 @@ class PHPDeserialize extends Operation {
* @returns {string} * @returns {string}
*/ */
run(input, args) { run(input, args) {
const refStore = [];
const inputPart = input.split("");
/** /**
* Recursive method for deserializing. * Recursive method for deserializing.
* @returns {*} * @returns {*}
@ -60,7 +62,6 @@ class PHPDeserialize extends Operation {
} }
return result; return result;
} }
/** /**
* Read characters from the input until `until` is found. * Read characters from the input until `until` is found.
* @param until * @param until
@ -70,14 +71,10 @@ class PHPDeserialize extends Operation {
let result = ""; let result = "";
for (;;) { for (;;) {
const char = read(1); const char = read(1);
if (char === until) { if (char === until) break;
break; result += char;
} else {
result += char;
}
} }
return result; return result;
} }
/** /**
@ -85,13 +82,46 @@ class PHPDeserialize extends Operation {
* @param expect * @param expect
* @returns {string} * @returns {string}
*/ */
function expect(expect) { function expect(expectStr) {
const result = read(expect.length); const result = read(expectStr.length);
if (result !== expect) { if (result !== expectStr) {
throw new OperationError("Unexpected input found"); throw new OperationError(`Expected "${expectStr}", but got "${result}"`);
} }
return result; return result;
} }
/**
* Records a value by pushing it into the reference store and returns it.
* @param {any} value - The value to be recorded.
* @returns {any} - The recorded value.
*/
function record(value) {
refStore.push(value);
return value;
}
/**
* Normalizes the key by converting private and protected keys to standard formats.
* @param {string} key - The key to be normalized.
* @returns {string} - The normalized key.
*/
function normalizeKey(key) {
if (typeof key !== "string") return key;
// Match private: "\0ClassName\0prop"
const privateMatch = key.match(/^\u0000(.+)\u0000(.+)$/);
if (privateMatch) {
const [_, className, prop] = privateMatch; // eslint-disable-line no-unused-vars
return `private:${prop}`;
}
// Match protected: "\0*\0prop"
const protectedMatch = key.match(/^\u0000\*\u0000(.+)$/);
if (protectedMatch) {
return `protected:${protectedMatch[1]}`;
}
return key;
}
/** /**
* Helper function to handle deserialized arrays. * Helper function to handle deserialized arrays.
@ -100,7 +130,7 @@ class PHPDeserialize extends Operation {
function handleArray() { function handleArray() {
const items = parseInt(readUntil(":"), 10) * 2; const items = parseInt(readUntil(":"), 10) * 2;
expect("{"); expect("{");
const result = []; const result = {};
let isKey = true; let isKey = true;
let lastItem = null; let lastItem = null;
for (let idx = 0; idx < items; idx++) { for (let idx = 0; idx < items; idx++) {
@ -109,12 +139,11 @@ class PHPDeserialize extends Operation {
lastItem = item; lastItem = item;
isKey = false; isKey = false;
} else { } else {
const numberCheck = lastItem.match(/[0-9]+/); let key = lastItem;
if (args[0] && numberCheck && numberCheck[0].length === lastItem.length) { if (args[0] && typeof key === "number") {
result.push('"' + lastItem + '": ' + item); key = key.toString();
} else {
result.push(lastItem + ": " + item);
} }
result[key] = item;
isKey = true; isKey = true;
} }
} }
@ -122,39 +151,96 @@ class PHPDeserialize extends Operation {
return result; return result;
} }
const kind = read(1).toLowerCase(); const kind = read(1).toLowerCase();
switch (kind) { switch (kind) {
case "n": case "n":
expect(";"); expect(";");
return "null"; return record(null);
case "i": case "i":
case "d": case "d":
case "b": { case "b": {
expect(":"); expect(":");
const data = readUntil(";"); const data = readUntil(";");
if (kind === "b") { if (kind === "b") {
return (parseInt(data, 10) !== 0); return record(parseInt(data, 10) !== 0);
} }
return data; if (kind === "i") {
return record(parseInt(data, 10));
}
if (kind === "d") {
return record(parseFloat(data));
}
return record(data);
} }
case "a": case "a":
expect(":"); expect(":");
return "{" + handleArray() + "}"; return record(handleArray());
case "s": { case "s": {
expect(":"); expect(":");
const length = readUntil(":"); const lengthRaw = readUntil(":").trim();
const length = parseInt(lengthRaw, 10);
expect("\""); expect("\"");
const value = read(length);
expect('";'); // Read until the next quote-semicolon
if (args[0]) { let str = "";
return '"' + value.replace(/"/g, '\\"') + '"'; // lgtm [js/incomplete-sanitization] while (true) {
} else { const next = read(1);
return '"' + value + '"'; if (next === '"' && inputPart[0] === ";") {
inputPart.shift(); // Consume the ;
break;
}
str += next;
} }
const actualByteLength = new TextEncoder().encode(str).length;
if (actualByteLength !== length) {
// eslint-disable-next-line no-console
console.warn(`Length mismatch: declared ${length}, got ${actualByteLength} — proceeding anyway`);
}
return record(str);
}
case "o": {
expect(":");
const classNameLength = parseInt(readUntil(":"), 10);
expect("\"");
const className = read(classNameLength);
expect("\"");
expect(":");
const propertyCount = parseInt(readUntil(":"), 10);
expect("{");
const obj = {
__className: className
};
for (let i = 0; i < propertyCount; i++) {
const keyRaw = handleInput();
const value = handleInput();
let key = keyRaw;
if (typeof keyRaw === "string" && keyRaw.startsWith('"') && keyRaw.endsWith('"')) {
key = keyRaw.slice(1, -1);
}
key = normalizeKey(key);
obj[key] = value;
}
expect("}");
return record(obj);
}
case "r": {
expect(":");
const refIndex = parseInt(readUntil(";"), 10);
if (refIndex >= refStore.length || refIndex < 0) {
throw new OperationError(`Invalid reference index: ${refIndex}`);
}
return refStore[refIndex];
} }
default: default:
@ -162,10 +248,8 @@ class PHPDeserialize extends Operation {
} }
} }
const inputPart = input.split(""); return JSON.stringify(handleInput());
return handleInput();
} }
} }
export default PHPDeserialize; export default PHPDeserialize;