.NETDevOpsSecurityDockerCI/CD

GitLab gepatcht, Ruby niet: wat het Alpine-incident .NET-teams leert over runtime-updates

Alpine draaide een bijgewerkte GitLab, maar het lek zat in Ruby. Zo controleer je in .NET of je runtime, base image en regex-invoer echt gepatcht zijn.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 29 oktober 2026 · 12 min lezen
GitLab gepatcht, Ruby niet: wat het Alpine-incident .NET-teams leert over runtime-updates

Alpine Linux publiceerde deze week een eerlijk incidentverslag over hun eigen GitLab-server. De kern past in één zin uit dat verslag: "upgrading GitLab is not enough: you also need Ruby 3.3.13, 3.4.12, 4.0.9 or later." Ze hadden GitLab bijgewerkt. Twee keer zelfs. Het lek bleef open, omdat het niet in GitLab zat.

Op Hacker News ging de discussie al snel over C-bibliotheken en zelf hosten. Ik wil het over iets saaiers hebben, iets wat ik bij bijna elke klant tegenkom: teams weten precies welke versie van hun eigen code draait, en hebben geen idee welke runtime eronder zit.

Wat er bij Alpine gebeurde

Volgens het verslag van Alpine gaat het om CVE-2026-97229, een kritieke kwetsbaarheid (CVSS 10.0) in de manier waarop GitLab reguliere expressies uit CI-configuratie evalueert. De oorzaak zit in Onigmo, de regex-engine van Ruby. GitLab bracht op 12 oktober een fix uit, maar die werkte alleen in combinatie met een gepatchte Ruby, en die zat in de officiële GitLab-pakketten.

Alpine bouwt GitLab niet uit die pakketten. De tijdlijn:

  • 13 oktober: Alpine upgradet per ongeluk naar 19.3.4, terwijl de fix in 19.3.5 zat.
  • 20 en 21 oktober: een aanvaller maakt een account aan, stuurt kwaadaardige CI-configuraties en laat GitLab-workers acht keer crashen.
  • 22 oktober: upgrade naar 19.3.6. Dat lost het Ruby-probleem nog steeds niet op.
  • 23 oktober: Ruby brengt de officiële fix uit en de aanvaller probeert het opnieuw.
  • 27 en 28 oktober: GitLab gaat dicht, wordt opnieuw opgebouwd en alle wachtwoorden worden gereset.

Twee details vond ik opvallend. De aanvaller had Alpine's eigen GitLab-containerimage gedownload, wat volgens Alpine "suggests they were tuning the exploit to our exact build." En bij vier van de acht crashes kwam de aanvaller deels verder, al liep de aanval daarna vast. Of er code is uitgevoerd, kan Alpine niet uitsluiten. Wel hebben ze via externe bronnen gecontroleerd dat pakketten, de aports-repository en de releases van 21 oktober niet zijn aangepast.

In de HN-thread wees iemand er nog op dat containerscanners de 19.3.6-image waarschijnlijk als gepatcht zouden markeren, omdat Ruby de fixes uitbracht zonder CVE-nummers aan te vragen. Dat heb ik niet zelf nagegaan, maar het klinkt aannemelijk. Een scanner kijkt naar versienummers en CVE-databases. Als een fix in een laag zit die geen CVE heeft, ziet de scanner hem niet.

Hetzelfde patroon in .NET

Een ASP.NET Core-app bestaat in productie uit minstens drie lagen met elk een eigen patchritme: je eigen code en NuGet-pakketten, de .NET-runtime, en het besturingssysteem eronder (meestal de base image). De meeste teams hebben voor de eerste laag een proces. Dependabot of Renovate opent PR's, dotnet list package --vulnerable draait in CI, iemand kijkt ernaar.

Voor de runtime ligt dat anders. Hoe de runtime binnenkomt, hangt af van je deploymentmodel, en dat bepaalt ook hoe hij oud wordt.

Framework-dependent in een container

Je Dockerfile begint met iets als FROM mcr.microsoft.com/dotnet/aspnet:8.0. Microsoft werkt die tag bij bij elke servicing-release. Jouw image niet. Die krijgt de nieuwe runtime pas als je hem opnieuw bouwt, en een app waar al drie maanden niemand aan werkt, wordt al drie maanden niet opnieuw gebouwd. Dan draait er dus een runtime van drie maanden geleden, ook al zegt de tag nog steeds 8.0.

