Tam sayı taşması: Nasıl oluşur ve nasıl önlenebilir?

Sonraki hikaye
Rene Holt

Görünüşe aldanmayın; bilgisayarda saymak pek kolay olmayabilir. Bir sayı "çok büyüdüğünde" neler olacağını öğrenmek için okumaya devam edin.

Microsoft Exchange Server'ın şirket içi sürümlerindeki bir hatanın, başarısız bir tarih denetimi nedeniyle e-postaların yolda kalmasına neden olmasıyla BT dünyasında birçok kişi 2022 yılına kötü bir başlangıç yaptı. Basitçe ifade etmek gerekirse, daha sonra Y2K22 olarak adlandırılan bir hata (yaklaşık 25 yıl önce başlayarak dünyayı ürküten Y2K’ye benzer şekilde adlandırılmıştır) yazılımın 2022 yılı için tarih biçimini işleyememesine neden oldu. Microsoft'un çözümü neydi? Temel tam sayı türünün içerebileceği en yüksek değere ulaşılmadan önce tarih değerine yeterli "zaman" tanımak için, kötü amaçlı yazılım algılama güncellemelerinin tarihini kurgusal 33 Aralık 2021 olarak ayarlayın.

Yine de geliştiriciler için asıl sorun, kendi tarih işleme kodlarını uygulamalarıdır çünkü bu tür hatalar yapma ve kurgusal tarihler kullanacak garip düzeltmeler oluşturma riski vardır. Bu nedenle, bu rutinleri bir işletim sistemi, derleyici vb. için yazmıyorsanız her zaman standart tarih API'lerini kullanmalısınız.

2015 yılında, Boeing 787 Dreamliner jet uçağının yazılımını etkileyen benzer bir hata bulundu. Hata zamanında tespit edilip giderilmeseydi 248 gün sürekli çalışmasının ardından uçuş ortasında bile uçakta tüm AC elektrik gücünün tamamen kesilmesine neden olabilirdi. Pilotların havada uçaklarının kontrolünü kaybetmesini önlemenin yolu nedir?  787'nizin sistemini 248 gün dolmadan yeniden başlatın veya daha iyisi, düzeltme ekini uygulayın.

Öyleyse Microsoft’un neden Exchange’in kötü amaçlı yazılımdan koruma bileşeni için güncellemelerin 2021'den kalma olduğunu iddia etmesi gerekti? Bir uçağın düşmemesi için neden sisteminin kapatılıp tekrar açılması gerekiyor? Her iki durumda da suç, video oyunlarından GPS sistemleri ve havacılığa kadar her tür yazılımda endişe kaynağı olan güvenlik açığı “tam sayı taşmasında” bulundu. 2019 ve 2020'de yayınlanan yaklaşık 32.500 CVE'ye bakan 2021 CWE En Tehlikeli 25 Yazılım Zayıflığı listesinde, tam sayı taşması veya sarması on ikinci sırada yer aldı.

Yazılım geliştiricilerin matematik bilgisi, sayıların ne zaman biteceğini tahmin etmeye yetmiyor mu? Gerçekte durum daha karmaşıktır. Tam sayı taşmasının ne kadar gözden kaçabilecek bir durum olduğunu görmek için bilgisayarların sayıları nasıl depoladığına ve işlediğine biraz daha derinlemesine bakalım.

Tam sayı nedir?

Matematikte tam sayılar, 1, 2 ve 3 gibi pozitif sayıları, 0 sayısını ve -1, -2 ve -3 gibi negatif sayıları içerir. Tam sayılar, kesirleri veya ondalık sayıları içermez. Bu, tüm tam sayılar kümesinin aşağıdaki sayı doğrusuyla temsil edilebileceği anlamına gelir:

Genel olarak, bir programlama dilinde birkaç tür tam sayı değişkeni  vardır ve her biri, türün belirli bir makinede kullandığı bit sayısına bağlı olarak bir dizi tam sayı değerini depolar. Bir tam sayı türü ne kadar çok bit kullanırsa orada depolanabilecek değerler de o kadar büyük olur.

Tipik bir x64 makinesinde olabilecek bit boyutlarını dikkate alarak, C programlama dilindeki tam sayı türlerine bakalım. 

Bir karakter (char) 8 bit bellek tüketir, yani aşağıdaki değerleri depolayabilir:

Bir karakterin yalnızca minimum -128'e kadar ve maksimum 127'ye kadar değerleri depolayabileceğine dikkat edin.

Ancak, yalnızca negatif olmayan tam sayıları depolayan char tam sayı türünün başka bir "modu" daha vardır:

