// CLASSIFIED // OFFENSIVE OPS // YETKİLİ PERSONEL DIŞINA ÇIKMAZ //
SYS ONLINE
SES --------
UPLINK SECURE
COORD --.----°N ---.----°W
----.--.--
--:--:-- UTC
MUCAHIC
// Offensive Operations Division, EST. 2026
ANATOMI / DEEP-DIVE BLOG · NOTLAR CVE-2024-6387 OpenSSH async-signal-safety glibc

regreSSHion Anatomisi:
OpenSSH'nin SIGALRM Bug'ını Anlamak

Bu bir "exploit yapıyoruz" yazısı değil, bir anatomik analiz. 2024'te Qualys'in yayınladığı CVE-2024-6387 (regreSSHion), OpenSSH'nin içinde 20 yıldır uyuyan bir signal handler race condition. Tam silah haline getirmek için 10,000+ deneme gerek; asıl ilginç olan kısım bug'ın kendisi nasıl çalışıyor. Bu yazıda SIGALRM neden tehlikeli, async-signal-safe ne demek, glibc'in malloc'u kendi içine girince neden patlar, ve 2006'da kapatılan bir bug 2020'de hangi commit'le geri döndü, hepsini kafanı kırmadan anlatmaya çalışacağım.

Operatör
OP-0042 / MUCAHIC
Yayın
2026.03.15
Okuma
~ 18 DK
Dosya ID
MU-009-REGSSH

Yıllar önce internete bağlanırken telnet kullanırdık, her şey clear-text, parolan, komutların, hayatın. Sonra OpenSSH geldi ve "merak etme, biz şifreliyoruz" dedi. O kadar iyi iş çıkardı ki sektörün geri kalanı durup baktı: belki başka bir şeye gerek yok, sadece bunu üstüne koyalım. Bugün milyonlarca sunucuda sshd tek başına nöbet tutuyor. Çoğumuz "SSH'ye saldırı geçmez" diye düşünür.

2024'ün Temmuz'unda Qualys o varsayımı paramparça etti. CVE-2024-6387 / regreSSHion, OpenSSH'nin içinde 20 yıldır uyuyan, 2006'da bir kez kapatılıp 2020'de yanlışlıkla geri açılmış bir signal handler race condition. Auth gerekmiyor. Teorik olarak tek bir bağlantıyla unauth root RCE. Hedef sayısı? 14 milyon internet-erişimli sshd sunucusu.

Bu yazı bir "exploit yapıyoruz" yazısı değil. Çünkü tam silah haline getirmek için heap massage + 10,000+ race denemesi gerek; o işin public PoC'leri zaten ortada. Benim bu yazıyla yapmak istediğim şey bug'ın kendisini parçalamak: SIGALRM nedir, signal handler içinde ne yapılıyor da bu kadar kritik bir bug çıkıyor, glibc'in malloc'u neden kendi kuyruğuna basıyor, ve 56 mikrosaniyelik bir pencerede saldırgan tam olarak ne hayal ediyor. Bu yazıyla amaçladığım o boşa bakan kavramları somut bir şekilde göstermek.

sshd Adındaki Sessiz Devler

Ofiste çalışan bir bilgisayarda ssh komutuyla başka bir makineye giriyorsun. Bu o kadar sıradan ki kimse durup düşünmüyor: karşı tarafta bir program var ve o program seninle senin parolanı bilmeden tanışıp, sonra parolanı kontrol ediyor. O program sshd, SSH daemon. Linux'ta, BSD'lerde, IoT cihazlarında, router'larda, NAS'larda, neredeyse her yerde aynı program koşuyor.

İnternete açık olan bütün makinelerde sshd kelimenin tam anlamıyla ön kapıda. SSH portunu açık tutmak istemiyorsan VPN arkasına saklarsın; ama VPN olmayan, anlık erişim isteyen, otomasyonla bağlanan binlerce sistem var. Hepsi sshd'yi orada bırakıyor.

Bu yüzden 2024 Temmuz'unda Qualys'in raporu bu kadar yankı buldu. Onlar sadece şunu söylediler: "Auth olmadan, hiçbir şey bilmeden, tek TCP bağlantısıyla root elde edebiliyoruz." Saldırının kendisi kolay değil, race condition'ı yakalamak için saatler, hatta günler gerekebiliyor. Ama bunu önemsiz yapmıyor. Hedef sayısı önemsiz yapamayacak kadar fazla.

