Technical Article

Securizarea unui analizor PDF în Pascal împotriva fișierelor malițioase

Un PDF nu este doar un document pe care îl deschideți. Este un mic program pe care îl rulați. Fiecare font încorporat este un interpret bazat pe stivă care așteaptă charstrings, fiecare imagine este un decodor alimentat cu câmpuri de lățime, înălțime și adâncime de biți pe care fișierul le-a ales, și fiecare flux sosește înfășurat în filtre ai căror parametri au fost setați de fișier. Niciunul dintre aceste numere nu este al dvs. Ele provin de la cel care a produs fișierul, care în cazuri reale reprezintă factura unui client sau o anexă de la un expeditor necunoscut. Decodoarele care transformă acești octeți în pixeli și glife reprezintă suprafața de atac, iar un analizor (parser) care are încredere în datele de intrare este la un singur fișier malformat distanță de un blocaj sau ceva mai rău.

PDFlibPas a trecut printr-o etapă de securizare (hardening) care a tratat întreaga cale de decodificare ca fiind ostilă, pe parcursul programelor de fonturi (TrueType, Type1, CFF și tabelele CMap), decodoarelor de imagini (PNG, GIF, TIFF, JBIG2 și CCITT Grupul 3 și Grupul 4) și filtrelor de flux (LZW, ASCII85 și predictorii Flate). Ceea ce urmează sunt cinci clase de defecte pe care le-a închis, fiecare bazată pe comportamentul specific Delphi care le-a făcut posibile. Acestea sunt remediate în versiunile curente, iar aceleași tipare se repetă în orice cod Pascal care analizează date de intrare nesigure.

O depășire de întreg care generează un buffer subdimensionat

Bugul clasic de siguranță a memoriei într-un decodor de imagini este un produs de dimensiuni care se depășește. Un decodor citește lățimea, înălțimea, numărul de componente și adâncimea de biți, le înmulțește pentru a dimensiona ieșirea, alocă acel număr de octeți, apoi scrie imaginea la dimensiunile sale reale. Dacă înmulțirea este efectuată în aritmetică pe 32 de biți, produsul se poate depăși la o valoare mică chiar și atunci când fiecare factor individual se află într-un interval normal, astfel încât alocarea reușește, dar rezultă mult prea mică, iar decodificarea scrie în afara limitelor. Acesta este CWE-190, depășire de întreg (integer overflow), ducând la o scriere în afara limitelor pe heap (CWE-787) un pas mai târziu.

Calea comună de imagini limita deja fiecare dimensiune la 65535; decodoarele autonome nu au moștenit toate acea limită. O expresie de tip octeți-pe-rând-înmulțit-cu-înălțimea precum ByteCount * FHeight, sau o expresie pe pixel precum FWidth * Components * BitDepth, este un produs pe 32 de biți în Delphi când ambii operanzi sunt întregi pe 32 de biți, indiferent de cât de largă este variabila căreia îi atribuiți rezultatul. O lățime și o înălțime de 60000 sunt fiecare plauzibile pentru o scanare mare, dar produsul lor în octeți depășește un interval cu semn pe 32 de biți, iar lungimea rezultă mică. Aceeași capcană exista în pasul predictorului ZLib, BitsPerComponent * Colors * Columns.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

Ceea ce face din aceasta o problemă Delphi și nu una generică este reducerea silențioasă a tipului (narrowing). Atribuirea unei expresii prea largi într-o destinație pe 32 de biți este o conversie legală despre care compilatorul nu va avertiza în mod implicit, iar verificarea intervalului (range checking) nu prinde o depășire care are loc înainte ca valoarea să fie vreodată folosită ca index. Lăsați produsul pe 32 de biți și limbajul vă oferă în mod silențios o lungime care minte cu privire la cantitatea de memorie pe care decodificarea urmează să o atingă.

Un tip de câmp care face imposibilă declanșarea unei protecții