Tam sayı türlerinin hem işaretli hem de işaretsiz modları vardır. İşaretli türler negatif değerleri depolayabilirken, işaretsiz türler bunları depolayamaz.

Aşağıdaki tabloda, C programlama dilindeki bazı temel tam sayı türleri, bunların tipik bir x64 makinesindeki boyutları ve depolayabilecekleri değer aralığı gösterilmektedir:

Tür

Boyut (bit genişliği)

Aralık

Char (karakter)

8

işaretli: -128 ila 127

işaretsiz: 0 ila 255

short int (kısa tam sayı)

16

işaretli: -32.768 ila 32.767

işaretsiz: 0 ila 65.535

İnt (tam sayı)

32

işaretli: -2.147.483.648 ila 2.147.483.647

işaretsiz: 0 ila 4.294.967.295

long long int (uzun uzun tam sayı)

64

işaretli: -9.223.372.036.854.775.808 ila 9.223.372.036.854.775.807

işaretsiz: 0 ila 18.446.744.073.709.551.615

Tablo 1. Microsoft C++ (MSVC) derleyici araç seti için tam sayı türleri, tipik boyutları ve aralıkları

Tam sayı taşması nedir?

Bir tam sayı türü için fazla büyük olan bir değeri depolama girişiminde bulunulduğunda tam sayı taşması veya sarması meydana gelir. Bir tam sayı türünde depolanabilecek değer aralığı daha doğru şekilde, etrafı saran dairesel bir sayı doğrusu olarak temsil edilir. İşaretli bir karakter için dairesel sayı doğrusu aşağıdaki gibi gösterilebilir:

İşaretli bir karakterde 127'den büyük bir sayı depolanmaya çalışılırsa, sayı -128'e kadar sarılır ve oradan yukarıya, sıfıra doğru devam eder. Böylece, 128 olması gereken değer yerine -128 değeri, 129 yerine −127 değeri vb. depolanır.

Soruna tersten bakarsak, işaretli bir karakterde -128'den küçük bir sayı depolamaya çalışılırsa sayım 127'ye kadar sarılır ve oradan aşağı, sıfıra doğru devam eder. Bu nedenle, olması gereken −129 yerine 127 değeri, −129 yerine 126 değeri depolanır. Buna bazen tam sayı taşması denir.

Gözden kaçabilen tam sayı taşmasını yakalayabilmek

Tam sayı taşması, etrafı saran bir değer çemberi olarak düşünüldüğünde daha kolay anlaşılabilir. Bununla birlikte, tam sayı türlerini "dönüştürme" ve programları taşıma, derleme gibi işlemlerin ayrıntılara indiğimizde, tam sayı taşmalarını önlemeyle ilgili bazı zorlukları daha iyi anlayabiliriz.

Dönüştürme işlemlerinizi izleyin

Bazen bir değeri orijinal halinden farklı bir türde depolamak yararlıdır, hatta gereklidir; dönüştürme veya “tür dönüştürme” işlemi programcıların bunu yapmasına olanak sağlar. Bazı dönüştürme işlemleri güvenli, bazıları ise tam sayı taşmalarına yol açabileceği için güvenli değildir. Orijinal değerin korunması garanti söz konusu olduğunda ise dönüştürme işlemi güvenli kabul edilir.

Daha küçük (bit genişliği açısından) bir tam sayı türünde depolanan bir değeri aynı modda daha büyük bir tam sayı türüne (daha küçük işaretsiz bir türden daha büyük işaretsiz bir türe ve daha küçük işaretli bir türden daha büyük işaretli bir türe) dönüştürmek güvenli olur. Bu nedenle, işaretli bir karakterden (char) işaretli bir kısa tam sayıya (short int) dönüştürmek güvenlidir; çünkü işaretli bir kısa tam sayı (short int) işaretli bir karakterde (char) depolanabilecek tüm olası değerleri depolayacak kadar büyüktür.

İşaretli ve işaretsiz tam sayılar arasında dönüştürme yapıldığında değerlerin aynı kalması garanti edilemediğinden, tam sayı taşmaları için bu yöntem önerilmez. İşaretli bir türden işaretsiz bir türe (daha büyük olsa bile) dönüştürme yaparken, işaretsiz türler negatif değerleri depolayamadığından tam sayı taşması olabilir:

Yukarıdaki resimde kırmızı çizginin solundaki, -128'den -1'e kadar olan tüm negatif işaretli karakter (char) değerleri bir tam sayı taşmasına neden olur ve işaretsiz bir türe dönüştürüldüğünde yüksek pozitif değerler haline gelir. −128, 128 olur, −127 ise 129 olur ve bu şekilde devam eder.

