Şimdiye kadar CPU’ların executable dosyalardan yüklenen makine kodunu nasıl yürüttüğünü, ring tabanlı güvenliğin ne olduğunu ve syscall’ların nasıl çalıştığını gördük. Bu bölümde ise Linux kernel’ının içine biraz daha dalıp programların gerçekten nasıl yüklendiğini ve çalıştırıldığını anlamaya çalışacağız.
Başlamadan önce bu bölümün en ters gelen fikrini masaya koyalım, çünkü geri kalan her şey ona dayanıyor.
Bir program çalıştırmak, sezgisel olarak bir doğum gibi görünür: yeni bir şey ortaya çıkar. Linux’ta öyle olmaz. execve yeni bir process yaratmaz; var olan process’in içini boşaltıp yerine yeni programı koyar. Aynı PID, aynı açık dosyalar, aynı kullanıcı kimliği — ama bambaşka bir kod ve bambaşka bir bellek. Bir deri değiştirme, bir doğum değil.
Bu yüzden execve başarılı olduğunda geri dönmez. Dönecek bir yer kalmamıştır; onu çağıran kod artık bellekte yoktur. Bir fonksiyonun başarısını “geri dönmemesinden” anlamak tuhaf gelir, ama mantığı tam olarak budur.
Peki o zaman yeni process nereden geliyor? Ayrı bir syscall’dan: fork. İkisi birlikte çalışır ve 16. bölümde o tarafı ele alacağız. Bu bölüm tek bir soruya odaklanıyor: kernel, elindeki dosyayı çalışan bir programa nasıl çeviriyor?
Kernel kaynak koduyla ilk kez karşılaşıyorsan: Ana anlatı boyunca ihtiyacın olan her şey düz metinle anlatılıyor. Kernel kaynağına inen kısımlar katlanmış “Derinleşme” panellerine alındı; hiçbirini açmadan bölümün tamamını anlayabilirsin. Merak edersen oradalar.
Bu bölümde neyi çözüyoruz?
execve çağrısının mevcut process’i yeni bir programla nasıl değiştirdiğini göreceğiz.
Kernel’ın dosya formatını nasıl tanıdığını ve shebang satırını nasıl ele aldığını anlayacağız.
“Terminalden çalıştırdım” ile “CPU gerçekten yeni koda atladı” arasındaki köprüyü kuracağız.
Özellikle x86-64 üzerindeki Linux’a bakacağız. Neden?
Linux, masaüstünden mobile ve sunucuya kadar geniş kullanım alanına sahip tam teşekküllü bir üretim işletim sistemidir. Üstelik açık kaynak olduğu için doğrudan kaynak koda bakarak ilerlemek çok kolaydır. Bu kitapta kernel koduna bol bol referans vereceğim.
x86-64, modern masaüstü bilgisayarların büyük kısmında kullanılan mimaridir ve pek çok kodun hedefidir. Burada göreceğimiz x86-64’e özgü ayrıntıların önemli kısmı yine de daha genel fikirlere açılır.
Öğreneceğimiz şeylerin çoğu, ayrıntılar değişse de başka işletim sistemlerine ve mimarilere de kolayca genellenebilir.
Exec Syscall’larının Temel Davranışı
Çok önemli bir syscall ile başlayalım: execve. Bir programı yükler ve başarılı olursa mevcut process’i o programla değiştirir.
Bunların yanında execl, execlp, execvp, execvpe gibi isimler de görürsün. Bunlar syscall değil, libc’nin sağladığı sarmalayıcı fonksiyonlardır; argümanları farklı biçimlerde alırlar — l tek tek liste, v vektör, pPATH üzerinden arama, e ortam değişkenlerini elle verme — ama hepsi sonunda aynı yere, execve syscall’ına iner. Kernel’ın gerçekten bildiği iki tanedir: execve ve execveat.
Bir not: execveat
Linux’ta execve ve execveat kullanıcıya görünen ayrı syscall’lardır, ama kernel içinde büyük ölçüde ortak bir yardımcı yola bağlanırlar: do_execveat_common. execveat bazı ek seçeneklerle program yürütmeye izin verir. Biz sadelik adına çoğunlukla execve üzerinden konuşacağız; pratikte fark, kernel’ın ortak helper’a hangi dosya descriptor’ı ve flag’lerle girdiğidir.
ve ne demek diye merak ediyorsan: v, argüman vektörü olan argv’yi; e ise environment vektörü olan envp’yi temsil eder. Diğer exec varyantları da farklı çağrı imzalarını son eklerle ayırt eder. execveat içindeki at ise, programın hangi konuma göre çalıştırılacağını belirtebildiği için oradadır.
execve çağrı imzası:
int execve(const char *filename, char *const argv[], char *const envp[]);
filename, çalıştırılacak programın yolunu belirtir.
argv, null-terminated bir argüman listesidir; yani son öğe null pointer’dır. C’deki main fonksiyonunda gördüğün argc değeri aslında daha sonra bu listeden hesaplanır.
envp ise programın ortam değişkenlerini taşıyan, yine null-terminated başka bir listedir. Bunlar… geleneksel olarak KEY=VALUE çiftleridir. Geleneksel olarak. Bilgisayarları seviyorum.
Küçük ama önemli bir ayrıntı: Bir programın ilk argümanının program adı olması diye bildiğimiz şey sadece bir konvansiyondur. execve bunu kendiliğinden ayarlamaz. İlk argüman, çağıran kod argv içine ne koyduysa odur; ister program adı olsun ister bambaşka bir şey.
Yine de ilginç biçimde, bazı kod yollarında execve ve çevresindeki logic, argv[0]’ın program adını temsil ettiğini varsayar. Birazdan interpreted dillerden söz ederken bunun bir örneğini göreceğiz.
Adım 0: Çağrı Kernel’a Nasıl Ulaşıyor?
execve yazdığında çalışan şey aslında libc’nin ince bir sarmalayıcısıdır. O sarmalayıcı, 2. bölümde gördüğümüz gibi argümanları register’lara yerleştirir ve mimariye özgü giriş talimatını tetikler. CPU kernel mode’a geçer, kernel da bu çağrıyı tek bir ortak yardımcıya yönlendirir.
Yol boyunca yapılan ilk iş küçük ama önemli: dosya adı user space’ten kernel space’e kopyalanır. Kernel, kullanıcının verdiği bir işaretçiye asla doğrudan güvenmez; kullanıcı o belleği çağrının ortasında değiştirebilir ya da geçersiz bir adres verebilir. Kopyalamak, kontrol edilen değerin sonradan değişmemesini garanti eder. Bu “önce kopyala, sonra doğrula” refleksi kernel kodunun her yerinde karşına çıkar ve bir güvenlik açığı sınıfının tamamını kapatır.
Adım 1: Kurulum
Kernel artık ortak yürütme yolundadır ve ilk işi bir çalışma dosyası hazırlamaktır: linux_binprm adlı bir yapı. Adı binary program’ın kısaltmasıdır ve bu syscall boyunca “yeni program hakkında bildiğimiz her şey” onun içinde taşınır.
İçindeki alanlar bölümün geri kalanının haritası gibidir:
Yeni bir bellek düzeni. Henüz kurulmamış, ama yeri ayrılmıştır. Eski programın belleği hâlâ ayakta; ikisi bir süre yan yana durur.
argc ve envc. Argümanlar ve ortam değişkenleri sayılıp kernel tarafına kopyalanır, çünkü birazdan eski adres alanı yok olacaktır ve onları orada bırakmak olmaz.
filename ve interp. Başlangıçta ikisi de aynı dosyayı gösterir. Bir Python betiği çalıştırdığında filename betiğin kendisi olarak kalır ama interp yorumlayıcının yoluna kayar. Bu iki alanın ayrılabiliyor olması, birazdan göreceğimiz shebang mekanizmasının tamamını mümkün kılan şeydir.
256 baytlık bir tampon. Dosyanın ilk 256 baytı buraya okunur.
Son madde bu bölümün en verimli ayrıntısı, o yüzden üstünde duralım. Kernel, bir dosyanın ne olduğunu anlamak için dosyanın tamamını okumaz. Baştan sabit sayıda bayt alır ve kararını yalnızca ona bakarak verir. Bu, hem hızlıdır hem de kötü niyetli bir dosyanın kernel’ı keyfi miktarda okumaya zorlamasını engeller — ama birazdan göreceğin gibi kendi tuhaf sonuçları vardır.
Bir dürüstlük notu: execve’nin “başarısız olursa eski program devam eder” sözü tam değildir. Kernel uygun bir format bulamazsa, yetki yetmezse veya hazırlık erken bozulursa gerçekten hata döner ve hiçbir şey olmamış gibi devam edilir. Ama bir noktadan sonra eski adres alanı sökülür; buna point of no return denir ve ondan sonra ortaya çıkan bir hata artık geri dönülebilir değildir. Process sonlandırılır. Günlük mental modelde “başarırsa dönmez, başarısız olursa hata verir” yeterlidir; gerçekte arada küçük bir gri şerit vardır.
Adım 2: Binfmt’ler
Kurulum bitti; şimdi kernel’ın asıl sorusu geliyor: elimdeki bu dosya ne?
Kernel bunu tek bir yerden bilmez. Bunun yerine, her biri yalnızca tek bir dosya biçiminden anlayan bir handler listesi tutar ve dosyayı sırayla hepsine gösterir. Bu handler’lara binfmt denir (binary format). Her biri kernel kaynağında kendi dosyasında yaşar: ELF için fs/binfmt_elf.c, gömülü sistemlerde kullanılan düz formatlar için fs/binfmt_flat.c, birazdan uzun uzun konuşacağımız shebang için fs/binfmt_script.c. Liste sabit de değildir; yüklenen bir kernel modülü havuza kendi binfmt’ini ekleyebilir.
Her binfmt, load_binary() adında tek bir fonksiyon dışa açar. Bu fonksiyon linux_binprm yapısını alır ve tek bir soruya cevap verir: bu dosya benim tanıdığım format mı?
Kontrolün en yaygın biçimi çok basittir. Handler, buffer’ın ilk birkaç baytına bakar ve kendi imzasını arar. Bu imzaya magic number denir — bir dosyanın en başına vurulmuş kimlik damgası gibi düşün. ELF dosyaları 0x7F baytıyla ve ardından gelen E, L, F harfleriyle başlar; binfmt_script ise yalnızca # ve ! baytlarına bakar. Bazı handler’lar bunun yerine dosyanın başındaki başlığı çözmeye çalışır ya da dosya uzantısına bakar.
Tanıdıysa programı çalıştırmaya hazırlar ve başarı döner. Tanımadıysa hiçbir şeye dokunmadan geri çekilir ve ENOEXEC döner — yani “bu benim işim değil, sıradakine sor”.
Kernel bu listeyi biri başarılı olana kadar dolaşır. Zincir bazen kendi üzerine de katlanır: bir script’in yorumlayıcısı varsa ve o yorumlayıcı da bir script’se sıra binfmt_script → binfmt_script → binfmt_elf şeklinde ilerleyebilir. Dikkat et, zincirin sonunda her zaman gerçek bir binary format duruyor; script bir yerde durup asıl makine koduna teslim olmak zorunda.
Format Vurgulama: Script’ler
Linux’un desteklediği pek çok formattan binfmt_script özellikle bahsetmek istediğim ilk format.
Hiç shebang satırı gördün mü? Hani bazı script’lerin en başında interpreter yolunu söyleyen şu satır:
1
#!/bin/bash
Buradaki en yaygın yanılgı, shebang satırını shell’in okuduğunu sanmaktır. Okumaz. Shebang bir kernel özelliğidir; bir script, terminalden mi yoksa bambaşka bir programın içinden mi çağrıldığından bağımsız olarak, tam olarak derlenmiş bir binary ile aynı syscall üzerinden yürütülür. Shell’i tamamen devre dışı bıraksan bile #! satırı çalışmaya devam eder.
fs/binfmt_script.c dosyasının, bir dosyanın #! ile başlayıp başlamadığını nasıl kontrol ettiğine bak:
/* Not ours to exec if we don't start with "#!". */ if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!')) return -ENOEXEC;
Dosya bir shebang ile başlıyorsa binfmt_script, ilk boşluğa kadar olan kısmı yorumlayıcının yolu olarak alır. Kalan kısmın tamamını ise — içinde kaç tane boşluk olursa olsun — tek bir argüman olarak yorumlayıcıya geçer. Okuma, yeni satıra ya da buffer’ın sonuna gelindiğinde durur.
Bu ayrım hayatında en az bir kez başını ağrıtacak. #!/usr/bin/env python3 -u yazdığında env programı python3 ve -u diye iki argüman almaz; "python3 -u" diye tek parça bir metin alır, bu isimde bir program bulamaz ve script çalışmaz. (GNU coreutils 8.30 ve sonrasında gelen env -S bayrağı, o satırı senin adına parçalamak için vardır.)
Burada iki ilginç, riskli şey oluyor.
Birincisi, linux_binprm içindeki ve dosyanın ilk 256 baytıyla doldurulan buffer’ı hatırlıyor musun? Yürütülebilir formatı tespit etmek için kullanılan bu aynı buffer, binfmt_script içinde shebang satırlarını okumak için de kullanılıyor.
Bu buffer her zaman 256 bayt değildi; uzun süre 128 bayttı. BINPRM_BUF_SIZE satırının Git blame kaydına bakarsan sınırın neden ikiye katlandığını tek bir commit’te bulursun:
Yani bugün shebang satırına sığdırabildiğin yol uzunluğu, birinin gerçek bir hata raporunun peşine düşüp bu sabiti ikiye katlamasının sonucu. Kernel’daki pek çok sayı böyledir: teoriden değil, birinin canını yakan somut bir olaydan gelir.
Shebang kernel tarafından işlendiği ve tüm dosya yerine yalnızca buf içinden okunduğu için, interpreter satırı sabit bir üst sınıra takılır. Linux 5.1’den beri #! sonrasında okunan metin sınırı 255 karakterdir; daha eski kernel’larda bu sınır 127 karakterdi. BINPRM_BUF_SIZE = 256 teknik detayı buradan gelir. Satır bu sınıra sığmazsa fazla kısım yok sayılır; kernel yarım kalan yolu/argümanı denediği için sonuç çoğu zaman ENOENT veya benzeri bir execve hatasıdır — veri sadece kaybolup program sessizce doğru çalışmaya devam etmez.
Böyle bir bug yaşadığını düşün. Kodunu bozan şeyin kök nedenini arıyorsun. Sonra problemin, Linux kernel’ının derinliklerinde duran bir buffer uzunluğu sınırı olduğunu öğreniyorsun. Büyük kurumsal path’lerde bir kısmın gizemli biçimde silindiğini fark eden bir sonraki BT çalışanına şimdiden sabır diliyorum.
İkinci riskli şey: Az önce argv[0]’ın program adı olmasının sadece bir konvansiyon olduğunu ve çağıranın istediği argv’yi verebileceğini konuşmuştuk ya? İşte binfmt_script, argv[0]’ın program adı olduğunu varsayan yerlerden biri.
Bu handler, önce argv[0]’ı siler ve ardından argv’nin başına şunları ekler:
argv güncellendikten sonra handler, linux_binprm.interp değerini interpreter yoluna ayarlayarak yürütme hazırlığını tamamlar. Son olarak programın başarıyla hazırlandığını göstermek için 0 döndürür.
Format Vurgulama: ELF
Zincirin sonunda neredeyse her zaman aynı handler duruyor: binfmt_elf. Linux’ta çalıştırdığın hemen her derlenmiş program — ls’ten tarayıcına kadar — ELF formatındadır.
Tanıma adımı, biraz önce anlattığım şablonun ders kitabı örneği. binfmt_elf, buffer’ın ilk dört baytına bakar ve şunu arar: 0x7F, ardından E, L, F harfleri. Kendi gözünle görmek istersen:
Shell oturumu
$ head -c 4 /bin/ls | xxd00000000: 7f45 4c46 .ELF
O 7f baytının neden orada olduğu bile düşünülmüş: yazdırılabilir bir karakter değildir, dolayısıyla bir ELF dosyasını yanlışlıkla metin olarak açan bir program onu hemen ikili dosya olarak tanır.
İmza tutunca binfmt_elf işi devralır ve asıl ağır iş başlar: dosyanın hangi parçasının belleğin neresine yerleşeceğini okumak, o parçaları yeni adres alanına haritalamak, gerekiyorsa dinamik linker’ı devreye sokmak ve nihayet CPU’yu programın giriş noktasına atlatmak.
O ağır işin tamamı 12. bölümün konusu; ELF’in içini oraya bırakıyorum. Bu bölüm açısından önemli olan tek şey şu: kernel dosyayı tanıdı ve sorumluluğu doğru handler’a devretti.
Format Vurgulama: Çeşitli Yorumlayıcılar
Son olarak bir tanesinden daha söz edeceğim, çünkü diğerlerinden farklı bir şey yapıyor: binfmt_misc.
Buraya kadar gördüğümüz her binfmt kernel’ın içinde C ile yazılmıştı; yeni bir format desteklemek istiyorsan kernel’a kod yazman gerekiyordu. binfmt_misc bu kapıyı user space’e açar: hangi dosyanın hangi yorumlayıcıya gideceğini, kernel’a tek satır kod eklemeden sen tarif edersin. Kernel /proc/sys/fs/binfmt_misc/ altına küçük bir dosya sistemi mount eder ve oraya belirli bir biçimde yazdığın her satır yeni bir format kaydı olur.
Her kayıt üç şeyi söyler:
Format nasıl tanınacak. Belirli bir konumdaki bir magic number ya da aranacak bir dosya uzantısı.
Hangi yorumlayıcı çalıştırılacak. Yorumlayıcıya argüman geçirmenin bir yolu yoktur; gerekiyorsa araya bir sarmalayıcı script koyman gerekir.
Birkaç yapılandırma bayrağı. Bunlardan biri binfmt_misc’in argv’yi nasıl kuracağını belirler.
Bu binfmt_misc sistemi geçmişte Java class/JAR dosyaları gibi formatlar için kullanılabildiği gibi, bugün pratikte QEMU user emulation ve container/multi-arch geliştirme akışlarında da sık karşına çıkar: örneğin ARM için derlenmiş bir binary’yi x86-64 makinede uygun QEMU interpreter’ına otomatik yönlendirmek mümkün olur. Bazı sistemlerde özel bytecode, .pyc benzeri dosyalar veya kurum içi executable formatları da bu yolla bir kullanıcı alanı yorumlayıcısına aktarılabilir.
Buradaki tasarım fikri hoşuma gidiyor: kernel, kendisine yeni format öğretme yetkisini dışarı veriyor ama bunu ayrıcalıklı kod çalıştırmadan yapıyor. Yeni bir format desteklemek için artık kernel geliştiricisi olman gerekmiyor.
Zincirin Sonu
Bir exec syscall’ı pratikte iki yoldan biriyle biter:
Belki birkaç kat script yorumlayıcısından geçtikten sonra, kernel tanıdığı bir binary formata ulaşır ve o kodu çalıştırır. İşte tam bu anda eski program artık yoktur; yerini yenisi almıştır.
Ya da kernel elindeki bütün handler’ları dener, hiçbiri bu dosyayı tanımaz ve çağıran programa eli boş döner: bir hata kodu.
Nadir kernel-içi hata yollarında point of no return sonrası eski programa dönülemeyeceğini az önce not etmiştik; bu ayrıntı günlük mental modelde değil, doğruluk payı olarak aklının köşesinde dursun.
Unix benzeri bir sistem kullandıysan, terminalden çalıştırılan shell script’lerin bazen ne shebang ne de .sh uzantısı olmadan yine de yürütüldüğünü fark etmiş olabilirsin. Elinin altında Unix benzeri bir terminal varsa hemen deneyebilirsin:
(chmod +x, işletim sistemine dosyanın çalıştırılabilir olduğunu söyler. Bunu yapmazsan dosyayı yürütemezsin.)
Peki shell script neden shell script olarak çalışıyor? Kernel’ın format handler’larının, üzerinde açık bir etiket olmayan shell script’i güvenilir biçimde tanıması mümkün görünmüyor.
Çünkü bu davranış aslında kernel’ın işi değil. Bu, shell tarafında başarısız bir exec çağrısını ele almanın yaygın yolu.
Bir dosyayı shell üzerinden çalıştırdığında exec syscall’ı başarısız olursa, çoğu shell dosyayı yeniden denemek için bu kez bir shell process’i başlatır ve dosya adını ona ilk argüman olarak verir. Bash genelde kendi kendisini interpreter olarak kullanır; ZSH ise çoğu zaman sh’in işaret ettiği şeyi, yani genellikle Bourne shell’i çağırır.
Bu davranış o kadar yaygındır ki, Unix sistemleri arasında taşınabilirliği hedefleyen eski standartlardan biri olan POSIX’te bile yer alır. POSIX bugün her araç ve sistem tarafından birebir takip edilmese de, pek çok davranış hâlâ onun izini taşır. Yine de hangi shell’in hangi interpreter’ı seçtiği ve binary olmayan dosyaya ne kadar tolerans gösterdiği implementation’a göre değişebilir.
Bir exec syscall’ı [ENOEXEC] ile eşdeğer bir hatayla başarısız olursa, shell komut adını ilk argüman olarak verdiği yeni bir shell process’i başlatır ve kalan argümanları da bu yeni shell’e aktarır. Çalıştırılmak istenen dosya bir metin dosyası değilse shell bu denemeyi atlayabilir; bu durumda hata yazdırır ve 126 çıkış kodu döndürür.
Yani terminalde gördüğün o basit davranışın arkasında iki ayrı katman var: kernel “bu formatı tanımıyorum” diyor, shell de bu cevabı kesin bir hayır olarak değil, bir öneri gibi okuyup dosyayı kendisi çalıştırmayı deniyor.
Özet
Peki, ne öğrendik?
Bir programı çalıştırmanın tek yolu execve ailesidir. Yeni bir process kurmaz; mevcut process’in kimliğini değiştirir.
Başarılı bir execvedönmez, çünkü dönecek program artık yoktur.
Kernel dosyanın ne olduğunu anlamak için yalnızca ilk 256 baytına bakar ve bunu sırayla her binfmt handler’ına sorar.
Tanıma çoğu zaman bir magic number kontrolüdür: ELF için 0x7F ELF, betikler için #!.
Shebang satırı bir kernel özelliğidir, shell özelliği değil. Kernel yorumlayıcıyı bulur ve asıl dosyayı ona argüman olarak verir.
binfmt_misc ile kernel’a yeni format tanıtılabilir; .exe dosyalarının Linux’ta çift tıklamayla çalışması bu sayededir.
Peki terminalde ./program yazdığında bu execve çağrısını kim yapıyor? ls, cat, echo gibi komutlar neden doğrudan çalışıyor da kendi programının başına ./ koymak gerekiyor? Sıradaki bölümde, kullanıcı ile kernel arasındaki ilk elçiyi tanıyacağız: shell.