Bölüm 3:
Bir Program Nasıl Çalıştırılır? GitHub'da düzenle

Ş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.

Özellikle x86-64 üzerindeki Linux’a bakacağız. Neden?

Öğ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ışı

Kullanıcının terminalde ./file.bin çalıştırmasından, execve ve SYSCALL üzerinden kernel içindeki binfmt yükleyicisine uzanan yürütme akış diyagramı.

Çok önemli bir sistem çağrısıyla başlayalım: execve. Bir programı yükler ve başarılı olursa mevcut işlemi o programla değiştirir. Birkaç sistem çağrısı (execlp, execvpe, vb.) daha mevcut, ancak hepsi çeşitli şekillerde execve‘nin üzerinde katmanlanıyor.

Bir not: execveat

execve aslında, yapılandırma açısından daha genel olan execveat syscall’ı üzerine kuruludur. Bu syscall bazı ek seçeneklerle program yürütmeye izin verir. Biz sadelik adına çoğunlukla execve üzerinden konuşacağız; pratikte fark, execve‘nin execveat için birtakım varsayılanlar belirlemesidir.

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[]);

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: Tanım

Sistem çağrılarının nasıl çalıştığını zaten biliyoruz, ancak gerçek dünyadan bir kod örneğini hiç görmedik! execve‘nin başlık altında nasıl tanımlandığını görmek için Linux çekirdeğinin kaynak koduna bakalım:

fs/exec.c
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3, 3 bağımsız değişkenli sistem çağrısının kodunu tanımlamak için kullanılan bir makrodur.

arity’nin makro adına neden sabit kodlandığını merak ettim; Google’da araştırdım ve bunun bazı güvenlik açıklarını düzeltmeye yönelik bir geçici çözüm olduğunu öğrendim.

Dosya adı argümanı, dizeyi kullanıcı alanından çekirdek alanına kopyalayan ve bazı kullanım izleme işlemleri yapan bir getname() işlevine iletilir. include/linux/fs.h içinde tanımlanan bir filename yapısını döndürür. Kullanıcı alanındaki orijinal dizeye yönelik bir işaretçinin yanı sıra, çekirdek alanına kopyalanan değere yönelik yeni bir işaretçiyi de saklar:

include/linux/fs.h
struct filename {
	const char		*name;	/* pointer to actual string */
	const __user char	*uptr;	/* original userland pointer */
	int			refcnt;
	struct audit_names	*aname;
	const char		iname[];
};

execve sistem çağrısı daha sonra bir do_execve() işlevini çağırır. Bu da bazı varsayılanlarla do_execveat_common() öğesini çağırır. Daha önce bahsettiğim execveat sistem çağrısı da do_execveat_common()‘yi çağırır ancak kullanıcı tarafından sağlanan daha fazla seçenekten geçer.

Aşağıdaki kod parçasına hem do_execve hem de do_execveat tanımlarını ekledim:

fs/exec.c
static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat(int fd, struct filename *filename,
		const char __user *const __user *__argv,
		const char __user *const __user *__envp,
		int flags)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };

	return do_execveat_common(fd, filename, argv, envp, flags);
}

execveat çağrısında bir file descriptor (bir kaynağa işaret eden kimlik türü) syscall’a ve oradan da do_execveat_common fonksiyonuna geçer. Bu descriptor, programın hangi dizine göre yürütüleceğini belirler.

execve tarafında ise file descriptor argümanı için özel bir değer kullanılır: AT_FDCWD. Bu, Linux kernel’ında path’lerin current working directory’ye göre yorumlanmasını söyleyen ortak bir sabittir. File descriptor alan fonksiyonlarda genelde if (fd == AT_FDCWD) { /* special codepath */ } benzeri açık bir kontrol görürsün.

Adım 1: Kurulum

Artık çekirdek işlev yürütme programının yürütülmesi olan do_execveat_common‘ye ulaştık. Bu işlevin ne yaptığına dair daha büyük bir resim görünümü elde etmek için koda bakmaktan kısa bir adım atacağız.

do_execveat_common‘nin ilk büyük işi linux_binprm adında bir yapı kurmaktır. Yapı tanımının tamamının bir kopyasını eklemeyeceğim, ancak incelenecek birkaç önemli alan var:

(TIL: binprm, binary program anlamına gelir.)

Bu tampon buf‘a daha yakından bakalım:

linux_binprm @ include/linux/binfmts.h
	char buf[BINPRM_BUF_SIZE];

Gördüğümüz gibi uzunluk BINPRM_BUF_SIZE sabitiyle tanımlanıyor. Kod tabanında bunu arattığında, include/uapi/linux/binfmts.h içinde şu tanıma ulaşıyorsun:

include/uapi/linux/binfmts.h
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256

Böylece çekirdek, yürütülen dosyanın açılış 256 baytını bu bellek arabelleğine yükler.

Bir yana: UAPI nedir?

Yukarıdaki yolun /uapi/ içerdiğini fark etmiş olabilirsin. Peki bu sabit neden linux_binprm yapısıyla aynı dosyada tanımlanmıyor?

UAPI, “kullanıcı alanı API’si” anlamına gelir. Bu durumda, birisinin arabellek uzunluğunun çekirdeğin genel API’sinin bir parçası olması gerektiğine karar verdiği anlamına gelir. Teorik olarak, UAPI’nin kullanıcı alanına açık olduğu her şey ve UAPI olmayan her şey çekirdek koduna özeldir.

Çekirdek ve kullanıcı alanı kodu başlangıçta tek bir karmakarışık kütle halinde bir arada mevcuttu. 2012 yılında, sürdürülebilirliği iyileştirme girişimi olarak UAPI kodu ayrı bir dizinde yeniden düzenlendi.