Bunun aksine, işaretsiz bir türden aynı boyuttaki işaretli bir türe dönüştürme yapıldığında, işaretsiz bir türde depolanabilecek pozitif değerlerin üst yarısı, o boyuttaki işaretli bir türde depolanabilecek maksimum değeri aştığı için bir tam sayı taşması meydana gelebilir.

Yukarıdaki resimde kırmızı çizginin solundaki, 128'den 255'e kadar olan tüm yüksek pozitif işaretsiz karakter (char) değerleri, aynı boyutta işaretli bir türe dönüştürüldüğünde tam sayı taşmasına neden olur ve negatif değerlere dönüşür. 128, -128 olur, 129 ise -127 olur ve bu şekilde devam eder.

İşaretsiz bir türden daha büyük boyutlu işaretli bir türe dönüştürmek tam sayı taşması riski taşımadığından daha kabul edilebilir bir şeydir: Daha küçük ve işaretsiz bir türde depolanabilen tüm değerler daha büyük ve işaretli bir türde de depolanabilir.

Dolaylı yukarı dönüştürme: 32 bit için “önyargıya” dikkat edin

Tam sayı türlerini dolaysız dönüştürme becerisine sahip olmak yararlı olsa da derleyicilerin, operatörlerin işlenenlerini (aritmetik, ikili, Boole ve tekli) nasıl dolaylı olarak yukarı dönüştürdüğünün farkında olmak önemlidir. Bu dolaylı yukarı dönüştürme işlemleri çoğu durumda tam sayı taşmalarını önlemeye yardımcı olur çünkü bunlar işlenenleri üzerinde işlem yapmadan önce daha büyük boyutlu tam sayı türlerine yükseltirler. Aslında, temel donanım genellikle aynı tür işlenenler üzerinde işlem yapılmasını gerektirir ve derleyiciler bu amaca ulaşmak için daha küçük boyutlu işlenenleri daha büyük olanlara yükseltir.

Bununla birlikte, dolaylı yukarı dönüştürmeler için kurallar 32 bit türleri tercih eder, bu da zaman zaman programcı için beklenmedik sonuçlara yol açabilir. Kurallar bir önceliği takip eder. İlk olarak, işlenenlerden biri veya her ikisi de 64 bit tam sayı (long long int)  türündeyse, diğer işlenen zaten 1 değilse 64 bit'e yükseltilir ve sonuç 64 bitlik bir tür olur. İkinci olarak, işlenenlerden biri veya her ikisi de 32 bit tam sayı (int) türündeyse, diğer işlenen zaten 1 bir değilse, 32 bitlik bir türe yükseltilir ve sonuç 32 bitlik bir tür olur.

Ancak burada, bu modelin programcıları kolayca hataya düşürebilen bir istisnası vardır. İşlenenlerden biri veya her ikisi de 16 bitlik türler (short int) veya 8 bitlik türler (char) ise, işlenenler işlem gerçekleştirilmeden önce 32 bit'e yükseltilir ve sonuç 32 bitlik bir tür (int) olur. Bu davranışın istisnası olan operatörler yalnızca ön ve son ek artırma ve eksiltme operatörleridir (++, --), bu da örneğin 16 bitlik (short int) bir işlenenin yükseltilmediği ve sonucun da 16 bit olduğu anlamına gelir.

8 bit ve 16 bit işlenenlerin 32 bit'e "önyargılı" yükseltilmesini anlamak, tam sayı taşmalarını önlemek için kontroller yapılırken çok önemlidir. İki char veya iki short int türü eklediğinizi düşünüyorsanız yanılıyorsunuz; çünkü bunlar dolaylı olarak int türlerine yükseltilir ve bir int sonucu döndürürler. İşlem sonucunda 32 bitlik bir sonuç döndürüldüğünde, bir tam sayı taşması olup olmadığını kontrol etmeden önce sonucu 16 veya 8 bit'e düşürmek gerekir. Aksi takdirde tam sayı taşmasını tespit edememe riski vardır çünkü bir int, bir short int veya bir char tarafından işlenen olarak sağlanabilecek nispeten küçük değerlerle taşmayacaktır.

Kod taşınabilirliği ile ilgili sorunlar I – Farklı derleyiciler

Bir programın farklı mimarilerde çalışabilen farklı derlemelerini oluşturmak, yazılım tasarım süresini etkileyen önemli faktörlerden biridir. Farklı derlemeler için kötü planlanmış bir kodu yeniden yazmak sadece vakit alan, zor bir iş olmakla kalmayıp dikkatli olunmazsa tam sayı taşmasına da yol açabilir.

