Skip to content

Commit 435cf0f

Browse files
hansotttimokoessler
authored andcommitted
Test that Zen protects against React2Shell
1 parent b093ecd commit 435cf0f

File tree

1 file changed

+117
-65
lines changed

1 file changed

+117
-65
lines changed
Lines changed: 117 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawnSync, spawn } from "child_process";
22
import { resolve } from "path";
33
import { test, before } from "node:test";
4-
import { equal, fail, match } from "node:assert";
4+
import { deepStrictEqual, fail, match } from "node:assert";
55
import { getRandomPort } from "./utils/get-port.mjs";
66
import { timeout } from "./utils/timeout.mjs";
77

@@ -10,6 +10,7 @@ const pathToAppDir = resolve(
1010
"../../sample-apps/react2shell-next"
1111
);
1212
const port = await getRandomPort();
13+
const port2 = await getRandomPort();
1314

1415
before(() => {
1516
const { stderr } = spawnSync(`npm`, ["run", "build"], {
@@ -21,77 +22,72 @@ before(() => {
2122
}
2223
});
2324

24-
function sendReact2ShellRequest(port) {
25-
// Based on https://github.com/assetnote/react2shell-scanner/
26-
27-
const cmd = "echo $((41*271))";
28-
29-
const prefixPayload =
30-
"var res=process.mainModule.require('child_process').execSync('{cmd}').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{{digest: `NEXT_REDIRECT;push;/login?a=${{res}};307;`}});".replace(
31-
"{cmd}",
32-
cmd
33-
);
25+
async function testReact2Shell(targetUrl) {
26+
const boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad";
3427

35-
const part0 =
36-
'{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":"${prefixPayload}","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}'.replace(
37-
"{prefixPayload}",
38-
prefixPayload
39-
);
28+
const part0 = JSON.stringify({
29+
then: "$1:__proto__:then",
30+
status: "resolved_model",
31+
reason: -1,
32+
value: '{"then":"$B1337"}',
33+
_response: {
34+
_prefix:
35+
"var res=process.mainModule.require('child_process').execSync('echo $((41*271))').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",
36+
_chunks: "$Q2",
37+
_formData: { get: "$1:constructor:constructor" },
38+
},
39+
});
4040

41-
// Build the multipart body as a string
42-
const boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad";
43-
const parts = [];
44-
parts.push(
45-
`${boundary}\r\n` +
46-
'Content-Disposition: form-data; name="0"\r\n\r\n' +
47-
`${part0}\r\n`
48-
);
49-
parts.push(
50-
`${boundary}\r\n` +
51-
'Content-Disposition: form-data; name="1"\r\n\r\n' +
52-
'"$@0"\r\n'
53-
);
54-
parts.push(
55-
`${boundary}\r\n` +
56-
'Content-Disposition: form-data; name="2"\r\n\r\n' +
57-
"[]\r\n"
58-
);
59-
parts.push(`${boundary}--`);
60-
const body = parts.join("");
61-
62-
return fetch(`http://127.0.0.1:${port}/`, {
41+
const body = [
42+
`------WebKitFormBoundaryx8jO2oVc6SWP3Sad`,
43+
`Content-Disposition: form-data; name="0"`,
44+
``,
45+
part0,
46+
`------WebKitFormBoundaryx8jO2oVc6SWP3Sad`,
47+
`Content-Disposition: form-data; name="1"`,
48+
``,
49+
`"$@0"`,
50+
`------WebKitFormBoundaryx8jO2oVc6SWP3Sad`,
51+
`Content-Disposition: form-data; name="2"`,
52+
``,
53+
`[]`,
54+
`------WebKitFormBoundaryx8jO2oVc6SWP3Sad--`,
55+
].join("\r\n");
56+
57+
const response = await fetch(targetUrl, {
58+
method: "POST",
6359
headers: {
64-
"User-Agent":
65-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0",
60+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
6661
"Next-Action": "x",
6762
"X-Nextjs-Request-Id": "b5dce965",
68-
"X-Nextjs-Html-Request-Id": "SSTMXm7OJ_g0Ncx6jpQt9",
69-
"Content-Type": `multipart/form-data; boundary=${boundary.slice(2)}`,
7063
},
71-
body,
72-
method: "POST",
64+
body: body,
65+
redirect: "manual",
7366
});
67+
68+
const redirectHeader = response.headers.get("X-Action-Redirect") || "";
69+
const isVulnerable = /.*\/login\?a=11111.*/.test(redirectHeader);
70+
71+
return {
72+
vulnerable: isVulnerable,
73+
statusCode: response.status,
74+
redirectHeader: redirectHeader,
75+
};
7476
}
7577

76-
test("Request is not blocked in monitoring mode", async () => {
77-
const server = spawn(
78-
`node`,
79-
["-r", "@aikidosec/firewall", "./.next/standalone/server.js"],
80-
{
81-
cwd: pathToAppDir,
82-
env: {
83-
...process.env,
84-
AIKIDO_DEBUG: "true",
85-
AIKIDO_BLOCK: "false",
86-
PORT: port,
87-
HOSTNAME: "127.0.0.1",
88-
},
89-
}
90-
);
78+
test("vulnerable to RCE without Zen", async () => {
79+
const server = spawn(`node`, ["./.next/standalone/server.js"], {
80+
cwd: pathToAppDir,
81+
env: {
82+
...process.env,
83+
PORT: port,
84+
HOSTNAME: "127.0.0.1",
85+
},
86+
});
9187

9288
try {
9389
server.on("error", (err) => {
94-
fail(err.message);
90+
fail(err);
9591
});
9692

9793
let stdout = "";
@@ -107,17 +103,73 @@ test("Request is not blocked in monitoring mode", async () => {
107103
// Wait for the server to start
108104
await timeout(2000);
109105

110-
const result = await sendReact2ShellRequest(port);
106+
const result = await testReact2Shell(`http://127.0.0.1:${port}`);
107+
deepStrictEqual(result, {
108+
vulnerable: true,
109+
statusCode: 303,
110+
redirectHeader: "/login?a=11111;push",
111+
});
112+
} catch (err) {
113+
fail(err);
114+
} finally {
115+
server.kill();
116+
}
117+
});
111118

112-
equal(result.status, 500);
113-
const response = await result.text();
114-
equal(response.includes('E{"digest":"'), true);
119+
test("not vulnerable to RCE with Zen", async () => {
120+
const server = spawn(`node`, ["./.next/standalone/server.js"], {
121+
cwd: pathToAppDir,
122+
env: {
123+
...process.env,
124+
AIKIDO_DEBUG: "true",
125+
AIKIDO_BLOCK: "true",
126+
PORT: port2,
127+
HOSTNAME: "127.0.0.1",
128+
NODE_OPTIONS: "-r @aikidosec/firewall",
129+
},
130+
});
131+
132+
try {
133+
server.on("error", (err) => {
134+
fail(err);
135+
});
136+
137+
let stdout = "";
138+
server.stdout.on("data", (data) => {
139+
stdout += data.toString();
140+
});
141+
142+
let stderr = "";
143+
server.stderr.on("data", (data) => {
144+
stderr += data.toString();
145+
});
146+
147+
// Wait for the server to start
148+
await timeout(2000);
149+
150+
const result = await testReact2Shell(`http://127.0.0.1:${port2}`);
151+
deepStrictEqual(result, {
152+
vulnerable: false,
153+
statusCode: 500,
154+
redirectHeader: "",
155+
});
115156

116157
match(stdout, /Starting agent/);
117-
//match(stderr, /Zen has blocked an SQL injection/);
158+
match(
159+
stderr,
160+
new RegExp(
161+
escapeStringRegexp(
162+
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.fields.[0].value._response._prefix"
163+
)
164+
)
165+
);
118166
} catch (err) {
119167
fail(err);
120168
} finally {
121169
server.kill();
122170
}
123171
});
172+
173+
function escapeStringRegexp(string) {
174+
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
175+
}

0 commit comments

Comments
 (0)