Pin je de image op een digest voor reproduceerbare builds (wat ik aanraad), dan wordt het nog explicieter: zonder bot die die digest bijwerkt, verandert er nooit iets.

# Digest vastzetten voor reproduceerbaarheid; Dependabot of Renovate werkt hem bij
FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:<digest> AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"
  - package-ecosystem: "nuget"
    directory: "/"
    schedule:
      interval: "weekly"

Self-contained en Native AOT

Bij een self-contained publish zit de runtime in je eigen output. Dat is handig, totdat er een runtime-CVE uitkomt. Dan moet je herbouwen en opnieuw uitrollen, ook als je code niet veranderd is.

Er zit nog een addertje onder het gras. Bij een self-contained build kiest de SDK de nieuwste runtime-patch die hij zelf kent. Staat er op je build-agent een SDK van een half jaar oud, of zet je global.json de SDK vast met "rollForward": "disable", dan krijgt je app de runtime van een half jaar oud mee. De pipeline is groen, de build is netjes reproduceerbaar, en je hebt het oude lek gewoon mee ingepakt. Precies de situatie van Alpine: de versie van je eigen spullen klopt, de laag eronder niet.

Meet wat er echt draait

Het eerste wat ik bij klanten toevoeg, is dat de app zelf zegt op welke runtime hij draait. Dat kost tien regels.

using System.Runtime.InteropServices;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(o =>
    o.AddPolicy("ops", p => p.RequireRole("ops")));

var app = builder.Build();

app.Logger.LogInformation(
    "Gestart op {Framework} ({Os}, {Arch})",
    RuntimeInformation.FrameworkDescription,
    RuntimeInformation.OSDescription,
    RuntimeInformation.ProcessArchitecture);

app.MapGet("/internal/runtime", () => Results.Ok(new
{
    Framework = RuntimeInformation.FrameworkDescription,
    RuntimeVersion = Environment.Version.ToString(),
    Os = RuntimeInformation.OSDescription
})).RequireAuthorization("ops");

app.Run();

Zet dat endpoint achter autorisatie. Een openbaar endpoint met je exacte runtimeversie is een cadeautje voor iemand die, net als bij Alpine, een exploit op je build afstemt. De logregel bij het opstarten is genoeg om in Loki of Application Insights per omgeving te zien wat er draait.

Laat CI falen op een verouderde runtime

Weten wat er draait is stap één. Stap twee is dat de pipeline nee zegt als het te oud is. Microsoft publiceert de release-metadata als JSON op builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json. Per kanaal staan daar onder meer latest-runtime, security (bevat de laatste release beveiligingsfixes) en eol-date.

Met de file-based apps uit .NET 10 heb je hier geen apart project voor nodig. Eén bestand in je repo:

// tools/check-runtime.cs
// Gebruik: dotnet run tools/check-runtime.cs -- 8.0.31
using System.Net.Http.Json;
using System.Text.Json.Serialization;

if (args.Length != 1 || !Version.TryParse(args[0], out var deployed))
{
    Console.Error.WriteLine("Geef de runtimeversie mee, bijvoorbeeld 8.0.31");
    return 2;
}

var channel = $"{deployed.Major}.{deployed.Minor}";
using var http = new HttpClient();
var index = await http.GetFromJsonAsync<ReleasesIndex>(
    "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json");

var entry = index?.Channels.SingleOrDefault(c => c.ChannelVersion == channel);
if (entry is null)
{
    Console.Error.WriteLine($"Kanaal {channel} staat niet in de releases-index.");
    return 2;
}

var exitCode = 0;

if (entry.EolDate is { } eol && DateOnly.Parse(eol) <= DateOnly.FromDateTime(DateTime.UtcNow.AddDays(30)))
{
    Console.WriteLine($"Waarschuwing: .NET {channel} is uit support vanaf {eol}.");
}

var latest = Version.Parse(entry.LatestRuntime);
if (deployed < latest)
{
    Console.WriteLine($"Runtime {deployed} loopt achter op {latest}.");
    if (entry.Security)
    {
        Console.WriteLine("De nieuwste release bevat beveiligingsfixes.");
        exitCode = 1;
    }
}
else
{
    Console.WriteLine($"Runtime {deployed} is actueel.");
}

