Elke codebase die ik overneem heeft een lijstje bugs met het label "low". Een crash bij een raar bestand. Een import die vastloopt op een gigantische regel. Een endpoint dat omvalt als je er rommel naartoe stuurt. Niemand fixt ze, want wie stuurt er nou rommel naartoe?
Die redenering is deze herfst onderuit gegaan. IEEE Spectrum publiceerde op 2 november een stuk van Dina Genkina met de titel "AI is forcing us to pay off 40 years of technical debt", en op Hacker News haalde het ruim 1.100 punten en zo'n 670 reacties. De aanleiding is PHP2Shell, en de kern is ongemakkelijk simpel: een bug die jarenlang als crash gold, bleek met wat AI-hulp een remote code execution.
Wat er bij PHP2Shell gebeurde
Het gaat om twee CVE's in PHP zelf, CVE-2026-109618 en CVE-2026-109619, allebei met een CVSS v4-score van 9.2. Volgens de analyse van OffSec begint het met een multipart-request zonder Content-Length en een geneste file-upload zonder bestandsnaam. PHP bouwt dan metadata-arrays voor bestanden die er niet zijn, het geheugen loopt vol en het proces raakt een fatale out-of-memory. Op dat moment is interne request-data maar half geïnitialiseerd, en de opruimcode beslist op basis van die halve data wat er moet gebeuren. Daar zit het lek.
Getroffen zijn PHP 5.4 en nieuwer, tot de patches van 21 oktober (8.2.35, 8.3.36, 8.4.27 en 8.5.12). Wiz liet zien dat WordPress, Drupal en Joomla via die route over te nemen waren zonder in te loggen.
Het detail dat ertoe doet staat in het IEEE-stuk. Securitybedrijf Calif deed een audit van PHP, gesponsord door OpenAI, en binnen twee weken had een onderzoeker met AI-tools een manier gevonden om die out-of-memory-bug om te zetten in het uitvoeren van willekeurige code. Calif meldde het op 11 augustus, Wiz op 24 augustus en OpenAI zelf op 18 september. Drie teams, onafhankelijk, binnen zes weken.
Een tweede voorbeeld noemde een HN-gebruiker in de discussie. Bugsink zette de tijdlijn van HTTP2Shell op een rij, een out-of-bounds write in de Brotli-decoder van Google. De bug zat er sinds 2018 in, de fix stond sinds februari 2026 in Git, maar een release kwam pas in oktober. Volgens die tijdlijn bracht Microsoft een out-of-band .NET-update uit, omdat de .NET-runtime die native decoder meelevert. Dat is dus ook jouw stack.
De rekensom achter "low" klopt niet meer
Een bug kreeg altijd een prioriteit op basis van twee dingen: hoe erg is het als het misgaat, en hoeveel moeite kost het een aanvaller om hem uit te buiten. Dat tweede getal hield een hoop code overeind. Een crash omzetten in een exploit was weken werk voor een specialist, en die specialist ging zich echt niet over jouw facturatie-API buigen.
Die kosten zijn ingestort. Matt Suiche van Tolmo zegt in het IEEE-stuk dat alle legacy-software van de afgelopen 40 jaar wordt blootgelegd. Matthew Green van Johns Hopkins is somberder: niemand gaat dit oplossen tot er iets heel ergs gebeurt. Ik denk dat Green gelijk heeft over de meeste organisaties, en dat dat precies de reden is om het zelf wel te doen.
De HN-discussie is verdeeld, en terecht. Volgens sommigen had de kop moeten luiden "AI helpt ons de schuld weg te werken", want AI vond de bug ook. Anderen wijzen erop dat dezelfde tools razendsnel nieuwe schuld maken: code die niemand begrijpt, fixes die net de test halen. Gebruiker swatcoder vraagt zich af of we schuld aflossen of op een loopband van fix-werk zonder einde zijn gestapt. Dat weten we over een jaar pas.
Voor je backlog maakt dat weinig uit. Een crash op invoer van buiten is vanaf nu een security-ticket, tot je hebt aangetoond dat hij dat niet is.
Herweeg je backlog met drie vragen
Ik loop "low"-bugs langs met drie vragen.
Kan iemand van buiten de invoer sturen die dit triggert? Een crash in een admin-tool achter VPN is iets anders dan een crash in een publiek upload-endpoint.
Gebeurt de crash halverwege een toestand? PHP2Shell werkte omdat het proces omviel terwijl de request-state half klaar was. In .NET is het equivalent een exception halverwege een stuk code dat gedeelde state muteert: een cache, een statische dictionary, een connection pool, een bestand dat half geschreven is.
Zit er native code of unsafe tussen? Managed .NET maakt van de meeste geheugenfouten een nette exception. Een IndexOutOfRangeException is vervelend, maar het is geen geheugencorruptie. Zodra je native libraries aanroept of zelf met pointers werkt, verdwijnt dat vangnet.
Scoort een bug op twee van de drie, dan gaat hij omhoog. Zo simpel.
Waar .NET-code dezelfde soort schuld heeft
Ik zie in .NET-projecten steeds dezelfde vier plekken terugkomen.
Onbegrensde invoer
Dit is de directe tegenhanger van PHP2Shell: iemand stuurt iets wat groter of dieper is dan je ooit verwachtte. ASP.NET Core heeft redelijke standaardlimieten. Kestrel accepteert standaard een request body tot 30.000.000 bytes, multipart-bodies mogen 128 MB zijn en een formulier mag 1.024 waarden hebben. Voor de meeste API's is dat veel te ruim. Zet het expliciet, dan staat in je code wat je bedoelt:
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.Limits.MaxRequestBodySize = 2 * 1024 * 1024; // 2 MB voor gewone API-calls
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
});
builder.Services.Configure<FormOptions>(form =>
{
form.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB per upload
form.ValueCountLimit = 200;
form.MultipartHeadersCountLimit = 8;
});
builder.Services.ConfigureHttpJsonOptions(json =>
{
json.SerializerOptions.MaxDepth = 32; // standaard is 64
});
var app = builder.Build();
app.MapPost("/documents", async (IFormFile file) =>
{
await using var stream = file.OpenReadStream();
// ...
return Results.Accepted();
})
.WithMetadata(new RequestSizeLimitAttribute(10 * 1024 * 1024))
.DisableAntiforgery();
app.Run();
Die globale limiet laag houden en per endpoint ophogen werkt beter dan andersom. Wie een nieuw endpoint toevoegt, krijgt dan automatisch de strenge variant.
Het lastige zit in je eigen parsers. Neem de klassieke CSV-import:
using var reader = new StreamReader(request.Body);
while (await reader.ReadLineAsync(ct) is { } line)
{
if (line.Length > 4_096)
throw new ImportTooLargeException("Regel te lang.");
// ...
}
Die lengtecontrole ziet er verstandig uit en doet niets. ReadLineAsync leest de hele regel in het geheugen voordat jij hem te zien krijgt. Stuur 500 MB zonder één newline en je controle komt nooit aan de beurt. De request-limiet van Kestrel vangt het nog op, maar op een endpoint waar je die limiet hebt opgehoogd voor grote uploads ben je hem kwijt. Dit is precies het soort bug dat jarenlang "de import is traag bij grote bestanden" heet.
Met System.IO.Pipelines kun je de limiet afdwingen voordat de regel compleet is:
using System.Buffers;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using System.Text;
public sealed class ImportTooLargeException(string message) : Exception(message);
public static class BoundedLines
{
public static async IAsyncEnumerable<string> ReadLinesAsync(
PipeReader reader,
int maxLineBytes,
int maxLines,
[EnumeratorCancellation] CancellationToken ct = default)
{
var count = 0;
while (true)
{
ReadResult result = await reader.ReadAsync(ct);
ReadOnlySequence<byte> buffer = result.Buffer;
while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
{
if (line.Length > maxLineBytes)
throw new ImportTooLargeException($"Regel {count + 1} is groter dan {maxLineBytes} bytes.");
if (++count > maxLines)
throw new ImportTooLargeException($"Meer dan {maxLines} regels.");
yield return Encoding.UTF8.GetString(line);
}
// Wat overblijft is een regel zonder einde. Die mag niet blijven groeien.
if (buffer.Length > maxLineBytes)
throw new ImportTooLargeException("Regel zonder einde overschrijdt de limiet.");
if (result.IsCompleted)
{
if (buffer.Length > 0)
{
if (++count > maxLines)
throw new ImportTooLargeException($"Meer dan {maxLines} regels.");
yield return Encoding.UTF8.GetString(buffer);
}
reader.AdvanceTo(buffer.End);
yield break;
}
reader.AdvanceTo(buffer.Start, buffer.End);
}
}
private static bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
{
SequencePosition? newline = buffer.PositionOf((byte)'\n');
if (newline is null)
{
line = default;
return false;
}
line = buffer.Slice(0, newline.Value);
buffer = buffer.Slice(buffer.GetPosition(1, newline.Value));
return true;
}
}
In een endpoint geef je request.BodyReader mee en vertaal je de exception naar een 413:
app.MapPost("/import", async (HttpRequest request, CancellationToken ct) =>
{
var rows = 0;
try
{
await foreach (var line in BoundedLines.ReadLinesAsync(request.BodyReader, 4_096, 50_000, ct))
rows++;
}
catch (ImportTooLargeException ex)
{
return Results.Problem(ex.Message, statusCode: StatusCodes.Status413PayloadTooLarge);
}
return Results.Ok(new { rows });
});
Het geheugengebruik is nu begrensd door maxLineBytes plus wat de pipe buffert, ongeacht wat iemand instuurt.
stackalloc met een grootte die van buiten komt
stackalloc is een prima performance-tool, en ik zie hem steeds vaker in code die door een AI-assistent "geoptimaliseerd" is. Het probleem ontstaat als de grootte afhangt van invoer:
// Fout: header.Length komt van de client
Span<byte> buffer = stackalloc byte[header.Length * 4];
Een header van een paar megabyte en je krijgt een StackOverflowException. Die kun je in .NET niet afvangen. Het proces stopt, alle requests die op dat moment liepen gaan mee. Een vaste bovengrens met een fallback naar ArrayPool lost het op:
const int StackLimit = 512;
int needed = Encoding.UTF8.GetMaxByteCount(header.Length);
byte[]? rented = null;
Span<byte> buffer = needed <= StackLimit
? stackalloc byte[StackLimit]
: (rented = ArrayPool<byte>.Shared.Rent(needed));
try
{
int written = Encoding.UTF8.GetBytes(header, buffer);
Process(buffer[..written]);
}
finally
{
if (rented is not null)
ArrayPool<byte>.Shared.Return(rented);
}
Zoek in je repo eens op stackalloc en unsafe. Elke treffer waar de grootte niet een constante is, verdient een tweede blik.
Serializers die types uit de payload halen
BinaryFormatter is sinds .NET 9 uit de runtime gehaald; de methodes gooien een PlatformNotSupportedException, tenzij je het losse compatibiliteitspakket installeert. Draai je nog op .NET Framework of heb je dat pakket toegevoegd om een migratie "even" werkend te krijgen, dan heb je een bekende RCE-klasse aan boord. Hetzelfde geldt voor Newtonsoft.Json met TypeNameHandling op iets anders dan None.
De Roslyn-analyzers kennen deze patronen al. Ze staan alleen standaard niet op error. In je .editorconfig:
[*.cs]
# Onveilige deserialisatie
dotnet_diagnostic.CA2300.severity = error # BinaryFormatter
dotnet_diagnostic.CA2301.severity = error
dotnet_diagnostic.CA2326.severity = error # TypeNameHandling anders dan None
dotnet_diagnostic.CA2327.severity = error
dotnet_diagnostic.CA2328.severity = error
dotnet_diagnostic.CA2329.severity = error
dotnet_diagnostic.CA2330.severity = error
# XML met DTD-verwerking
dotnet_diagnostic.CA3075.severity = error
Dat breekt de build op plekken waar je al jaren omheen werkt. Mooi. Dan heb je ze tenminste gevonden.
Native code die je niet zelf schreef
HTTP2Shell liet zien dat de .NET-runtime zelf native code meelevert. Daarnaast trekken veel projecten via NuGet native libraries binnen, zoals SQLite of SkiaSharp. Die vallen buiten het vangnet van managed code en worden alleen bijgewerkt als jij de package bijwerkt. Hoe je controleert wat er werkelijk in je container staat, schreef ik eerder uit in het stuk over je .NET-artifact. Kijk in elk geval in runtimes/*/native van je publish-output.
Laat de AI eerst bij jou zoeken, en eis bewijs
Wat de aanvallers kunnen, kun jij ook. Ik geef een agent tegenwoordig een smalle opdracht: hier zijn de endpoints die invoer van buiten verwerken, hier is de code tot aan de database. Zoek plekken waar invoer de grootte van een allocatie, de diepte van een recursie of het type van een object bepaalt. Beschrijf per vondst hoe een aanvaller hem zou triggeren.
Veel ruis, maar ook vondsten die je zelf in drie maanden niet had gezien.
De regel die ik eraan koppel komt rechtstreeks uit de HN-kritiek: een fix zonder falende test bestaat niet. Program_whiz schreef dat AI-oplossingen soms precies de test halen en verder broos zijn. Daarom schrijf ik eerst de test die de aanval nabootst, zie hem falen, en pas dan de fix. Voor de import-parser hierboven:
using System.IO.Pipelines;
using Xunit;
public class BoundedLinesTests
{
[Fact]
public async Task Regel_zonder_newline_wordt_geweigerd_voordat_hij_het_geheugen_vult()
{
var pipe = new Pipe();
var producer = Task.Run(async () =>
{
var chunk = new byte[64 * 1024];
Array.Fill(chunk, (byte)'A');
for (var i = 0; i < 1_000; i++) // 64 MB, geen enkele newline
{
FlushResult flush = await pipe.Writer.WriteAsync(chunk);
if (flush.IsCompleted) break; // lezer is gestopt
}
await pipe.Writer.CompleteAsync();
});
await Assert.ThrowsAsync<ImportTooLargeException>(async () =>
{
await foreach (var _ in BoundedLines.ReadLinesAsync(pipe.Reader, maxLineBytes: 4_096, maxLines: 50_000))
{
}
});
await pipe.Reader.CompleteAsync();
await producer;
}
}
Zet je de naïeve StreamReader-versie in dezelfde test, dan loopt hij door tot alle 64 MB in één string staan. Die test blijft in de suite, zodat de volgende die de parser "vereenvoudigt" er meteen op vastloopt.
Wat AI niet voor je aflost
Een te ruime limiet of een vergeten analyzer-regel is snel opgelost. Een architectuur waarin één proces alles doet, met gedeelde statische state en een serializer die overal doorheen loopt, is dat niet. Daar helpt een agent bij het vinden. Het opknippen blijft mensenwerk, met de afwegingen die daarbij horen.
En je doorlooptijd blijft je zwakste plek. Drie teams vonden PHP2Shell binnen zes weken. Ga ervan uit dat de bug in jouw code niet lang alleen van jou blijft. Hoe snel een fix van merge naar productie gaat, bepaalt hoe lang je openstaat.
Wat ik deze maand zou doen
Filter je issue-tracker op crashes, timeouts en "out of memory" van het afgelopen jaar en stel bij elke de drie vragen. Zet de Kestrel- en FormOptions-limieten expliciet, laag, en hoog ze per endpoint op. Zoek op stackalloc, unsafe, BinaryFormatter en TypeNameHandling, en zet de bijbehorende analyzers op error. Geef daarna een agent je publieke endpoints en laat hem zoeken, met de afspraak dat elke vondst eerst een falende test wordt.
Veelgestelde vragen over technische schuld en AI-exploits in .NET
Is managed .NET-code niet gewoon veilig tegen dit soort geheugenbugs?
Grotendeels wel, want de runtime doet bounds checking en maakt van een buffer overflow een exception. Het vangnet verdwijnt bij unsafe code, bij native libraries die je via NuGet of de runtime zelf meeneemt, en bij deserialisatie waarbij de payload bepaalt welk type wordt aangemaakt. Daarnaast blijft een crash op invoer van buiten een denial-of-service, ook als er geen code wordt uitgevoerd.
Vangen de standaardlimieten van Kestrel een PHP2Shell-achtige aanval af?
Voor een deel: Kestrel begrenst de request body standaard op 30.000.000 bytes en FormOptions begrenst multipart-bodies en het aantal formulierwaarden. Die waarden zijn ruim gekozen zodat de meeste apps werken, dus voor een API die kleine JSON-berichten verwerkt is het verstandig ze veel lager te zetten. Je eigen parsers moeten daarnaast zelf limieten afdwingen, omdat ze vaak draaien op endpoints waar je de globale limiet hebt opgehoogd.
Hoe vind ik nog gebruik van BinaryFormatter in oude projecten?
Zet analyzer-regel CA2300 en CA2301 op error in je .editorconfig en bouw de solution, dan breekt de build op elke aanroep. Controleer daarnaast of het pakket System.Runtime.Serialization.Formatters ergens als dependency is toegevoegd, want daarmee werkt BinaryFormatter op .NET 9 en nieuwer weer. Op .NET Framework zit het nog gewoon in de runtime en is de analyzer je enige automatische check.
Kan ik een AI-agent mijn codebase laten auditen zonder dat de broncode uitlekt? Dat hangt af van waar het model draait en welke afspraken je met de aanbieder hebt over training en opslag. Voor klantcode werk ik met een zakelijk account waarbij training op invoer uitstaat, of met een lokaal model als het contract dat vereist. Geef de agent in beide gevallen alleen de code die voor de audit nodig is en geen secrets of productieconfiguratie.
Verder lezen
- Het security-embargo is dood: patchen in het tijdperk van AI-exploits
- GitLab gepatcht, Ruby niet: wat het Alpine-incident .NET-teams leert over runtime-updates
- Ruff zet 413 regels standaard aan. Wat betekent dat voor je .NET-analyzers?
Conclusie: Het label "low" op een crash betekende altijd "te duur om uit te buiten", en dat argument is weg. Begrens wat je van buiten binnenlaat en laat de AI eerst in jouw code zoeken. Elke fix die daaruit komt, begint met een test die de aanval nabootst en faalt.
Bronnen: AI is forcing us to pay off 40 years of technical debt (IEEE Spectrum) · Hacker News-discussie · PHP2Shell-analyse (OffSec) · HTTP2Shell-tijdlijn (Bugsink).
