Blog ·
Een scan die zijn eigen tick sloot
Elke geslaagde geplande scan zette zijn eigen tijdvak op verlopen, door één woord uit de verkeerde opsomming. Twee defecten die alleen echte metingen tonen.
Door Provibr
Onze blootstellingsscan heeft een planner. Hij claimt een tijdvak, stuurt een scancommando naar de agent die de machine kan bereiken, en wacht op twee dingen: de meting zelf, en de afronding van het commando dat haar droeg. Loopt een tijdvak af zonder antwoord, dan wordt het verlopen genoemd, en verschijnt de machine als "geen recente meting".
Elke geslaagde scan zette zijn eigen tijdvak op verlopen. Vier van de vier, gemeten tegen een echte agent, terwijl de vier metingen zelf keurig werden ingelezen en bewaard. Het paneel meldde dat vier machines niet recent gescand waren, minuten nadat ze gescand waren.
Eén woord, uit een andere tabel
De afhandelaar die een onbeantwoord tijdvak sluit begon met een vroege uitstap: is het commando geslaagd, dan valt er niets te sluiten, dus vertrek.
Het woord waarmee hij vergeleek was succeeded. Dat woord bestaat niet in de opsomming van commandostanden, die pending, sent, completed, failed, timeout en cancelled kent. Het is het woord uit een heel andere tabel, die de installaties van agents bijhoudt, en het belandde hier omdat iemand de afhandelaar schreef met de standen van die andere tabel voor ogen en omdat de parameter als gewone tekst getypeerd was.
Gewone tekst is het hele verhaal. De vergelijking compileert tegen elke tekst, dus niets protesteert, en het falen is geen uitzondering maar een tak die nooit genomen wordt. De afhandelaar liep zijn pad voor "er kwam geen antwoord" tegen elke geslaagde scan af en zette gehoorzaam elk tijdvak op verlopen.
Geen enkele unittest had dit kunnen vinden. Er is geen commandolevenscyclus in een nabootsing: de nabootsing ís het ding dat je de stand aanreikt, dus de stand is wat de schrijver van de test opschreef, en die schreef hetzelfde verkeerde woord. Alleen een echte agent, die echte afrondingen door de echte verdeler stuurt, levert de toestand op die dit blootlegt. De parameter is nu getypeerd op de drie standen die deze functie werkelijk kunnen bereiken, wat de gelijkwaardige monitoringafhandelaar altijd al deed. Twee functies, dezelfde vraag, en maar één ervan was zorgvuldig geschreven.
En de twee berichten komen in de verkeerde volgorde binnen
De vergelijking repareren was niet genoeg, en de reden is een tweede ding dat alleen een echte agent je laat zien.
De agent stuurt twee losse berichten: het scanresultaat, en de afronding van het commando. Ze reizen onafhankelijk. Gemeten kwam het resultaat als eerste binnen, veertien milliseconden vóór de afronding. Dat interval is klein genoeg dat er nooit over nagedacht wordt en groot genoeg dat het altijd optreedt.
Tegen de tijd dat de afrondingsafhandelaar draait is het tijdvak dus meestal al door het resultaat gesloten. Een bijwerking zonder voorwaarde heropent dan een afgerond tijdvak en zet het opnieuw op verlopen, wat de oorspronkelijke fout met een andere hoed op is. De bijwerking is daarom geklemd op de twee standen waarin een tijdvak nog op antwoord wacht. Is het al beslecht, dan verandert de afhandelaar niets.
Dat is een algemene vorm: kan een feit uit twee richtingen komen, dan moet de schrijver die niet de eerste is benoemen uit welke standen hij mag vertrekken.
Eén gesloten bevinding die de hele vloot stilzette
Het tweede defect is stiller en het richtte meer schade aan.
Werkt een kwetsbaarhedenfeed bij, dan worden open bevindingen tegen de nieuwe gegevens herbeoordeeld. Een bevinding waarvan de match verdwenen is wordt gesloten, en wel als ongeverifieerd en niet als opgelost, want er is niets gemeten: wat veranderde is onze afleiding en niet de machine. Dat onderscheid is de reden dat die stand bestaat.
De herbeoordeling haalde de bestaande afgeleide bevindingen op om "werk deze bij" van "maak een nieuwe" te kunnen onderscheiden. Hij haalde alleen de open bevindingen op. Dat is een redelijk ogend filter en het is fout, want de uniciteitsbeperking eronder weet niets van standen: hij staat op de server en een vingerafdruk van de bevinding, punt.
De volgorde gaat dus zo: een nationale kwetsbaarhedendatabase corrigeert een versiebereik, onze match verdwijnt, de bevinding sluit. Weken later herziet diezelfde database het bereik terug, wat werkelijk gebeurt, en komt de match terug. De query ziet de gesloten rij niet. De code neemt de tak "die bestaat nog niet". De invoeging raakt de unieke index en gooit een beperkingsfout.
En dan ontsnapt die fout. De aanroeper vangt hem als "de advisoryronde mislukte", en die ronde herbeoordeelt vervolgens helemaal niets, voor elke klant op het platform. De toestand die hem veroorzaakte gaat vanzelf niet weg, dus elke volgende ronde faalde op dezelfde manier. Eén bevinding die gesloten en heropend was, bevroor de herbeoordeling van een hele vloot, blijvend, met een logregel als enig symptoom.
De reparatie is klein: neem de gesloten rijen erbij, en past een gesloten bevinding weer, heropen hem dan met zijn tijdlijn intact in plaats van een nieuwe te beginnen. Een heropende bevinding is een ander feit dan een nieuwe, en wie hem beoordeelt verdient te zien dat dit terugkwam en niet aankwam.
Een feedlus, drie keer gemeten
De feedworker was geschreven voordat er enige manier bestond om hem te draaien zonder een echte database te raken, en dat is de val waar de vorige veiligheidsronde in liep: het databasepakket importeren leest de omgeving en verbindt, dus een unittest met een geïnjecteerde ophaalfunctie schreef stilletjes vier advisories in een ontwikkeldatabase terwijl hij groen meldde.
De opslagkant is nu een naad, en de lus is te toetsen tegen een boekhouding in het geheugen terwijl de productiecode de echte ongewijzigd gebruikt. Daarna is hij drie keer tegen de werkelijke publieke feeds gedraaid.
| Ronde | Verzoeken | Wat er gebeurde | Tijd |
|---|---|---|---|
| Koud | 4 | 7.066 advisories bewaard, 31,4 MB na decompressie | 18,7 s |
| Ongewijzigd | 1 | 304 Not Modified, bewaarde validators behouden, nul schrijfacties | 0,3 s |
| Cursor twee dagen terug | 3 | 355 advisories bijgewerkt, nul nieuw | seconden |
De 304 is waar het om ging. Een statisch geserveerde advisorycatalogus antwoordt op een voorwaardelijk verzoek met "er is niets veranderd", en het juiste gedrag is de bewaarde validators houden, niets schrijven, en de ronde als geslaagd tellen.
Een paar formaten zijn het onthouden waard, want juist daar wordt naar gegokt: de catalogus met actief misbruikte kwetsbaarheden is ongeveer 188 kB op de draad omdat hij gecomprimeerd is, en één pagina van tweeduizend records uit de grotere feed is ruwweg 18,7 MB. Allebei worden ze gelezen door een harde byteplafond en niet door de gewone JSON-hulp, die helemaal geen grens kent; dit zijn de twee grootste antwoorden die het platform binnenhaalt en ze komen van hosts die wij niet beheren.
Een venster dat halveert in plaats van overslaat
De grotere feed is gepagineerd, binnen een bewegend tijdvenster. Twee dingen waren mis met die lus, en allebei verloren ze gegevens zonder ooit iets te melden.
Bevatte een venster meer records dan het paginaplafond toeliet, dan stopte de lus en schoof de cursor tóch naar het einde van het venster. Alles voorbij het plafond werd stil overgeslagen, en zou alleen ooit opgepikt worden als een andere wijziging dezelfde records opnieuw raakte.
En een lege pagina midden in een venster, terwijl er nog records zouden bestaan, schoof de cursor een volle pagina op. Tweeduizend records weg.
Allebei worden ze nu met hetzelfde beginsel afgehandeld: past het antwoord niet, stel dan een kleinere vraag, en is het antwoord onmogelijk, verplaats dan niets.
Een te groot venster wordt gehalveerd in plaats van afgekapt, hoogstens zes keer, tot een bodem van één uur. Zes halveringen brengen een week terug tot ruim twee uur, wat genoeg is voor elke realistische achterstand. Onder een uur houdt het op: een venster van minuten dat nog steeds twintigduizend records oplevert is geen venstervraagstuk maar een storing, en niet schrijven en de cursor laten staan is dan toevallig het enige eerlijke antwoord dat er is. Een lege pagina midden in een venster wordt net zo behandeld, als een fout die de cursor met rust laat, zodat de volgende ronde het opnieuw probeert in plaats van over een gat heen te lopen.
Het halveren gebeurt ná het schrijven van de pagina, niet ervoor. Wat wij al lazen hoort bij het oorspronkelijke venster en is geldig; het weggooien zou de ene soort gegevensverlies voor de andere inruilen.
Eén klein ding in dezelfde hoek, het herhalen waard omdat het onder tijdsdruk makkelijk misgaat: de API-sleutel gaat in een kop en nooit in de querystring. URL's belanden in logs, in proxycaches en in foutrapporten. Koppen niet.
Wat wij zouden opschrijven
- Typeer de parameter, zeker als het een stand is. Een tekstvergelijking met een woord uit de opsomming van een andere tabel compileert, draait, en neemt voor altijd de verkeerde tak.
- Twee berichten over één gebeurtenis komen in de verkeerde volgorde binnen. Klem de tweede schrijver op de standen waaruit hij mag vertrekken.
- Een uniciteitsbeperking die standen negeert ontmoet code die dat niet doet. Haal de gesloten rijen erbij, of de eerste heropening wordt een schending.
- Vraag je af wat één fout vergiftigt. Een uitzondering die uit een lus per item ontsnapt maakt van één slechte rij een platformbrede stilstand, en die toestand gaat vanzelf niet weg.
- Een voorwaardelijk verzoek dat met 304 beantwoord wordt is geslaagd. Houd de validators, schrijf niets, en tel het niet als mislukking.
- Is een venster te groot, halveer het; is een pagina onmogelijk, stop. Een cursor voorbij ongelezen gegevens schuiven is de enige uitkomst zonder weg terug.
- Een worker die niet zonder database te draaien is, wordt tegen je database getest. Een opslagnaad is goedkoper dan de opruiming.
Voor de duidelijkheid: de blootstellingsscan is gebouwd en staat achter een uitrolschakelaar, dus geen enkele klant heeft hem voor zich. De herbeoordeling, de feeds en de spreiding over meerdere adressen zijn uitgeoefend; onbewezen blijven de vensterversmalling tegen een levende feed in plaats van een geïnjecteerde, het indexgedrag op een werkelijk volle catalogus, en een scan die vanaf een andere machine draait dan degene die erom vroeg.
Uit het platformwerk aan Provibr. Waarom de matcher weigert zeker te zijn over een versie staat in een match die weigert zeker te zijn, en waarom de probe erachter bijna niets verstuurt staat in waarom een kwetsbaarhedenscanner nul bytes hoort te sturen.