teknikbakis

Çıkış kodu 144: pkill -f kendi oturumumu öldürdü, aynı gün iki tuzak daha

Uzak bir sunucuda ssh ile iş yürüttüğüm tek bir günde üç hata defalarca tekrarlandı: süreç arayan komut kendini buldu, tırnaklar yanlış kabukta kapandı, maskeleyerek yazdırdığım sırlar ekrana düştü. Üçünün kökü aynı varsayımdı.

DOSYA TB-2026-013
SİSTEM Linux · bash · ssh · systemd
KATMAN Kabuk / uzak yürütme
SÜRE 2 gün · 26–27 Eylül
DURUM ÇÖZÜLDÜ
KANIT 3 ek

Semptom

26 Eylül 2026'da uzak bir sunucuda uzun bir toparlama işi yürütüyordum; her komut ssh üzerinden gidiyordu. Bir noktada, sunucuda çalışan bir işin bitmesini bekleyen bir döngü kurdum: süreç listede göründüğü sürece beklesin, bitince devam etsin. Süreci bulmak için pgrep -f ve bir desen kullandım.

Döngü bitmedi. Temizlemek için döngünün kendisini pkill -f ile hedefledim. Komut döndüğünde çıkış kodu 144'tü ve oturumum yoktu. 128'in üstündeki bir çıkış kodu, kabuğun bir sinyalle öldürüldüğünü söyler. Öldüren, az önce kendi yazdığım komuttu.

