Big Fish: Het exploiteren van een 37 jaar oude kernelbug in OpenBSD

Begin dit jaar hebben we enkele kwetsbaarheden gemeld bij OpenBSD, vaak samen met voorgestelde patches. Eén daarvan was ongeveer 37 jaar oud en stamt uit de oorspronkelijke BSD-bronnen, waar een teller voor de koppeling van gedeeld geheugen (shared memory attachment counter) was gedefinieerd als een signed 16-bit short. Door herhaaldelijk een proces te forken met records voor de koppeling van gedeeld geheugen, kon die teller boven zijn maximumwaarde van 32.767 worden geduwd, waardoor deze terugsprong (wrap) naar een negatieve waarde. Deze integer overflow kon leiden tot een use-after-free via een race condition; in combinatie met een aparte out-of-bounds write in de kernel maakte dit lokale privilege-escalatie naar root mogelijk. Deze problemen zijn respectievelijk opgelost in errata 026 en 018.

De exploit

Het is ons gelukt om lokale root-rechten in OpenBSD te verkrijgen met KARL / SMAP / SMEP ingeschakeld, zonder gebruik te maken van een information disclosure-kwetsbaarheid, via het volgende pad:

  1. Map 127 SysV shared memory-segmenten, map deze via munmap() en fork 259 processen om de shared memory attach count te laten wrappen naar -32.643.
  2. Voer heap grooming en layout uit, waarbij statistieken van sysctl(KERNMALLOCSTATS) (zoals kbtotal en kb_totalfree) worden gebruikt om allocaties bij te houden en te bepalen wanneer nieuwe pagina's worden gealloceerd.
  3. Win de race condition door IPC_RMID in één proces te gebruiken, terwijl in een helperproces een nieuw size-confused shared memory-segment wordt gekoppeld.
  4. Lijn IP-socket-allocaties uit en zet IPMULTICASTTTL op een niet-standaard waarde van 2 om een persistente 16-byte struct ipmoptions te alloceren. Het laatste woord hiervan bevat twee 16-bit tellers: imo + 12 = imonummemberships | imomax_memberships << 16.
  5. Trigger de conditionele -1 out-of-bounds write om zowel het aantal lidmaatschappen (number of memberships) als de velden voor het maximum aantal lidmaatschappen te overschrijven.
  6. Sluit de socket via close() om de groepsleden te doorlopen, wat atomaire decrementen triggert die de PTE-zelfreferentiële mapping twee keer wijzigen. Dit schakelt de mapping om van gebruikers- naar supervisor-toegang, waardoor kernel-code-executie wordt bereikt door een voorspelbare kernel-pointer te overschrijven.
  7. Ruim de shared memory-boekhouding op en herstel de overschreven kernel-pointer om een root-shell te verkrijgen.

Dat is de korte versie; de langere versie is interessanter en bevat de geschiedenis van de kwetsbaarheid, aangezien een korte shm_nattch ongeveer 37 jaar terug te herleiden is naar de oorspronkelijke BSD-bronnen.

Kijkend naar het grotere geheel, leidde deze kwetsbaarheid tot code-executie via een write-what-where-primitief. De write werd veroorzaakt door een TAILQ_REMOVE, de what was onze userland-geheugenmapping die supervisor-modus toegankelijk was gemaakt om SMAP/SMEP te omzeilen, en de where was een voorspelbare locatie van een kernel-pointer.

Mitigatie van de exploit

Hoewel OpenBSD's KARL de kernel-binary opnieuw linkt om zowel functie- als data-adressen te randomiseren, blijven de geheugenallocatoren van de kernel deterministisch. Zowel de boot-time allocator als de best-fit allocator missen base address randomization. Bijgevolg landen vroege boot-allocaties consistent op voorspelbare locaties over verschillende boots op dezelfde hardware. Wanneer deze voorspelbare structuur-allocaties functiepointers bevatten, worden ze ideale doelwitten voor exploitatie. Zo richt de exploit zich betrouwbaar op de lo0- en enc0-ioctl-handlers, waarbij er één wordt overschreven om de controle over de flow te krijgen en de andere wordt gebruikt om de overschreven pointer te herstellen.

Naast de eerder genoemde errata-fixes zijn er verschillende andere benaderingen die kunnen worden genomen om deze wegen in de toekomst te elimineren of de waarschijnlijkheid van gebruik te verminderen.