Un fișier TIFF este un lanț de directoare de fișiere de imagini (IFD), fiecare purtând decalajul în octeți (offset) al următorului. Un fișier malițios poate îndrepta acel lanț înapoi spre sine, iar un cititor care îl parcurge fără o condiție de oprire rulează la nesfârșit. Acesta este CWE-835, o buclă infinită condusă de date de intrare controlate de atacator, iar apărarea este un contor care se oprește odată ce depășește o limită pe care niciun fișier legitim nu ar atinge-o.

Contorul de pagini a fost declarat ca Word, care în Delphi reține valori de la 0 la 65535. Bucla purta o protecție de terminare sub forma „oprește-te când numărul de pagini depășește 65535”, ceea ce pare corect până când observați că operandul și pragul partajează o limită superioară. Un Word nu poate fi niciodată mai mare decât 65535, astfel încât compararea este structural întotdeauna falsă: când contorul atinge 65535, următoarea incrementare îl aduce înapoi la 0, protecția nu vede niciodată o valoare peste plafon, iar un lanț IFD în buclă menține cititorul rulând la nesfârșit.

Remedierea a fost lărgirea câmpului, astfel încât protecția să poată exprima o valoare pe care contorul o poate reține efectiv. Cu TPDFTIFF.FPageCount declarat ca Integer, aceeași comparare FPageCount > 65535 devine realizabilă, bucla se termină, iar proprietatea publică PageCount și-a schimbat tipul pentru a se potrivi, fără a afecta apelantul. Ori de câte ori o verificare a limitelor are forma Value > MaxValueOfType(Value), iar operandul este deja definit exact la acel maxim, condiția este o constantă falsă: lărgiți tipul sau testați egalitatea cu maximul pentru ca acesta să se poată declanșa.

Verificarea limitelor dezactivată pe o cale critică

Cu verificarea intervalului (range checking) activată, Delphi inserează o verificare a limitelor pentru fiecare index de matrice și șir, ceea ce reprezintă diferența dintre un index în afara limitelor care generează o excepție capturabilă ERangeError și același index care citește sau scrie în memoria care nu aparține structurii. Căile fierbinți o dezactivează uneori cu o directivă locală {$R-}, ceea ce este justificabil chiar până când indicii nu mai sunt de încredere.

Accesorul de listă pe care se bazează interpreții de fonturi, TPDFlibStringList.Get, este exact o astfel de cale. Pe Windows, este compilat cu verificarea intervalului dezactivată și indexează direct stocarea sa suport, astfel încât un index în afara limitelor nu este o eroare, ci un acces brut la memorie. Acest lucru este în regulă atunci când indexul este întotdeauna valid, și încetează să mai fie în regulă în interiorul unui interpret de charstring CFF sau Type2, unde indexul poate proveni din fișier. Un charstring care preia un operand dintr-o stivă goală produce un index de minus unu; un identificator de glifă decalat cu unu în raport cu numărul de glife indexează un slot dincolo de sfârșit. Cu verificarea intervalului dezactivată, ambele devin un acces de tip out-of-bounds în loc de o excepție capturabilă, iar deoarece sloturile conțin valori AnsiString contorizate prin referință, o citire eronată poate corupe de asemenea contorul de referință al unui șir.

Securizarea nu a reactivat verificarea intervalului pentru calea fierbinte. A făcut mai întâi indicii demonstrabil valizi: înainte de a lua elementul din vârful stivei de operanzi, interpretul verifică dacă stiva nu este goală, iar fiecare protecție de index a fost scrisă ca un operator strict „mai mic decât” în raport cu numărul, în loc de „mai mic sau egal” care admite decalajul de tip off-by-one. Directiva transferă responsabilitatea pentru limite de la compilator la dvs., iar validarea pe care a eliminat-o trebuie pusă înapoi manual la fiecare punct de intrare.

Recursivitate nelimitată într-un interpretor charstring

Un charstring Type2 poate apela o subrutină, iar o subrutină este ea însăși un charstring care poate apela o alta, astfel încât operatorii de apel de subrutină locali și globali permit fișierului să decidă cât de adânc merge. O subrutină care se apelează pe sine, direct sau printr-un ciclu, recurge la nesfârșit până când stiva nativă este epuizată și procesul se oprește. Acesta este CWE-674, recursiune necontrolată.

