Pos('/Length', Dict) gibi alt dize (substring) aramaları, bir PDF sözlüğünü okumak için yanlış araçtır, çünkü PDF ad anahtarları (name keys) önekleri paylaşır: /Length, /Length1'in bir öneğidir ve /Encrypt, /EncryptMetadata'nın bir öneğidir. ISO 32000-1 §7.3.5, bir adı yalnızca bir sınırlayıcı (delimiter) veya boşlukta (whitespace) biten bir belirteç (token) olarak tanımlar, bu nedenle bir anahtar yalnızca kendisinden sonraki bayt bu karakterlerden biri olduğunda bulunmuş sayılır. Bu tek kontrolü atlayan bir sözlük okuyucu, eninde sonunda tamamen geçerli bir dosyadan yanlış değeri okuyacaktır
Bize bunu öğreten hata hiç de bir sözcük analizcisi (lexer) sorunu gibi görünmüyordu. Bir uygunluk taraması bir FontFile akışını hasarlı olarak bildirmeye başladı: Sıkıştırılmamış yazı tipi programı, tablonun ortasında kesilmiş bir parçaydı. Dosya her görüntüleyicide sorunsuz açılıyordu. Diskteki akış verileri sağlamdı. Temel neden, paylaşılan sözlük okuyucumuzun tek bir satırında yatıyordu: Pos('/Length', ...), FontFile akış sözlüklerinin ISO 32000-1 Tablo 127 uyarınca taşıdığı standart bir anahtar olan /Length1 ile eşleşmişti ve okuyucu, /Length1'den sonraki tamsayıyı akış uzunluğu olarak almıştı. O belirli dosyanın yazarı, sözlük girişleri §7.3.7 uyarınca sırasız olduğundan tamamen serbest olduğu üzere, /Length1'i /Length'ten önce serileştirmişti. Akış, çöp bir bayt sayısına kırpıldı ve onu tüketen sonraki her kontrol sessizce körleşti
Alt dize eşleme neden PDF sözlüğü ayrıştırmasını bozar?
Alt dize eşleme, PDF ad alanının kasıtlı önek aileleriyle dolu olması ve sözlük girişi sırasının belirtilmemiş olması nedeniyle bozulur. ISO 32000-1 Tablo 127, aynı sözlükte bir /Length'in hemen yanında duran, gömülü yazı tipi akışları için /Length1, /Length2 ve /Length3'ü tanımlar. Şifreleme sözlüğü, fragmandaki (trailer) /Encrypt ile içindeki /EncryptMetadata'yı eşleştirer. Kısa anahtarlar daha kötüdür: Key = 'N' ile Pos('/' + Key, ...) olarak oluşturulan bir arama memnuniyetle /Name veya /Nums üzerine konar. Bu çakışmaların hiçbiri bozuk bir dosya gerektirmez. /Length1'i /Length'ten önce sıralayan bir yazar tamamen kurallara uygundur, bu da alt dize hatasının bozuk girdiye karşı bir dayanıklılık açığı değil — geçerli girdiye karşı bir doğruluk açığı olduğu anlamına gelir
Hata modu da sessiz cinstendir. Yanlış bir /Length bir istisna (exception) oluşturmaz; size akışın gerçekte işgal ettiğinden daha kısa veya daha uzun bir bayt dilimi sunar. Bu dilim bir yazı tipi alt kümesi kontrolünü, bir CMap ayrıştırmasını veya bir meta veri taramasını beslerse, tüketici çöp görür ve zlib akışının yarısı şişirilemediği (inflate) için genellikle hiçbir şey bildirmez ve kod devam eder. Tam olarak bu sınıf hatayı sevk ettik ve ISO 32000-1 §7.2–§7.3'ün madde madde yapılan denetiminden sonra, her Pos tarzı anahtar aramasını şüpheli olarak işaretleyerek paylaşılan okuyucumuzun v2.14.3 sürümünde düzelttik
ISO 32000-1 §7.3.5 bir adı gerçekte nasıl tanımlar?
Bölüm 7.3.5 kısa ve kesindir: Bir ad (name) nesnesi, bir taksim işareti (solidus) ve ardından gelen düzenli karakter dizisidir ve belirteç, ilk sınırlayıcı veya boşluk karakteriyle sonlandırılır. Sınırlayıcılar, sekiz parantez karakteri artı taksim ve yüzde işaretidir — ( ) < > [ ] { } / % — ve boşluk ise null, sekme (tab), satır besleme (line feed), form besleme (form feed), satır başı (carriage return) ve boşluktur (§7.2.2–§7.2.3). Bu sonlandırma kuralı hikayenin tamamıdır. /Length1, "arkasından 1 gelen /Length" değildir; Pascal'daki LengthOne ve Length'in farklı tanımlayıcılar olması gibi tek, bölünmez bir belirteçtir. Ham bayt aramasıyla anahtarları bulan herhangi bir okuyucu, sonlandırma kuralı silinmiş olarak sözcük analizcisini (lexer) yeniden uyguluyor demektir
İşte esaslarına indirgenmiş hatanın şekli. Bu sürüm derlenir, yazarları /Length'i ilk sıraya koyan dosyalara karşı testleri geçer ve bunu yapmayan yazarlar için akışları bozar
// YANLIŞ: /Length1, /Length2, /Length3 ile de eşleşir
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
Tam belirteç (whole-token) eşleme: Anahtardan sonraki baytı kontrol edin
Doğru yüklem doğrudan §7.3.5'ten çıkar: Aday bir eşleşme, yalnızca kendisinden hemen sonraki karakter bir sınırlayıcı, boşluk veya tamponun sonu olduğunda gerçek bir anahtardır. Geriye kalan her şey, yalnızca bir öneği paylaşan daha uzun bir addır, bu nedenle arama vazgeçmek yerine onun ötesinde devam etmelidir. Okuyucumuzdaki düzeltme, her ham Pos aramasını bu kural üzerine inşa edilmiş tek bir paylaşılan rutinle değiştirdi
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Doğru: ISO 32000-1 §7.3.5 uyarınca tam belirteç eşleme
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // belirteç burada biter: gerçek anahtar
P := PosEx(Key, Dict, P + 1); // daha uzun bir adın öneği: aramaya devam et
end;
end;
O döngüdeki iki ayrıntı ağırlık taşır. İlk olarak, ilk önek çakışmasında başarısızlık döndürmek yerine aramaya devam eder, çünkü /Length1 120 /Length 4076 yasal bir sıralamadır ve gerçek anahtar hala ileridedir. İkinci olarak, tampon sonu durumu bir sonlandırıcı sayılır, çünkü bir sözlük parçası meşru olarak bir addan hemen sonra bitebilir. Kendi kodunuzda denetlemeye değer daha ince bir nokta: Arama dizeniz taksim işaretini içermiyorsa aynı kural eşleşmenin sol tarafı için de geçerlidir, aksi takdirde Pos('Length', ...), /PieceLength içine düşebilir. Arama dizesini yukarıdaki gibi öndeki / ile sabitlemek sol kenarı işler, çünkü /'in kendisi önceki belirteci sonlandıran bir sınırlayıcıdır
Saldırgan bir PDF, bir ayrıştırıcı hatasını nasıl gigabaytlık bir bellek tahsisine dönüştürebilir?
Hatalı biçimlendirilmiş veya kötü amaçlı bir dosya, bu sözcük hatalarını kaynak tükenmesine (resource exhaustion) tırmandırır, çünkü sözlük tamsayıları sıklıkla tahsis boyutlarını besler. Denetimimiz, nesne akışı (object-stream) genişlemesinde tam olarak bu şekle sahip bir zincir buldu. Bir ObjStm sözlüğünün /N girişi, akışın kaç sıkıştırılmış nesne tuttuğunu söyler ve genişletme kodu, bununla boyutlandırılmış bir dizi üzerinde SetLength çağırdı. Ancak, tamsayı ayrıştırıcısı başarısızlık durumunda çıkış parametresine dokunmadan geri döndürdü — böylece sayısal olmayan bir /N, SetLength'e başlatılmamış (uninitialized) bir yığın (stack) değeri verdi. Oradaki çöp bir pozitif tamsayı, henüz güvenmeyi kabul etmediğiniz bir belgeyi tararken bile birkaç baytlık bozuk girdinin tetiklediği gigabaytlarca tahsis talebi anlamına gelir
Onarım iki bağımsız parçaya sahipti ve her ikisi de genelleştirilebilir. Ayrıştırıcı artık başarısızlıkta başlatılmamış bellek yerine açık bir 0 döndürüyor. Ve tüketici artık /N'e hesapsızca güvenmiyor: /First'ten önceki ObjStm başlık bölgesi, her sıkıştırılmış nesne için bir tamsayı çifti — nesne numarası ve ofset — saklar ve her çift, ayırıcılar dahil en az dört bayt kaplar. Bu nedenle, beyan edilen başlık boyutu için FirstVal div 4 + 1 üzerindeki herhangi bir /N fiziksel olarak imkansızdır ve herhangi bir tahsis yapılmadan önce reddedilir. Bu sınır tek bir karşılaştırmaya mal olur ve halihazırda eldeki verilerden türetilir, aranacak kalıp budur: İsteğe bağlı bir sabit değil, dosyanın kendisinin kanıtladığı bir tavan
// /N is saldırgan kontrollüdür; bunu /First'ün tutabileceği şeyle sınırlandırın
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // açık sıfır, asla yığın çöpü değil
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // başlık bu kadar çok çift içeremez
// /Length asla akışı içeren dosyayı aşamaz
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // tamponu tahsis etmeden önce reddet
Her ikisi de v2.12.0'da sevk edilen iki tavan okuyucumuzdaki savunma hattını tamamlar. Akış okuyucu, sonuç tamponunu tahsis etmeden önce tüm dosyadan daha büyük olan herhangi bir /Length'i reddeder — bir akış içinde yaşadığı kapsayıcıdan daha büyük olamaz, bu nedenle kontrol yanlış pozitiflerden aridir. Ve şişirme (inflate) yolu, sıkıştırılması açılmış çıktıyı 256 MiB ile sınırlar; bu da birkaç kilobaytlık girdinin sınırsızca genişlediği klasik zlib bombasını durdurur; bu tavan, en kötü durumu hayatta kalınabilir kılarken herhangi bir gerçek PDF akışı için cömerttir. Üçündeki tema da aynıdır: Bir dosyanın beyan ettiği her boyut bir iddiadır ve ayrıştırıcı, ona bellek atamadan önce ölçebileceği bir şeye karşı her iddiayı doğrular. Aynı denetim duruşu, Delphi'de PDFium ABI'sini sertleştirme ve bellek güvenliği bölümünde ele alınan, bağlama (binding) sınırının bir katman altında da geçerlidir
Tam belirteç kuralının yeterli olmadığı yerler
Dürüst sınırlar, böylece yukarıdaki rutine aşırı güvenmezsiniz. Tam belirteç eşleme, anahtar tanımlamayı düzeltir, ancak bir sözlük aralığı üzerindeki düz bir bayt araması hala bir eşleşmenin iç içe geçmiş bir sözlüğün, bir dize sabitinin veya bir yorumun içinde olup olmadığını söyleyemez — bir sayfa nesnesi üzerindeki FindDictKey, ona çok geniş bir aralık verirseniz /Resources alt sözlüğünün içindeki bir anahtara konabilir. Okuyucumuz aralığı önce tek bir nesne gövdesiyle sınırlar ve dize ile yorum bağlamlarını ayrı, hala açık bir denetim öğesi olarak ele alır. Alt dize güvenliği bir merdivenin basamaklarından biridir, tüm merdiven değil: Çapraz referans tutarlılığı, nesne ve xref akışlarını doğrulama bölümünde ele alınan kendi disiplinidir ve yazarı olmadığınız belgeler için daha geniş tehdit kataloğu PDF güvenlik risklerini denetleme bölümündedir
Delphi veya Lazarus'ta elle yazılmış bir sözlük okuyucu bulunduruyorsanız, bu olaydan çıkarılacak kontrol listesi kısadır. Kod tabanındaki her Pos('/ aramasını yapın ve isabetleri tek bir tam belirteç yardımcısına yönlendirin. Anahtarlarınızın katıldığı önek ailelerini listeleyin — gerçek dosyalarda /Length, /Encrypt, /N, /Type1'e karşı /Type hepsi görünür. Ardından SetLength, GetMem veya bir kopyalama döngüsüne ulaşan her tamsayıyı yürüyün ve onu neyin sınırladığını sorun: Dosya boyutu, başlık kaynaklı bir tavan veya hiçbir şey. Burada açıklanan ayrıştırma katmanı, bayt düzeyindeki okuyucu ile oluşturma motorunun dokundukları her belgede birbirini çapraz kontrol ettiği PDFium Bileşenimizin altındaki temeldir