Ten eerste kunnen we safe-unlinking implementeren, een techniek die controleert op invarianten van dubbel gekoppelde lijsten voordat de unlink-geheugenschrijfacties worden uitgevoerd. Als de invarianten worden geschonden, wordt er een geschikte abort() of panic() aangeroepen (afhankelijk van of het om user-modus of kernel-modus gaat), waardoor geheugen corruptie wordt voorkomen. Een voorbeeldimplementatie is beschikbaar; FreeBSD en NetBSD hebben al een implementatie van safe-unlinking in hun sys/queue.h-implementatie.

Ten tweede kunnen we het allocatieadres van de zelfreferentiële PTE-map randomiseren, zodat de gerichte atomaire decrement-locaties niet mogelijk zouden zijn. Dit gebeurt door een willekeurige byte (.openbsd.randomdata) te gebruiken en tijdens het opstarten een PML4-slot (320 + (randombyte & 127)) te selecteren, en de recursieve entry op die locatie te installeren. De PTEBASE-definitie wordt dan een runtime-expressie, wat resulteert in bases van 0xffffa00000000000 tot 0xffffdf8000000000 in intervallen van 512 GiB. In het geval dat randomisatie niet geschikt is, zouden andere opties volstaan, zoals het markeren van de PTE als read-only en het wijzigen van CR0.WP tijdens de modificatie.

Ten derde kan de kernel-allocator non-deterministisch worden gemaakt via twee benaderingen: door het basisadres waar geheugenallocaties plaatsvinden tijdens het opstarten te randomiseren, en daarnaast de volgorde van allocatie tijdens runtime te randomiseren. De randomisatie van het basisadres is effectief gratis, aangezien de kloof die het creëert later nog steeds kan worden gebruikt; runtime-randomisatie brengt wel enige overhead met zich mee tijdens allocatie. Het is mogelijk dat diepere wijzigingen in de allocator deze impact kunnen verlichten. Beide benaderingen zouden voldoende zijn om de voorspelbare pointerlocatie te doorbreken zonder dat er aanvullende kwetsbaarheden (softwarematig of hardwarematig) nodig zijn.

Verder zijn er bestaande en komende compilerfuncties die kunnen worden gebruikt om volledige kwetsbaarheden te mitigeren, zoals de array bounds sanitizer (een subset van de Undefined Behaviour sanitizer), die out-of-bounds toegangen tot arrays met een vaste grootte kan elimineren. Calif heeft aan FreeBSD gesuggereerd dat de array bounds sanitizer een nuttige exploit-mitigatie zou kunnen zijn die in release-builds kan worden ingeschakeld, samen met het privé rapporteren van 21 problemen die de array bounds sanitizer zou hebben gedetecteerd; sommige met een impact op lokale privilege-escalatie. Een komende compilerfunctie die kan helpen bij het mitigeren van heap-problemen is Alloc Tokens, waarmee allocaties die pointers bevatten kunnen worden geïsoleerd van allocaties zonder pointers. Deze isolatie kan overflows vanuit allocaties zonder pointers veel moeilijker exploiteerbaar maken door pointer-toegang te voorkomen, en ervoor zorgen dat vulnerability-klassen zoals Use-after-Free niet het tegenovergestelde type allocatie als vervangende geheugenallocatie kunnen gebruiken.

Sinds het begin heeft OpenBSD consequent gepleit voor proactieve beveiliging via broncode-auditing, het vervangen van onveilige API's, geavanceerde wachtwoord-hashing, cryptografie, privilege-scheiding, sandboxing, en geheugenveiligheid via ASLR en W^X; hiermee zetten ze vaak de standaard voor systeem- en netwerkverharding.

Door proactief te verharden en nieuwe en bestaande mitigatie-opties aan te passen, kunnen we vaak systematisch volledige klassen van exploitatie-primitieven neutraliseren, of in ieder geval de kosten voor de aanvaller verhogen (bijv. extra tijdsinvestering, meer kwetsbaarheden nodig om een probleem te exploiteren, het vinden van alternatieve wegen, etc.). Investeren in defensieve verbeteringen voordat kwetsbaarheden actief worden getarget, verhoogt de drempel voor aanvallers en helpt een robuuste bescherming en langetermijnveerkracht te garanderen. In het tijdperk van AI-ondersteunde ontdekking van kwetsbaarheden wordt het mitigeren van volledige kwetsbaarheidsklassen, gecombineerd met het verhogen van de aanvalcomplexiteit, alleen maar kritieker.