return exitCode;

record ReleasesIndex(
    [property: JsonPropertyName("releases-index")] List<Channel> Channels);

record Channel(
    [property: JsonPropertyName("channel-version")] string ChannelVersion,
    [property: JsonPropertyName("latest-runtime")] string LatestRuntime,
    [property: JsonPropertyName("security")] bool Security,
    [property: JsonPropertyName("eol-date")] string? EolDate);

Let op: security zegt alleen iets over de nieuwste release. Lag je drie patches achter en bevatte alleen de middelste een beveiligingsfix, dan mist dit script dat. Wil je dat precies hebben, haal dan per kanaal ook de releases.json op en loop alle releases na die van jou. Voor de meeste teams is "achter én de laatste is een security-release" een prima eerste grens.

In GitHub Actions vraag je de runtime op uit de image die je net gebouwd hebt. Dat is de enige bron die telt:

- name: Controleer .NET-runtime in de image
  run: |
    RUNTIME=$(docker run --rm --entrypoint dotnet myapp:${{ github.sha }} --list-runtimes \
      | awk '/Microsoft.NETCore.App/ {print $2}' | sort -V | tail -1)
    echo "Runtime in image: $RUNTIME"
    dotnet run tools/check-runtime.cs -- "$RUNTIME"

Dit werkt op de gewone aspnet- en runtime-images. Voor self-contained of chiseled images zonder dotnet-host op het pad lees je de versie uit de opstartlog van een rooktest, of uit het /internal/runtime-endpoint.

Draai deze check ook op een schema, bijvoorbeeld wekelijks. De pipeline die alleen draait als iemand code pusht, ziet nooit dat de wereld onder een stilliggende app veranderd is. Die stilliggende apps zijn precies de apps die achterlopen.

Regex op invoer van gebruikers

Het tweede lesje uit dit incident gaat over wat je accepteert. GitLab evalueert reguliere expressies die gebruikers zelf in hun CI-configuratie zetten. Wie een account kon aanmaken, kon dus input leveren aan een C-bibliotheek in het hart van de server.

In .NET is de regex-engine managed code, dus geheugencorruptie zoals in Onigmo ligt minder voor de hand. Het klassieke risico is hier ReDoS: een patroon of invoer die de backtracking-engine minutenlang bezighoudt. Laat je gebruikers patronen invoeren (filters, routeringsregels, validatieregels in een SaaS-product), dan zou ik drie dingen afdwingen: een maximale lengte, een timeout, en waar het kan RegexOptions.NonBacktracking.

using System.Text.RegularExpressions;

public static class UserPattern
{
    private const int MaxPatternLength = 256;
    private static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(50);

    public static bool TryCreate(string pattern, out Regex? regex, out string? error)
    {
        regex = null;
        error = null;

        if (pattern.Length > MaxPatternLength)
        {
            error = $"Een patroon mag maximaal {MaxPatternLength} tekens lang zijn.";
            return false;
        }

        try
        {
            regex = new Regex(
                pattern,
                RegexOptions.NonBacktracking | RegexOptions.CultureInvariant,
                MatchTimeout);
            return true;
        }
        catch (NotSupportedException)
        {
            error = "Backreferences en lookarounds worden niet ondersteund.";
            return false;
        }
        catch (ArgumentException ex)
        {
            error = $"Ongeldig patroon: {ex.Message}";
            return false;
        }
    }

    public static bool SafeIsMatch(Regex regex, string input)
    {
        try
        {
            return regex.IsMatch(input);
        }
        catch (RegexMatchTimeoutException)
        {
            // Liever een gemiste match dan een vastgelopen worker
            return false;
        }
    }
}

NonBacktracking bestaat sinds .NET 7 en garandeert lineaire matchtijd ten opzichte van de invoer. Je levert backreferences, lookarounds en atomische groepen in. Voor door gebruikers ingevoerde filters mis ik die zelden.

Als vangnet voor regexes die je vergeten bent, kun je bij het opstarten een procesbrede standaardtimeout zetten, vóórdat er ergens een Regex wordt aangemaakt:

AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(2));

Dat geldt alleen voor regexes zonder expliciete timeout. Een fix voor je eigen code is het dus niet. Het voorkomt alleen dat één vergeten Regex.IsMatch je hele service platlegt.

Acht crashes zijn een alarmsignaal

Wat me in het verslag het meest bijbleef: de aanvaller liet GitLab-workers acht keer crashen, verdeeld over twee dagen. Achteraf is dat een duidelijk spoor. Op het moment zelf was het blijkbaar ruis.

Bij de meeste .NET-diensten die ik zie, is een herstart onzichtbaar. Kubernetes of Docker start de container opnieuw, de healthcheck wordt weer groen, niemand kijkt. Zet er een alert op. Een herstart per week is normaal, vier in een uur verdient een mens die kijkt. In Prometheus met kube-state-metrics is dat één regel:

- alert: ContainerRestartsBurst
  expr: increase(kube_pod_container_status_restarts_total{namespace="prod"}[1h]) > 3
  for: 5m
  labels:
    severity: page
  annotations:
    summary: "{{ $labels.container }} is in een uur meer dan drie keer herstart"

Alpine noemt zelf ook betere logging van uitgaande verbindingen en een langere bewaartermijn voor logs als maatregel. Dat zou ik voor elke selfhosted CI-server overnemen, en voor elke .NET-dienst die invoer van buiten verwerkt.

Wat ik deze week zou doen

Zoek per app op welke runtimeversie er in productie draait. Wat er in de csproj staat, telt hier niet. Ik verwacht dat je bij minstens één app schrikt.

En kijk naar je kalender. Volgens de releases-index van Microsoft is de einddatum van .NET 8 op 10 november 2026. Draait er daarna nog iets op 8, dan heb je een runtime die nooit meer een patch krijgt, hoe vaak je je image ook herbouwt. Het is dezelfde val als bij Alpine, alleen dan permanent.

De les uit het Alpine-incident is eigenlijk heel droog. Je versienummer zegt iets over jouw code. Over de lagen eronder zegt het niets, en daar zat dit lek.

Veelgestelde vragen over runtime-patches in .NET

Krijgt mijn Docker-image automatisch de nieuwste .NET-runtime als Microsoft de tag bijwerkt? Nee. De tag aspnet:8.0 wijst na een servicing-release naar een nieuwe image, maar jouw image bevat de runtime van het moment waarop jij hem bouwde. Pas na een nieuwe build en deploy draait de nieuwe runtime, dus een geplande wekelijkse rebuild of een bot die je digest bijwerkt is nodig.

Vindt dotnet list package --vulnerable ook kwetsbaarheden in de .NET-runtime? Nee, dat commando kijkt naar NuGet-pakketten in je projecten. De runtime in je container of in je self-contained publish valt erbuiten, net als OS-pakketten in de base image. Daarvoor heb je een runtimecheck zoals in dit artikel en een image-scanner nodig.

Is RegexOptions.NonBacktracking altijd sneller dan de standaard-engine? Niet altijd. Het garandeert lineaire matchtijd, wat je beschermt tegen ReDoS, maar bij eenvoudige patronen kan de standaard-engine of een source-generated regex sneller zijn en het aanmaken van een NonBacktracking-regex kost meer. Gebruik het vooral voor patronen die je niet zelf schrijft.

Moet ik een selfhosted GitLab of Gitea afschermen achter een VPN? Als er geen reden is om onbekenden een account te laten aanmaken, ja. Bij Alpine kon de aanvaller zelf een account registreren en daarmee CI-configuratie aanleveren, en achter een VPN of met registratie op uitnodiging was die eerste stap er niet geweest.

Verder lezen


Conclusie: Alpine had GitLab bijgewerkt en was toch kwetsbaar, omdat de fix in Ruby zat. Laat je .NET-apps hun runtimeversie loggen, laat CI falen op een verouderde runtime in de image die je net gebouwd hebt, herbouw stilliggende apps op een schema en geef invoer van gebruikers nooit ongelimiteerd aan een regex-engine.

Bronnen: Alpine Linux: GitLab Security Incident (CVE-2026-97229) · Hacker News-discussie · .NET releases-index.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op