Interpretul Type1 se proteja deja împotriva acestui lucru. Acesta conținea un contor al adâncimii de apel și un plafon, PLType1MaxCallDepth, și refuza să coboare dincolo de acesta, ceea ce reflectă limita de adâncime pe care specificația Type1 însuși o numește. Interpretul Type2, adăugat ulterior și similar structural, nu conținea aceeași protecție, iar un font construit manual cu o subrutină care își apelează propriul număr trece direct prin verificarea lipsă într-o depășire de stivă (stack overflow).

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

Remedierea a fost de a oferi căii Type2 aceeași adâncime limitată pe care o avea deja calea soră Type1. Orice coborâre recursivă peste o structură controlată de atacator, fie subrutine de fonturi, o matrice imbricată sau un lanț de referințe încrucișate, are nevoie de un plafon de adâncime pe care datele de intrare nu-l pot ridica.

Memorie neinițializată care se scurge în datele de ieșire

Cel mai subtil defect a divulgat conținutul din heap în rezultatul decriptat, iar cauza este o proprietate a funcției SetLength care este ușor de uitat. Când măriți un AnsiString cu SetLength, Delphi alocă octeții, dar nu îi aduce la valoarea zero, astfel încât noua regiune conține orice se afla anterior în acea memorie heap. Dacă fiecare octet este scris ulterior, acest lucru nu contează niciodată; dacă o cale lasă o parte din buffer nescrisă și apoi o returnează ca date, acei octeți învechiți pleacă odată cu rezultatul. Acesta este CWE-457, utilizarea memoriei neinițializate, iar când rezultatul trece de o graniță de încredere, devine o scurgere de informații (information leak).

Calea de decriptare AES-CBC s-a lovit tocmai de acest lucru. Bufferul de ieșire a fost dimensionat cu SetLength, iar decriptorul a procesat textul cifrat câte un bloc de 16 octeți pe rând. Când lungimea textului cifrat nu era un multiplu de 16, o lungime pe care un atacator o poate alege, blocul parțial final nu a fost scris niciodată, așa că acești octeți finali au păstrat conținutul heap-ului pe care SetLength l-a lăsat în urmă, iar bufferul a fost predat înapoi ca text decriptat al unui obiect document. Soluția constă în două protecții și niciuna singură nu este suficientă: punctul de intrare pentru decriptare respinge acum orice text cifrat a cărui lungime nu este un multiplu al dimensiunii blocului, iar ca măsură de siguranță, rezultatul este golit cu FillChar înainte de utilizare, astfel încât orice cale care nu reușește să scrie o regiune returnează zerouri în loc de reziduuri din heap.

Cu ce rămâneți după finalizarea analizei

Cele cinci defecte sunt buguri diferite, dar se aseamănă. O lățime de întreg care depășește un produs, un tip de câmp care fixează o protecție la o constantă falsă, o verificare a intervalului dezactivată unde indicii nu mai erau siguri, o recursiune fără limită și un buffer pe care limbajul a refuzat să îl inițializeze cu zero. În fiecare dintre acestea, Delphi a făcut exact ceea ce definește, deoarece limbajul vă oferă aritmetică ce se depășește, reducere de tip (narrowing) silențioasă, verificări ale intervalului pe care le puteți opri, recursiune fără o limită încorporată și alocare care nu inițializează. Acesta este contractul, iar un analizor Pascal îl respectă prin gestionarea manuală a patru lucruri la fiecare graniță pe care o controlează fișierul: lățimea întregului, verificarea intervalului, adâncimea recursiunii și inițializarea bufferului.

Aceste defecte sunt închise în versiunile actuale ale PDFlibPas, motorul pentru Delphi și C++Builder. Dacă activitatea dvs. se referă și la modul în care un fișier pretinde a fi protejat, notele însoțitoare despre auditarea criptării și a permisiunilor și despre preflight-ul PDF/A și PDF/UA acoperă partea de analiză a aceluiași analizor, iar totul este livrat în interiorul PDFlibPas Delphi PDF Library, alături de API-urile de încărcare, redare și semnare acoperite în alte părți ale acestui blog.