Skip to content

refactor to allow objects and references in phpserialised data #2046

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 146 additions & 49 deletions src/core/operations/PHPDeserialize.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PHPDeserialize extends Operation {

this.name = "PHP Deserialize";
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.inputType = "string";
this.outputType = "string";
Expand All @@ -39,6 +39,8 @@ class PHPDeserialize extends Operation {
* @returns {string}
*/
run(input, args) {
const refStore = [];
const inputPart = input.split("");
/**
* Recursive method for deserializing.
* @returns {*}
Expand All @@ -60,7 +62,6 @@ class PHPDeserialize extends Operation {
}
return result;
}

/**
* Read characters from the input until `until` is found.
* @param until
Expand All @@ -70,28 +71,57 @@ class PHPDeserialize extends Operation {
let result = "";
for (;;) {
const char = read(1);
if (char === until) {
break;
} else {
result += char;
}
if (char === until) break;
result += char;
}
return result;

}

/**
* Read characters from the input that must be equal to `expect`
* @param expect
* @param expectStr
* @returns {string}
*/
function expect(expect) {
const result = read(expect.length);
if (result !== expect) {
throw new OperationError("Unexpected input found");
function expect(expectStr) {
const result = read(expectStr.length);
if (result !== expectStr) {
throw new OperationError(`Expected "${expectStr}", but got "${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.
Expand All @@ -100,72 +130,139 @@ class PHPDeserialize extends Operation {
function handleArray() {
const items = parseInt(readUntil(":"), 10) * 2;
expect("{");
const result = [];
let isKey = true;
let lastItem = null;
for (let idx = 0; idx < items; idx++) {
const item = handleInput();
if (isKey) {
lastItem = item;
isKey = false;
} else {
const numberCheck = lastItem.match(/[0-9]+/);
if (args[0] && numberCheck && numberCheck[0].length === lastItem.length) {
result.push('"' + lastItem + '": ' + item);
} else {
result.push(lastItem + ": " + item);
}
isKey = true;
}
const result = {};
for (let idx = 0; idx < items; idx += 2) {
const keyInfo = handleInput();
const valueInfo = handleInput();
let key = keyInfo.value;
if (keyInfo.keyType === "i") key = parseInt(key, 10);
result[key] = valueInfo.value;
}
expect("}");
return result;
}


const kind = read(1).toLowerCase();

switch (kind) {
case "n":
expect(";");
return "null";
case "i":
case "d":
return record({ value: null, keyType: kind });

case "i": {
expect(":");
const data = readUntil(";");
return record({ value: parseInt(data, 10), keyType: kind });
}

case "d": {
expect(":");
const data = readUntil(";");
return record({ value: parseFloat(data), keyType: kind });
}

case "b": {
expect(":");
const data = readUntil(";");
if (kind === "b") {
return (parseInt(data, 10) !== 0);
}
return data;
return record({ value: data !== 0, keyType: kind });
}

case "a":
expect(":");
return "{" + handleArray() + "}";
return record({ value: handleArray(), keyType: kind });

case "s": {
expect(":");
const length = readUntil(":");
const lengthRaw = readUntil(":").trim();
const length = parseInt(lengthRaw, 10);
expect("\"");
const value = read(length);
expect('";');
if (args[0]) {
return '"' + value.replace(/"/g, '\\"') + '"'; // lgtm [js/incomplete-sanitization]
} else {
return '"' + value + '"';

// Read until the next quote-semicolon
let str = "";
while (true) {
const next = read(1);
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({ value: str, keyType: kind });
}

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 valueRaw = handleInput();
let key = keyRaw.value;
if (typeof key === "string" && key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
key = normalizeKey(key);
obj[key] = valueRaw.value;
}

expect("}");
return record({ value: obj, keyType: kind });
}

case "r": {
expect(":");
const refIndex = parseInt(readUntil(";"), 10);
if (refIndex >= refStore.length || refIndex < 0) {
throw new OperationError(`Invalid reference index: ${refIndex}`);
}
const refValue = refStore[refIndex];
if (typeof refValue === "object" && refValue !== null && "value" in refValue && "keyType" in refValue) {
return refValue;
}
return record({ value: refValue, keyType: kind });
}

default:
throw new OperationError("Unknown type: " + kind);
}
}

const inputPart = input.split("");
return handleInput();
}
/**
* Helper function to make invalid json output (legacy support)
* @returns {String}
*/
function stringifyWithIntegerKeys(obj) {
const entries = Object.entries(obj).map(([key, value]) => {
const jsonKey = Number.isInteger(+key) ? key : JSON.stringify(key);
const jsonValue = JSON.stringify(value);
return `${jsonKey}:${jsonValue}`;
});
return `{${entries.join(',')}}`; // eslint-disable-line quotes
}

if (args[0]) {
return JSON.stringify(handleInput().value);
} else {
return stringifyWithIntegerKeys(handleInput().value);
}
}
}

export default PHPDeserialize;
15 changes: 13 additions & 2 deletions tests/operations/tests/PHP.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ TestRegister.addTests([
{
name: "PHP Deserialize array (JSON)",
input: "a:2:{s:1:\"a\";i:10;i:0;a:1:{s:2:\"ab\";b:1;}}",
expectedOutput: "{\"a\": 10,\"0\": {\"ab\": true}}",
expectedOutput: '{"0":{"ab":true},"a":10}',
recipeConfig: [
{
op: "PHP Deserialize",
Expand All @@ -57,12 +57,23 @@ TestRegister.addTests([
{
name: "PHP Deserialize array (non-JSON)",
input: "a:2:{s:1:\"a\";i:10;i:0;a:1:{s:2:\"ab\";b:1;}}",
expectedOutput: "{\"a\": 10,0: {\"ab\": true}}",
expectedOutput: '{0:{"ab":true},"a":10}',
recipeConfig: [
{
op: "PHP Deserialize",
args: [false],
},
],
},
{
name: "PHP Deserialize array with object and reference (JSON)",
input: 'a:1:{s:6:"navbar";O:18:"APP\\View\\Menu\\Item":3:{s:4:"name";s:16:"Secondary Navbar";s:8:"children";a:1:{s:9:"View Cart";O:18:"APP\\View\\Menu\\Item":2:{s:4:"name";s:9:"View Cart";s:6:"parent";r:2;}}s:6:"parent";N;}}', // eslint-disable-line no-useless-escape
expectedOutput: `{"navbar":{"__className":"APP\\\\View\\\\Menu\\\\Item","name":"Secondary Navbar","children":{"View Cart":{"__className":"APP\\\\View\\\\Menu\\\\Item","name":"View Cart","parent":"Secondary Navbar"}},"parent":null}}`,
recipeConfig: [
{
op: "PHP Deserialize",
args: [true],
},
],
}
]);
Loading