Lobi Resepsiyonisti: sshd Nasıl Çalışıyor?

Bug'ı anlamak için önce sshd'nin nasıl bir yapı olduğunu görmen gerek. Şöyle düşün: sshd bir otel lobisi gibi. Tek bir ana process (listener) lobinin orta direği gibi durur, 22 numaralı kapıda bekler. Birisi geldiğinde, yeni TCP bağlantısı, lobide oturan ana process fork() diyor. Yani kendinin bir kopyasını yaratıp "sen bu müşteriyle ilgilen, ben kapıda kalayım" diyor.

Her TCP bağlantısı için ayrı bir child process yaratılıyor. Bu, basit ama akıllı bir tasarım: çocuk process patlayıp ölse bile lobi (ana sshd) ayakta kalıyor, yeni müşteri kabul etmeye devam ediyor.

sshd yaşam döngüsü: listener fork eder, child process LoginGraceTime ile alarm kurulur
// sshd lifecycle, listener fork eder, child grace timer ile yaşar

Çocuk process'in tek bir işi var: müşteriyi auth ettirmek. Banner gönder, anahtar değişimi yap (KEX), kullanıcıdan parola veya key bekle, doğru ise shell ver, yanlış ise kapıyı göster. Bu süreç ne kadar sürer? Belirsiz. Müşteri olmayabilir bile, sadece TCP açıp kaçmıştır. Yahut sürekli yanlış parola deniyor olabilir.

İşte bu yüzden sshd'nin elinde bir kum saati var: LoginGraceTime. Default'u 120 saniye. "Sen 2 dakikada işini halletmezsen kafam karışır, child process'i öldürürüm" diyor. Bunu yapmak için kernel'a "bu çocuk process'te 120 saniye sonra alarm çal" diyor, teknik karşılığı alarm(120) system call'u. 120 saniye doldu mu? Kernel SIGALRM sinyalini gönderir, sshd onu yakalar, "evet, vakit doldu" der ve child'ı temizler.

// not: SIGALRM bilgisayar dilinde "telefon zili" gibidir. Async olarak, yani senin o anda ne yapıyor olduğuna bakmadan kapıyı çalar. Sen tam yemek yiyor da olabilirsin, banyoda da olabilirsin, mutfakta bıçakla bir şey doğruyor da olabilirsin. Telefon zili çalmaz mı? Çalar. Bu yazının kalbi buradan başlıyor.

async-signal-safe Tam Olarak Ne Demek?

Şimdi bilgisayar bilimi dünyasına geliyoruz ama söz veriyorum karmaşık olmayacak. Bir analoji ile başlayayım.

Sen elinde altı tane labutla hokkabazlık yapıyorsun diyelim. Tam bir labutu havaya atmışken telefonun çaldı. Birinci instinct: cep telefonunu eline al. Ama o saniyede altı labut da uçuyor, eğer ellerden birini ayırırsan dengeyi kaybedersin. Yapılabilecek şey çok sınırlı: ya labutlardan birini sabit bir yere parka park edebilirsin (eğer bir yerde duruyorsa), ya da telefonu görmezden gelirsin. Telefonu açmak tehlikeli.

Bilgisayarda da aynı durum var. Bir program çalışırken bir sürü iç işi yapıyor: bellekten yer ayırıyor, ayırırken kendi tabloları olan veriyi (free-list, lock'lar, internal counter'lar) güncelliyor. Bu işlerin ortasında signal gelirse, signal handler içinde tekrar aynı bellek tablolarına dokunmak ölümcül olur. Tablo yarım güncellenmiş halde, bir başka kod giriyor, yapısı bozuluyor.

Bu yüzden POSIX standardı (Unix-benzeri sistemlerin anayasası) der ki: signal handler içinde ancak listede olan fonksiyonları çağırabilirsin. Bu listeye async-signal-safe fonksiyonlar denir. Liste kısa ve sıkıcı: read(), write(), _exit(), kill(), sigaction() gibi yaklaşık 70 fonksiyon. Sayılı.

Listede OLMAYAN şeyler? İşin gözünü boyayan kısım burası:

Şimdi sana neden anlattığımı söyleyeyim: OpenSSH'nin SIGALRM handler'ı içinde syslog() çağırıyordu. Yani hokkabaz labutu havadayken telefonu açmaya çalışıyordu. Yıllar önce de bu yapılmış ve fark edilmişti (CVE-2006-5051), kapatılmıştı. Sonra 2020'de bir kod temizliği sırasında geri açıldı. Kimse fark etmedi. 4 yıl bekledi.