Farklı hedef makinelere yönelik kod oluşturmak için kullanılan derleyicilerin bir programlama dilinin standardını desteklemesi beklenir, ancak tanımlanmamış ve derleyici geliştiricilerinin kararına bırakılmış bazı uygulama ayrıntıları vardır. C'deki tam sayı türlerinin boyutları bu ayrıntılardan biridir ve C dili standardında bu konuda fazla yardımcı olacak bir şey yoktur. Bu da derleyiciniz ve hedef makineniz için uygulama ayrıntılarını iyi anlamadığınız takdirde olası bir felakete zemin hazırladığınız anlamına gelir.

Tablo 1'de açıklanan her tam sayı türü tarafından kullanılan bit sayısı, 32 bit, 64 bit ve ARM işlemcileri hedeflerken bir C derleyicisi içeren Microsoft C++ (MSVC) derleyici araç seti tarafından kullanılan şemadır. Ancak, C standardını uygulayan farklı derleyiciler farklı şemalar kullanabilir. long adında bir tam sayı türü düşünelim.

MSVC derleyicisi için, bir long, derlemenin 32 bit veya 64 bit program için olup olmadığına bakılmaksızın 32 bit kullanır. Ancak IBM XL C derleyicisi için, bir long, 32 bitlik bir derlemede 32 bit, 64 bitlik bir derlemede 64 bit kullanır. Tüm derlemelerinizde tam sayı taşmalarını doğru şekilde kontrol etmek için bir tam sayı türünün depolayabileceği boyutları ve maksimum, minimum değerleri bilmek çok önemlidir.

Kod taşınabilirliği sorunları II – Farklı derlemeler

Taşınabilirlikle ilgili dikkat edilmesi gereken diğer bir sorun, işaretsiz bir tam sayı türü olan size_t‘nin ve işaretli bir tam sayı türü olan ptrdiff_t'nin kullanılmasıdır. Bu türler, MSVC derleyicisi için 32 bitlik derlemede 32 bit, 64 bitlik derlemede 64 bit kullanır. İşlenenlerden birinin bu türlerden biri olduğu bir karşılaştırmaya dayalı olarak dallara ayrılıp ayrılmayacağına karar veren bir kod parçası; programı, 32 bit veya 64 bit bir derlemenin parçası olmasına bağlı olarak farklı yürütme yollarına yönlendirebilir.

Örneğin, 32 bitlik bir derlemede, bir ptrdiff_t ile işaretsiz bir int arasında bir karşılaştırma, derleyicinin ptrdiff_t‘yi işaretsiz bir int‘e dönüştürmesi ve böylece negatif bir değerin yüksek bir pozitif değer haline gelmesi anlamına gelir. Yani bir tam sayı taşması programda beklenmeyen bir yolun yürütülmesine veya erişim ihlaline neden olur. Ancak 64 bitlik bir derlemede, derleyici işaretsiz int‘i işaretli 64 bitlik bir türe yükseltir, yani tam sayı taşması olmaz ve programda beklenen yol yürütülür.

Tam sayı taşması nasıl arabellek taşmasına yol açar?

Bir tam sayı taşması güvenlik açığından yararlanmanın başlıca yolu, arabellekte depolanacak verilerin uzunluğunu sınırlayan denetimleri atlatarak arabellek taşmasına yol açmaktır. Bu, ayrıcalığın arttırılması ve rastgele kodların yürütülmesi gibi daha başka sorunlara yol açan, arabellek taşmasından yararlanmayla ilgili çok çeşitli tekniklere kapı açar.

Aşağıdaki kurgu örneği ele alalım:

#include
#include
#include

int MAX_BUFFER_LENGTH = 11; // [1]

char* initializeBuffer () {
char* buffer = (char*) malloc(MAX_BUFFER_LENGTH * sizeof(char));

if (buffer == NULL) {
printf("Could not allocate memory on the heap\n");
}

return buffer;
}

int main(void) {
signed int buffer_length;
char* source_buffer = "0123456789"; // Arbitrary test data
char* destination_buffer = NULL;

buffer_length = -1; // Hypothetical attacker-controlled variable
printf("buffer_length as a signed int is %d and implicitly cast to an unsigned int is %u\n", buffer_length, buffer_length);

// [2] Faulty size check
if (buffer_length > MAX_BUFFER_LENGTH) {
printf("Integer overflow detected\n");
}
else {
destination_buffer = initializeBuffer();

// [3] Potential buffer overflow due to integer overflow
strncpy(destination_buffer, source_buffer, buffer_length);
destination_buffer[buffer_length] = '\0';

printf("Destination buffer contents: %s\n", destination_buffer);
}

free(destination_buffer);

return 0;
}

