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\x27ile kaçırma denemesi ve iç içe<<EOFile 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/nullsorguyu 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:
$ 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:
$ 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:
$ 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:
- Yerel kabuk tırnakları,
$işaretini ve heredoc'u yorumlar, ssh'a bir argüman listesi verir. - ssh bu argümanları boşlukla birleştirip tek dize yapar; tırnak bilgisi burada kaybolur.
- Uzak kabuk o dizeyi baştan ayrıştırır ve
bash -c '<dize>'olarak çalıştırır. - Süreç tablosu ve çıktı: komut satırı
/procaltı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.
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'deCREATE OR REPLACE VIEWgö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ıpstatement_timeoutkoyuyorum; DDL'den önceSET lock_timeoutile beklemeye süre sınırı vermek de aynı kuyruğu önler. -
Saat dilimi. Sunucu CEST'teydi, benim makinem +03.
systemd-run --on-calendarile 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üyorsanEurope/Berlin, kendi saatinle düşünüyorsanEurope/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 sonundakisort+rmadımı ikincinin geçici dosyasını sildi. Her koşuyamktempile kendi geçici dosyası; aynı anda tek koşu gerekiyorsaflock.
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 -fvepkill -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.