regreSSHion'un Anatomisi: Adım Adım

Şimdi tam olarak ne oluyor ona bakalım. Saldırgan TCP bağlantısı açıyor, 22 portuna. sshd ana process bir fork() çekiyor ve child process'i doğuruyor. Child doğar doğmaz iki şey yapıyor:

Saldırgan ne yapıyor? Hiçbir şey. Banner'ı alıyor ama auth'a başlamıyor. TCP'yi açık tutuyor. 119 saniye boyunca öyle. Sanki ekranın başında uyuyakalmış bir kullanıcı gibi. Tam 120. saniyenin son ms'lerinde, örneğin t=119.999600 civarında, bir tane kötü biçimli (malformed) paket gönderiyor. Child o anda paketi parse etmek için malloc() çağırıyor: yeni gelen veri için heap'ten bir buffer ister.

Tam o saniyede kernel'in eli yetişiyor: SIGALRM! 120 saniye doldu. sshd'nin signal handler'ı çağrılıyor. Handler içinde ne yapıyor? "Vakit doldu, log yazayım." Kim ne yazar? syslog(). syslog() ne yapar? Buffer için malloc() ister.

Ama biz zaten malloc'tayız.

SIGALRM çağrı zinciri: alarm -> SIGALRM -> sshd_signal_handler -> sigdie -> syslog -> malloc -> reentrance
// SIGALRM çağrı zinciri, her kutu bir önceki kutunun içinden çağrılıyor

İşte burası kıyamet. glibc'in malloc'u iç tablolarını tutar, tcache, fastbins, unsortedbin denilen free-list yapıları. Ana process malloc'un içinde bu tabloları güncellerken, signal geliyor, signal handler bir kez daha malloc çağırıyor. Aynı tablolar bu sefer yarım güncellenmiş halde değiştiriliyor. Sonuç: tablolar bozuluyor. Free-list pointer'ları artık geçerli adresleri göstermiyor.

Saldırganın işi şudur: TCP bağlantısı sırasında öyle veri gönder ki, glibc'in heap'inde tam istediğin gibi blokların olsun, heap massage denen sanatın klasik adımları. Sonra race kazanılınca o bozulmuş free-list, saldırganın yazdığı bir adresi yeni malloc sonucu olarak döndürür. Saldırgan kendi shellcode'unu o adrese yazmıştı. Child process EIP/RIP'i o adrese sıçradığı an: root shell.

Race window zoom: server malloc içindeyken client tam o anda fire ediyor
// Race window, son 1000 mikrosaniyeyi 1000 kat genişlettim, son 56μs kritik bölge

Bütün bu olay 56 mikrosaniye içinde geçiyor. Saniyenin milyonda birinde, doğru anda, doğru pakete ihtiyaç var. Bu yüzden çoğu saldırı denemesi başarısız: SIGALRM bazen malloc'un dışında geliyor, bazen child paket ulaşmadan sinyali alıyor. Qualys 64-bit Linux'ta 10,000 deneme ortalaması verdi, ortalama 6-8 saat.

20 Yıl Uyuyan Bug ve Adının Hikayesi

İlk versiyon bu açığın kendisi 2006'da bulundu, CVE-2006-5051. O zaman da aynı şeydi: signal handler'da syslog. OpenSSH ekibi sessizce patch'ledi, 4.4p1 sürümüne katıldı. Yıllar geçti. 2020'de OpenSSH'de bir kod temizliği yapıldı, DEBUG_SYSLOG macro'su değiştirildi, signal handler'a giden bir compile-time guard silindi. Sonuç: 2006'da çözülen bug, kimse fark etmeden geri geldi. 4 yıl boyunca her yeni OpenSSH sürümünde mevcuttu.

Qualys'in araştırmacıları 2024'te modern Linux dağıtımlarındaki sshd'leri incelerken bunu yakaladılar. Bug'a verilen ad öyle güzel ki klasik olacak: regreSSHion. "Regression in SSH", SSH'de regresyon. Bilgisayar bilimi mizahında, daha önce kapatılmış bir bug'ın yanlışlıkla tekrar açılmasına "regression" denir.

Etkilenen sürümler şu şekilde:

OpenBSD bu hikayenin dışında kalıyor, OpenBSD 2001'den beri farklı bir signal handler mekanizması kullanıyor, async-signal-safe ihlali OpenBSD'de zaten engellenmiş durumda. Yani vulnerable sadece glibc tabanlı Linux sistemleri. Ki bu zaten internet sunucularının çok büyük çoğunluğu.

Lab Kurulumu ve PoC

Şimdi pratiğe geçelim. Bir Docker container içinde kaynaktan derlenmiş OpenSSH 9.6p1 koşacak, bu sürüm regresyon penceresinin (8.5p1 → 9.7p1) tam ortasında. Hedef: race window'a girip child process'i syslog → malloc içindeyken yakalamak ve segfault üretmek. Tam exploit chain (heap massage + tcache poisoning + shellcode) yapmıyoruz, bunun public PoC'leri var, yazının amacı root cause'u anlatmak.

Önemli detay: distro paketleri kullanılamıyor. Ubuntu 22.04 ve Debian 12'nin apt repo'larındaki openssh-server paketlerine bu CVE'nin backport patch'i girmiş durumda; apt install openssh-server dediğin anda zaten yamalı sürümü alıyorsun. Bu yüzden Dockerfile'da OpenSSH'nin orijinal release tarball'ını çekip ./configure && make install ile build ediyorum:

# Dockerfile (ozet)
FROM ubuntu:22.04
ENV OPENSSH_VERSION=9.6p1
RUN apt-get update && apt-get install -y \
    wget build-essential zlib1g-dev libssl-dev libpam0g-dev \
    gdb gcc strace tcpdump python3 rsyslog
WORKDIR /tmp/build
RUN wget -q https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-${OPENSSH_VERSION}.tar.gz \
    && tar xf openssh-${OPENSSH_VERSION}.tar.gz \
    && cd openssh-${OPENSSH_VERSION} \
    && ./configure --prefix=/opt/sshd-vuln --with-pam \
    && make -j$(nproc) && make install
RUN useradd -r -M sshd && mkdir -p /var/empty
EXPOSE 2222
CMD ["/opt/sshd-vuln/sbin/sshd", "-D", "-e", "-f", "/opt/sshd-vuln/etc/sshd_config"]

# build & run
$ docker build -t regresshion-lab .
$ docker run -d --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
    -p 2222:2222 --name regresshion regresshion-lab

-e bayrağı sshd'nin stderr'e tüm child crash'lerini yazdırması için. SYS_PTRACE da gdb attach etmek için. useradd -r -M sshd kritik, OpenSSH privilege separation için sshd adlı sistem user'ı arar, yoksa "Privilege separation user sshd does not exist" diyerek hemen ölür.

Container ayağa kalkınca önce banner yakalayalım, sunucunun gerçekten vulnerable aralığında olduğunu doğrulamak için. banner_scan.py'yi yazdım, OpenSSH version'u parse edip 4 kategoriden (legacy, safe, VULNERABLE, patched) birini etiketliyor:

$ python3 banner_scan.py 127.0.0.1 --port 2222
127.0.0.1:2222  VULNERABLE (regreSSHion)
  banner: SSH-2.0-OpenSSH_9.6

Sıra race_probe.py'de. Bu script tam exploit değil, "race window'a vardığımızı" gösteren bir teaching PoC. Her denemede şunu yapıyor:

Default LoginGraceTime 120 ile her deneme 2 dakika sürer. Demo için 8 saniyeye düşürdüm (mantık aynı, yarış yine 800μs penceresi):

$ python3 race_probe.py 127.0.0.1 2222 --grace 8.0 --eps 0.0008 --attempts 10
[*] target = 127.0.0.1:2222
[*] race window = 800 microseconds before grace expiry
[*] attempts    = 10

[0001] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0002] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0003] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0004] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0005] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0006] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0007] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0008] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0009] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'
[0010] grace+race fired (elapsed=  8.0s)  banner='SSH-2.0-OpenSSH_9.6\r'

[*] done. wall time = 80.2s

10 deneme, ~80 saniye. Şimdi server tarafına bakalım, docker logs regresshion:

$ docker logs --tail 30 regresshion
Server listening on 0.0.0.0 port 2222.
Server listening on :: port 2222.