Adım 2: Binfmt’ler

Çekirdeğin bir sonraki büyük işi bir grup “binfmt” (ikili format) işleyicisini yinelemektir. Bu işleyiciler fs/binfmt_elf.c ve fs/binfmt_flat.c gibi dosyalarda tanımlanır. Çekirdek modülleri ayrıca havuza kendi binfmt işleyicilerini de ekleyebilir.

Her işleyici, linux_binprm yapısını alan bir load_binary() işlevini kullanıma sunar ve işleyicinin programın formatını anlayıp anlamadığını kontrol eder.

Bu genellikle arabellekte sihirli sayılar aramayı, programın başlangıcının kodunu çözmeye çalışmayı (yine ara bellekten) ve/veya dosya uzantısını kontrol etmeyi içerir. İşleyici formatı destekliyorsa programı yürütmeye hazırlar ve bir başarı kodu döndürür. Aksi halde erkenden çıkar ve bir hata kodu döndürür.

Çekirdek, başarılı olana ulaşana kadar her binfmt’nin load_binary() işlevini dener. Bazen bunlar yinelemeli olarak çalışır; örneğin, bir betiğin belirlenmiş bir yorumlayıcısı varsa ve bu yorumlayıcının kendisi de bir betikse, hiyerarşi binfmt_script > binfmt_script > binfmt_elf olabilir (burada ELF, zincirin sonunda çalıştırılabilir formattır).

Format Vurgulama: Komut Dosyaları

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:

#!/bin/bash

Ben uzun süre bunun shell tarafından ele alındığını sanmıştım. Meğer öyle değilmiş. Shebang’ler aslında kernel özelliği ve script’ler de diğer programlarla aynı syscall’lar üzerinden yürütülüyor. Bilgisayarlar gerçekten çok havalı.

fs/binfmt_script.c dosyasının, bir dosyanın #! ile başlayıp başlamadığını nasıl kontrol ettiğine bak:

load_script @ fs/binfmt_script.c
	/* 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 işleyicisi yorumlayıcı yolunu ve yoldan sonraki boşlukla ayrılmış bağımsız değişkenleri okur. Yeni bir satıra veya arabelleğin sonuna ulaştığında durur.

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.

Araştırma yaparken buffer’ın bir zamanlar 128 bayt olduğunu söyleyen kaynaklara rastladım. Sonra fark ettim ki bu uzunluk sonradan 256’ya çıkarılmış. Nedenini merak edip BINPRM_BUF_SIZE satırı için Git blame baktım. Sonuç şuydu:

Visual Studio Code içinde BINPRM_BUF_SIZE sabitinin 128'den 256'ya çıkarıldığını gösteren Git blame ekran görüntüsü; değişiklik Oleg Nesterov commitine ait.

BİLGİSAYARLAR ÇOK HARİKA.

Shebang kernel tarafından işlendiği ve tüm dosya yerine yalnızca buf içinden okunduğu için, shebang satırı her zaman buf uzunluğuyla sınırlıdır. Yani bugün kendi Linux makinenizde 256 karakterden uzun bir shebang yazarsanız, 256 karakterden sonrası dümdüz kaybolur.

Bir dosyanın ilk 256 baytının binfmt tamponuna yüklendiğini, geri kalan shebang verisinin ise yok sayıldığını gösteren diyagram.

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:

Örnek: Argümanların Yeniden Yazılması

Örnek bir execve çağrısına bakalım:

// Argümanlar: filename, argv, envp
execve("./script", [ "A", "B", "C" ], []);

Bu varsayımsal script dosyasının ilk satırı şöyle olsun:

script
#!/usr/bin/node --experimental-module

Sonuçta Node interpreter’ına giden değiştirilmiş argv şu olur:

[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]

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: Çeşitli Tercümanlar

Bir başka ilginç işleyici ise binfmt_misc. /proc/sys/fs/binfmt_misc/ adresine özel bir dosya sistemi monte ederek, kullanıcı alanı yapılandırması aracılığıyla bazı sınırlı formatları ekleme olanağını açar. Programlar, kendi işleyicilerini eklemek için bu dizindeki dosyalara özel olarak biçimlendirilmiş yazma işlemleri gerçekleştirebilir. Her konfigürasyon girişi şunları belirtir:

Bu binfmt_misc sistemi genellikle Java kurulumları tarafından kullanılır ve sınıf dosyalarını 0xCAFEBABE sihirli baytlarına göre ve JAR dosyalarını uzantılarına göre algılayacak şekilde yapılandırılmıştır. Benim özel sistemimde, Python bayt kodunu .pyc uzantısıyla algılayan ve bunu uygun işleyiciye aktaran bir işleyici yapılandırılmıştır.

Bu, program yükleyicilerinin yüksek ayrıcalıklı çekirdek kodu yazmaya gerek kalmadan kendi formatları için destek eklemelerine izin vermenin oldukça güzel bir yoludur.

Sonunda (Linkin Park Şarkısı Olan Değil)

Bir exec sistem çağrısı her zaman iki yoldan biriyle sonuçlanır:

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:

Kabuk oturumu
$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello

(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.

Bir exec syscall’ı [ENOEXEC] ile eşdeğer bir hatayla başarısız olursa, kabuk komut adını ilk argüman olarak verdiği yeni bir kabuk süreci başlatır ve kalan argümanları da bu yeni kabuğa aktarır. Çalıştırılmak istenen dosya bir metin dosyası değilse kabuk bu denemeyi atlayabilir; bu durumda hata yazdırır ve 126 çıkış kodu döndürür.

Kaynak: Shell Command Language, POSIX.1-2017

Bilgisayarlar çok havalı!

4. bölüme devam et: Bir ELF Ustasına Dönüşmek