Aynı gün iki tuzak daha, birkaç kez geldi:

  • Tırnak ve heredoc. Bir Türkçe kesme işareti (IP'si), tek tırnaklı ssh komutunu ortasından böldü. Kesme işaretini \x27 ile kaçırma denemesi ve iç içe <<EOF ile birlikte üç kez sözdizimi hatası aldım. Bir seferinde uzak sunucuya gitmesi gereken nginx satırları benim makinemde çalışmaya kalktı: "komut yok". Başka bir seferinde heredoc'lu bir komuta eklediğim < /dev/null sorguyu boşalttı.
  • Maskeli sır. Sır içeren satırları "maskeleyerek" yazdıran bir regex, kalıplardan birini kaçırdı; bir API anahtarı, bir panel parolası ve bir anahtar daha oturum çıktısına düştü. Aynı akşam bir not dosyasını ilk 60 satırıyla bastım; içinde düz metin kimlik bilgileri vardı.

Ertesi gün aynı aileden üç kayıt daha geldi. Bir grep, yapılandırma dosyasında değişken adlarını ararken kodda varsayılan değer olarak gömülü bir veritabanı parolasını ve bir API anahtarını bastı. git remote -v, kendi git sunucumun uzak adresine gömülü erişim token'ını bastı; token tüm yerel klonlarda aynı biçimde duruyordu. Maskeli bir kod görüntüleyici, bir yorumdaki kısa parolayı kaçırdı.

Yanlış yollar

Her tuzakta ilk elime aldığım çare, aynı hastalığın başka bir biçimiydi.

Not almak. pgrep -f'nin kendi kabuğunu bulduğunu daha önce başka bir işte not etmiştim. Not, alışkanlığı değiştirmedi; yorgun bir anda el yine pgrep -f'ye gitti.

pkill -f ile temizlemek. Bitmeyen döngüyü durdurmak için döngünün metninden bir parçayı pkill -f'ye desen olarak verdim. O pkill'i çalıştıran kabuğun komut satırında da aynı metin vardı. Döngüyü bitmez yapan neden neyse, pkill'in hedef listesine kendi kabuğumu koyan neden de oydu.

Kesme işaretini kaçırmak. Tek tırnak içinde hiçbir karakter özel değildir, ters eğik çizgi de. Tek tırnak içinde yazılan \x27 uzak tarafa dört karakterlik düz metin olarak ulaşır (EK-2). Onun kesme işaretine dönüşmesi için yorumlayacak biri gerekir — $'...' biçimi ya da printf. Ortada ikisi de yoktu.

< /dev/null eklemek. Heredoc'lu bir komuta, ssh komutlarında sık eklenen < /dev/null parçasını da ekledim. Heredoc da bir standart girdi yönlendirmesidir. Aynı komutta iki girdi yönlendirmesi varsa soldan sağa uygulanır ve sonuncusu kazanır: sorgu metni hiç gitmedi, giden boş bir girdiydi. ssh -n de aynı şeyi yapar.

Maskeleyerek yazdırmak. Maske, sırrın dosyada hangi biçimlerde durduğunu bildiğimi varsayar. Bilmiyordum. Regex bir kalıbı kaçırdı. Değişken adlarını arayan grep desenim, eşleşen satırın tamamını yani yanındaki varsayılan değeri de getirdi. Maskeli görüntüleyici dizeleri maskeliyordu ama yorumlarda yalnız 16 karakter ve üstü sözcükleri; 15 karakterlik eski bir parola yorumda olduğu gibi kaldı. Maske bir filtre değil, bir tahmindir.

Doğru test

Üç tuzağı ayıran test aynı: komutu çalıştırmadan önce, bir sonraki katmanın ne göreceğini yazdır. Süreç arayan bir komut için o katman süreç tablosudur. Olayı 3 Ekim'de kendi makinemde, zararsız bir desenle yeniden ürettim:

EK-1 pgrep ve pkill kendi kabuğunu bulur · yerel yeniden üretim
$ bash -c "for i in 1; do pgrep -af 'bekle-42'; done"
1716643 bash -c for i in 1; do pgrep -af 'bekle-42'; done

$ bash -c "for i in 1; do pkill -f 'bekle-42'; echo 'hâlâ hayattayım'; done"; echo "çıkış: $?"
Sonlandırıldı
çıkış: 143

pgrep kendini listelemez ama kendisini başlatan kabuğu listeler ve o kabuğun komut satırı, deseni içeren metnin tamamıdır. for döngüsü kabuğun komutla yer değiştirmesini önlüyor; gerçek bekleme döngüsü de böyle yaşayan bir kabuktu. ssh ile gönderilen komut uzak tarafta tam olarak bir bash -c '<metin>' sürecidir. Burada 143 (128+15, SIGTERM) çıktı; o gün oturumun bildirdiği 144'tü. İkisi de sinyalle ölüm.

Tırnaklı bir komutta o katman, ssh'ın uzağa gönderdiği dizedir. ssh argümanlarını boşlukla birleştirip tek bir dize yapar; dizeyi görmek için ssh'ın yerine printf koymak yeter:

EK-2 Kesme işaretinin uzağa giden hâli · yerel yeniden üretim
$ set -- 'grep -c "IP'si engellendi" kayit.log; echo "nginx'i yeniden yükle"'
$ echo "argüman sayısı: $#"
argüman sayısı: 2
$ printf 'uzağa giden dize: %s\n' "$*"
uzağa giden dize: grep -c "IPsi engellendi kayit.log; echo nginxi yeniden yükle"

$ printf '[%s]\n' 'echo "IP\x27si engellendi"'
[echo "IP\x27si engellendi"]

İki kesme işareti tek tırnaklı dizeyi iki yerden kapatıp açtı. Sözdizimi hatası yok, komut çalışır; ama uzak kabuk grep'e tek bir uzun desen verir ve dosya vermez, grep standart girdiyi beklemeye başlar. Tek sayıda kesme işareti olsaydı yerel kabuk eşleşmeyen tırnak hatası verirdi. Hata veren hâli, iyi hâlidir. Alttaki satır: \x27 olduğu gibi gidiyor.

Heredoc'ta o katman, uzağa giden satırların kendisidir. ssh sunucu bash yerine wc -l koymak, kaç satırın gerçekten gittiğini sayar:

EK-3 Erken kapanan heredoc ve boşalan girdi · yerel yeniden üretim
$ cat ic-ice.sh
wc -l <<EOF
cat > snippets/ek.conf <<EOF
add_header X-Frame-Options "DENY" always;
EOF
add_header X-Content-Type-Options "nosniff" always;
EOF
$ bash ic-ice.sh
2
ic-ice.sh: satır 5: add_header: komut yok
ic-ice.sh: satır 6: EOF: komut yok

$ wc -c <<EOF < /dev/null
SELECT count(*) FROM olaylar;
EOF
0

İç heredoc'un EOF satırı dış heredoc'u kapattı: uzağa iki satır gitti, geri kalanlar yerel kabuğun komutu oldu. O gün "komut yok" diyen satırlar da nginx yönergeleriydi. İkinci örnekte sorgunun tek baytı bile gitmedi.

Sır için doğru test ise sırrı göstermeden var olduğunu kanıtlamaktır: hangi satırda, kaç karakter, özeti neyle başlıyor. Örneği Çözüm'de.

Kök neden

Üç tuzağın ortak kökü tek bir varsayım: yazdığım metinle çalışan metin arasında tek bir katman var. ssh komutu yazarken kafamdaki model "bunu uzakta çalıştır"dı. Gerçekte metin en az dört el değiştiriyordu:

  1. Yerel kabuk tırnakları, $ işaretini ve heredoc'u yorumlar, ssh'a bir argüman listesi verir.
  2. ssh bu argümanları boşlukla birleştirip tek dize yapar; tırnak bilgisi burada kaybolur.
  3. Uzak kabuk o dizeyi baştan ayrıştırır ve bash -c '<dize>' olarak çalıştırır.
  4. Süreç tablosu ve çıktı: komut satırı /proc altında görünür; ekrana basılan her şey terminal kaydına, kabuk geçmişine, oturum dökümüne yazılır.

pgrep -f, dördüncü katmandaki metni okudu ve orada kendi desenini buldu. Kesme işareti birinci katmanda kapandı, ikinci katman bunu fark etmedi, üçüncü katman başka bir komut çalıştırdı. Maske ise ekranı tek okuyucu sandı; oysa çıktı kalıcı bir dökümün parçasıydı ve ekranda bir an görünen sır, artık o dökümde duruyordu.

ÇIKARIM

Uzak komut, birden fazla yorumlayıcının sırayla okuduğu bir metindir. Her katman kendi kurallarıyla okur ve bir öncekinin niyetini bilmez. Katman sayısı ikiyi geçiyorsa metni satır içinde taşıma; dosyaya yaz.

Çözüm

Süreci desenle değil adla yönet

# başlat: birim adı, süreç kimliğidir
systemd-run --unit=tarama-0926 --collect /usr/bin/python3 /root/tarama.py

# bekle / durdur / log: eşleşecek desen yok
while systemctl is-active --quiet tarama-0926; do sleep 10; done
systemctl stop tarama-0926
journalctl -u tarama-0926 --no-pager | tail -n 20

Desen gerçekten gerekiyorsa tüm komut satırında değil, alanda arıyorum. Burada desen ikinci ve üçüncü alana bakar; kendi kabuğumun ikinci alanı bash, awk'ınki awk'tır:

ps -eo pid,args | awk '$2 ~ /python3$/ && $3 ~ /tarama\.py$/ {print $1}'

Uzak betik: dosyaya yaz, taşı, çalıştır, sil

scp ./bakim.sh sunucu:/root/bakim.sh
ssh sunucu 'bash /root/bakim.sh; rm -f /root/bakim.sh'

ssh'a giden metin artık tırnaksız, kesme işaretsiz iki kısa komut; betiğin içindeki her şey tek bir kabuk tarafından, bir kez okunur. Satır içi heredoc'u yalnız tek katman ve kesme işaretsiz metin için kullanıyorum, sınırlayıcıyı da tırnaklı yazıyorum (<<'EOF') ki yerel kabuk içerideki $ işaretini yorumlamasın.

Sırrı göstermeden doğrula

# yalnız adlar: değer hiç okunmaz
grep -noE '^[A-Z_]+ *=' config.py

# satır, ad, uzunluk — örnek dosyada
$ awk -F= '{ printf "%d: %s (%d karakter)\n", NR, $1, length($0) - length($1) - 1 }' ornek.env
1: DB_HOST (9 karakter)
2: API_KEY (29 karakter)

# iki kopyanın aynı olup olmadığı için özet öneki yeter
$ grep '^API_KEY=' ornek.env | cut -d= -f2- | tr -d '\n' | sha256sum | cut -c1-12
7327ef353087

# uzak adresteki gömülü kimlik bilgisini maskele
git remote -v | sed -E 's#//[^/@]*@#//***@#'

Sırrı komut satırı argümanı yapmıyorum: /proc/<pid>/cmdline varsayılan olarak makinedeki her kullanıcıya açıktır ve ps onu okur. Ortam değişkeni (/proc/<pid>/environ yalnız sahibine ve root'a açık) ya da standart girdi daha iyi; araçta --password-stdin gibi bir seçenek varsa onu kullanıyorum. Kodda gömülü bir parolayı ortam dosyasına taşırken de değeri hiç basmayan bir betik yazdım: satırı Python AST ile buluyor, değeri betiğin içinde .env dosyasına yazıyor, systemd-run -p EnvironmentFile= ile yalnız True/False döndüren bir ayrıştırma ön testi yapıyor, sonra geri alınabilir biçimde uyguluyor. Hiçbir adımda değer ekrana gelmedi. Maskeli görüntüleyiciyi de düzelttim: yorumları uzunluğuna bakmadan tamamen maskeliyor.

Ekrana düşen her sır, yenilenecek sırlar listesine girer. "Ekranda bir an göründü" diye bırakılmaz; ekran, sırrın son durağı değil.

Aynı günün üç yan tuzağı

  • Uzun sorgu ve CREATE OR REPLACE VIEW. Bir görünümü okuyan yedi dakikalık bir karşılaştırma sorgusu çalışırken görünümü değiştirmeye kalktım. PostgreSQL'de CREATE OR REPLACE VIEW görünüm üzerinde en güçlü kilidi (ACCESS EXCLUSIVE) ister ve sorgu bitene kadar bekler. Daha kötüsü, bekleyen o kilidin arkasına görünümü okumak isteyen her yeni sorgu da dizilir; API'nin sorguları dahil. Karşılaştırmayı önce geçici tabloya alıp statement_timeout koyuyorum; DDL'den önce SET lock_timeout ile beklemeye süre sınırı vermek de aynı kuyruğu önler.
  • Saat dilimi. Sunucu CEST'teydi, benim makinem +03. systemd-run --on-calendar ile verdiğim "20:41" sunucunun saatiyle okundu ve zamanlayıcı benim saatimle bir saat geç kuruldu. Artık dilimi takvime açıkça yazıyorum ve kurmadan önce sınıyorum. Hangi dilimi yazdığın değil, yazman önemli: sunucunun saatiyle düşünüyorsan Europe/Berlin, kendi saatinle düşünüyorsan Europe/Istanbul.
  • Aynı çıktı dosyasına iki koşu. Bir arka plan işini & ile başlatıp, ilki bitmeden aynı çıktı dosyasıyla ikinci bir koşu açtım. Birincinin sonundaki sort + rm adımı ikincinin geçici dosyasını sildi. Her koşuya mktemp ile kendi geçici dosyası; aynı anda tek koşu gerekiyorsa flock.
systemd-analyze calendar "2026-09-26 20:41 Europe/Istanbul"
systemd-run --unit=gece-isi --on-calendar="2026-09-26 20:41 Europe/Istanbul" /root/is.sh
systemctl list-timers gece-isi.timer      # LEFT sütunu: kalan süre

tmp=$(mktemp /var/tmp/tarama.XXXXXX)
flock -n /run/tarama.lock ./tarama.sh "$tmp" || echo "zaten çalışıyor"

Kalıcı ders

  • Komut metninin kaç el değiştirdiğini say. İkiden fazlaysa satır içinde taşıma: dosyaya yaz, taşı, çalıştır, sil.
  • Süreci desenle değil kimlikle yönet. Birim adı, PID dosyası, alan eşleşmesi. pgrep -f ve pkill -f'nin deseni, aramayı yapan kabuğun komut satırında da geçer.
  • Maske bir güvenlik denetimi değildir. Sır içeren dosyada değeri hiç okumayan komutlar kullan; görmen gerekiyorsa uzunluk ve özet öneki yeter.
  • Sızan sırrı yenile. Çıktı ekranda kalmaz; terminal kaydına, geçmişe ve dökümlere yazılır.
  • Bir tuzağı not etmek onu önlemez. Güvenli kalıbı betiğe dönüştür. Yorgunken el, hazırda duran şeye gider.