Connection from 172.17.0.1 port 44130 on 172.17.0.2 port 2222 rdomain ""
Bad packet length 0. [preauth]
ssh_dispatch_run_fatal: ... message authentication code incorrect [preauth]
Connection from 172.17.0.1 port 37286 on 172.17.0.2 port 2222 rdomain ""
Bad packet length 0. [preauth]
ssh_dispatch_run_fatal: ... message authentication code incorrect [preauth]
Connection from 172.17.0.1 port 60788 on 172.17.0.2 port 2222 rdomain ""
Bad packet length 0. [preauth]
ssh_dispatch_run_fatal: ... message authentication code incorrect [preauth]
... 10 satir boyunca tekrari ...

Her bir denememizde child process malformed paketi parse etmeye çalıştı, "Bad packet length 0" diyerek ssh_dispatch_run_fatal() ile kendini sonlandırdı. Bu 10 denemede SIGALRM tam unsafe call zincirinin ortasında yakalanmadı, segfault yok, stack smashing yok. Bekledim mi? Evet, dürüst olmak gerekirse beklemediğim de değildi. Çünkü:

Qualys'in orijinal raporunda Linux/glibc'de race penceresini kazanmanın 10,000 deneme ortalaması aldığı söyleniyor. 64-bit makinede ASLR de devrede olduğu için gerçek bir RCE elde etmek 6-8 saat sürüyor. Benim 10 deneme ile yaptığım sadece "race trigger'ı çalışıyor, child preauth fatal alıyor, SIGALRM dispatch ediliyor" demek. Kazanma şansı ise birkaç bin denemeye yayılıyor. Ev lab'ında segfault yakalama isteyen biri için --attempts 10000 diyerek bırakmak yeterli, gece çalıştır, sabah docker logs | grep segfault yap.

Şu noktadan sonra başlayan iş bu yazının kapsamı dışında ama özetle anlatayım: race kazanıldığında _int_malloc() içindeki free-list'in pointer'ları bozulmuş halde döner. Saldırgan TCP bağlantısının başında öyle paketler yollar ki, heap layout'u hesaplanabilir hale gelmiştir, heap massage. Bozulan pointer saldırganın kontrol ettiği bir bellek adresine işaret eder; oraya shellcode yerleştirilmiştir; child process'in instruction pointer (RIP) bir sonraki malloc() sonucunu kullandığında o adrese sıçrar; root shell. Public PoC'lerde (xonoxitron/regreSSHion, lflare/cve-2024-6387-poc, 7etsuo'nun C kodu) bunun tam zinciri 32-bit Linux için yazılmış.

Patch'in Mantığı, Savunma, ve Son Söz

9.8p1 sürümünde OpenSSH ekibi tam olarak ne yaptı? İki şey:

Patch yapamadığın sistemler için (legacy server, hala 8.9p1 koşan distro'ya saplanmış kurum vb.) anlamlı mitigation'lar:

regreSSHion'un Verdiği Dersler

Bu olay bir bug'dan daha fazlası. Bence üç şey öğretiyor:

Birincisi, "denenmiş ve gerçek" kategorisinde olduğunu sandığımız yazılımlar bile bug'lardan muaf değil. OpenSSH 25 yıllık bir proje, dünyanın en titiz security ekiplerinden biri yazıyor. Yine de 4 yıl boyunca kimse bu commit'i fark etmedi. Hiçbir kod sınırsız güven hak etmez.

İkincisi, regresyon belasi gerçek. Bir bug'ı kapatmak, onun kapalı kalacağı anlamına gelmiyor. CVE-2006-5051'i 2006'da kapattılar; ama o "kapanış"ın nedeni kod tarihinde uygun şekilde belgelenmemiş, bir sonraki refactor sırasında silinmesi engellenmemiş. Test coverage bu race condition için yetersizdi (zaten 56 mikrosaniyelik bir bug için unit test yazmak çok zor).

Üçüncüsü, async-signal-safety POSIX'in en bilinmeyen ama en kritik kavramlarından biri. C/C++ ile sistem programlama yapan insanların büyük çoğunluğu signal handler içinde printf() çağırmanın bug çıkardığını bilmiyor, çıktığında da çoğu zaman fark edilmeyecek kadar ince bir bug. regreSSHion bu kavramın ne kadar tehlikeli olduğunu son derece görünür bir örnekle gösterdi.

Zaman ayırdığın için teşekkürler. Yanlış bulduğun veya eksik kalan yerleri yazarsan, hatadan dönmek başka türlü olmuyor :)

// RAPOR SONU, MUCAHIC / OP-0042 / 2026.03.15