C'ye alışkın değil misiniz? Bu kodu Google Colab'da bir not defteri ile tarayıcınızda çalıştırın. 

[2]'de, buffer_length‘in negatif değerleri için kontrol yoktur; bu kontrolü geçtiği anlamına gelir. Ayrıca, MAX_BUFFER_LENGTH işaretli bir int'tir, ancak [1]'de işaretsiz bir tam sayı türü olarak bildirilmelidir, çünkü arabellek uzunlukları atanırken negatif değerler asla kullanılmamalıdır. İşaretsiz bir tam sayı türü olarak, derleyici, [2]'deki kontrolde tam sayı taşmasının saptanmasını sağlayacak şekilde buffer_length‘i dolaylı olarak işaretsiz bir int'e dönüştürürdü.

Ancak, buffer_length içinde depolanan -1 kontrolü atlar ve derleyici bunu [3] konumunda initializeBuffer işlevinde dolaylı olarak işaretsiz bir int olarak dönüştür, beklenen maksimum arabellek uzunluğunun (11) oldukça ötesinde, yaklaşık 4 milyarlık yüksek bir pozitif değere taşar.

Bu tam sayı taşması daha sonra doğrudan bir arabellek taşmasına yol açar, çünkü strncpy kaynak ara bellekten hedef arabelleğe yaklaşık 4 GB verinin kopyasını oluşturmaya çalışır. Bu nedenle, [2]'deki boyut kontrolü ile bir arabellek taşması önlenmeye çalışılsa bile, kontrol yanlış yapılır ve doğrudan bir arabellek taşmasına yol açan tam sayı taşması meydana gelir.

İşaretli ve işaretsiz tam sayı türleri arasında dönüştürme yaparken, beklenen maksimum değerden daha büyük bir değer olup olmadığını görmek için boyut kontrolü yapmak yeterli değildir. Aynı zamanda, beklenen minimum değerden daha küçük bir değer olup olmadığını kontrol etmek de çok önemlidir:

// [2] Corrected size check 
if (buffer_length < 0 || buffer_length > MAX_BUFFER_LENGTH) {

Genel kurallar

Kodunuzdaki olası tam sayı taşmalarını kontrol etmek ve ele almak için çeşitli stratejiler kullanılabilir; bunların bazıları taşınabilirlik veya hızdan ödün vermeye dayanır. Burada onlardan ayrı olarak en azından aşağıdaki yönergeleri aklınızda bulundurun:

·         Mümkün olduğunca işaretsiz tam sayı türlerini kullanmayı tercih edin. Unutmayın, bellek ayırmak için işaretli bir tam sayı türü kullanmanın hiçbir anlamı yoktur, çünkü bu durumda negatif bir değer asla geçerli değildir.

·         Dolaylı dönüştürme işlemlerinin tam sayı taşmalarına neden olabileceği noktaları daha kolay görmek için tüm dönüştürme işlemlerini açıkça yazarak kodunuzu gözden geçirin ve test edin.

·         Derleyicilerinizde bulunan ve belirli türlerdeki tam sayı taşmalarını tespit etmeye yardımcı olabilen tüm seçenekleri etkinleştirin. Örneğin GCC derleyicisinde, işaretli tam sayı taşmalarını kontrol eden bir -ftrapv seçeneği vardır.

Tam sayı taşmaları, yakın zamanda ortadan kalkacak bir sorun değildir. Aslında, 2038'de ortaya çıkması “planlanan”, dolayısıyla da Y2K38 olarak adlandırılan Y2K22'ye benzer bir hataya sahip Unix benzeri birçok eski sistem vardır. 64 bit sistemler yaygın hale gelmeden önce, her yerde 32 bit sistemler hakimdi; yani Unix zamanı 32 bitlik işaretli bir tam sayı olarak depolanıyordu. Unix zamanı 1 Ocak 1970'de 00:00:00 UTC'den itibaren saniyeleri saymaya başladığından, 32 bitlik zaman, sarma işlemini yapmadan önce 19 Ocak 2038'e kadar sadece birkaç saatimizi alabilir. Neyse ki sorunu önceden bilmek, çemberin başlangıç noktasına dönüp 1901'e kaymadan, savunmasız birçok sistemi hazırlamamıza ve güncellememize izin veriyor.