İçeriğe geç
Sahadan
TeknikOrta13 dk okuma

MySQL Portunu Kapattığınızı Sanıyorsunuz: Docker'ın Firewall'ı Atladığı Yer

MySQL varsayılan olarak tüm ağ arayüzlerini dinler ve Docker ile yayınlanan bir port ufw kurallarını hiç görmeden dışarıya açılır. Portun gerçekten kapalı olup olmadığı kural okunarak değil, dışarıdan denenerek ölçülür.

Erdem Özyurt

·Güncellendi:

Özet: MySQL sunucusu varsayılan olarak tüm ağ arayüzlerini dinler ve Docker ile yayınlanan bir port, ufw kurallarını hiç görmeden dışarıya açılır. Bu iki varsayılanın üst üste gelmesi, kimsenin açtığını hatırlamadığı bir 3306 portu üretir. Portun kapalı olduğu, kural okunarak değil sunucunun dışından denenerek doğrulanır.

Ağustos ayında honeypot ağımızda bir kayma oldu: aylardır birinci sırada duran web portlarını geçip MySQL 3306 birinci sıraya oturdu. Sekiz günde bu porta 5.161.194 olay geldi. Daha ilginci veritabanı potuna düşen trafiğin şekliydi: 7.351 etkileşimin tamamı boş parolayla gelmiş, tek bir SQL komutu yazılmamıştı.

Yani kimse veritabanını ele geçirmeye çalışmıyordu. Birisi parolasız MySQL örneklerinin listesini çıkarıyordu. Verinin tamamı ve saldırganın davranış analizi Tellal’da: TR hosting seli MySQL’e pivotladı. Taramanın sonraki günlerde nasıl seyrettiği ise ayrı bir bültende: zirve sanılan şey plato çıktı.

Bu yazı o taramanın karşı tarafıyla ilgili. Yani sizin sunucunuzla. Ve konu göründüğü kadar basit değil, çünkü çoğu sunucuda 3306 portunu kimse bilerek açmaz. İki varsayılan üst üste geldiği için açık kalır.

MySQL varsayılan olarak hangi arayüzleri dinler?

Hepsini. MySQL sunucusunun bind_address değişkeninin varsayılan değeri * yani yıldızdır, bu da sunucunun tüm IPv4 arayüzlerinde, sistem IPv6 destekliyorsa tüm IPv6 arayüzlerinde bağlantı kabul etmesi demektir.

Bunu ölçmek tek komut:

mysql -N -e "SELECT @@bind_address"

Dinlenen adresi sistem tarafından da doğrulayın:

ss -ltnp | grep 3306

Çıktıda görmek istediğiniz şey 127.0.0.1:3306 veya iç ağdaki bir adres. Şunu görüyorsanız MySQL her yerden bağlantı kabul etmeye hazır durumda:

LISTEN 0 151 *:3306 *:*

Burada önemli bir ayrım var. Bu tablo “veritabanım internete açık” demek değil. MySQL’in tüm arayüzleri dinlemesi tek başına bir zafiyet değil, çünkü paketin veritabanına ulaşması için önce firewall ve NAT katmanlarını geçmesi gerekir. Sorun şu ki o katmanların birinde beklenmedik bir delik var.

Docker ile yayınlanan port ufw kurallarını neden atlıyor?

Çünkü Docker paketi ufw’nin baktığı yere hiç göndermez. Docker, yayınlanan portlar için kurallarını nat tablosuna yazar ve paket ufw’nin kullandığı INPUT zincirine ulaşmadan yönlendirilir. Docker’ın kendi dokümantasyonu bunu net biçimde söylüyor: paketler firewall kuralları uygulanabilmeden önce yönlendirilir, yani firewall yapılandırmanız fiilen yok sayılır.

Sonuç şu tabloya benziyor. Sunucuda ufw aktif ve 3306 hiçbir kuralda geçmiyor:

$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere

Ama compose.yml dosyasında şu satır varsa:

services:
  db:
    image: mysql:8.4
    ports:
      - "3306:3306"

3306 dışarıya açıktır. Docker dokümantasyonunun ifadesiyle, varsayılan olarak tüm dış kaynak IP’lerinin Docker host adreslerine yayınlanmış portlara bağlanmasına izin verilir. ufw status çıktısı bu gerçeği hiçbir şekilde göstermez.

Bu, uzun süre “ben bir şey açmadım” cümlesiyle birlikte yaşayan bir durum. Kimse 3306’yı elle açmamıştır. Bir compose.yml dosyasında ports altına bir satır yazılmıştır, o kadar.

Docker’da veritabanı portu nasıl yayınlanmalı?

Çoğu durumda hiç yayınlanmamalı. Yayınlanacaksa host adresi açıkça yazılmalı.

Docker’ın port yayın söz dizimi [HOST_ADDR:]HOST_PORT:CONTAINER_PORT biçimindedir ve host adresi belirtilmezse daemon portu tüm host adreslerine (0.0.0.0 ve [::]) yayınlar. Adres yazıldığında ise davranış değişir: localhost adresi verilirse yayınlanan porta yalnız Docker host’u erişebilir.

Üç seçenek, artan güvenlik sırasıyla:

# 1. YANLIŞ: tüm adreslere yayınlanır, ufw kuralı bunu engellemez
ports:
  - "3306:3306"

# 2. Yalnız sunucunun kendisi erişir (yerel araçlar, yedek betikleri)
ports:
  - "127.0.0.1:3306:3306"

# 3. EN İYİ: hiç yayınlamayın
# Uygulama konteyneri veritabanını servis adıyla bulur

Üçüncü seçenek çoğu kurulumda doğru olanıdır. Aynı Docker ağındaki konteynerler birbirine servis adıyla ulaşır, portu host’a yayınlamaya gerek yoktur:

services:
  app:
    image: benim-uygulamam:latest
    environment:
      DB_HOST: db          # servis adı, host portu gerekmez
    depends_on:
      - db
  db:
    image: mysql:8.4
    # ports bloğu YOK

Bir değişiklik yaptıktan sonra konteynerin yeniden oluşturulması gerekir, yoksa eski port eşlemesi yaşamaya devam eder:

docker compose up -d --force-recreate db

Sonucu varsayımla değil ölçümle kapatın:

docker compose port db 3306
ss -ltnp | grep 3306

Docker kullanırken firewall kuralları nereye yazılır?

DOCKER-USER zincirine. Docker bu zinciri tam olarak bu iş için ayırmış durumda: kullanıcı tanımlı kurallar için bir yer tutucu ve Docker’ın kendi DOCKER-FORWARD ile DOCKER zincirlerinden önce işleniyor. Yani buraya yazdığınız kural, Docker’ın otomatik kurallarından önce söz sahibi olur.

Docker’ın dokümantasyonunda verdiği kalıp, dış arayüzden gelen ve izin verilen kaynak dışındaki trafiği düşürmek biçiminde:

# Tek bir adres dışındaki her kaynağı düşür
iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.2 -j DROP

# Bir alt ağ dışındaki her kaynağı düşür
iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP

ext_if yerine sunucunun gerçek dış arayüzünü yazın (ip route get 1.1.1.1 çıktısındaki dev değeri). Bu kurallar yeniden başlatmada kaybolur, iptables-persistent veya eşdeğeri bir mekanizmayla kalıcı hale getirilmeleri gerekir.

Yine de ilk tercih bu olmasın. DOCKER-USER kuralı, yayınlanmış bir portu sonradan daraltmak için doğru araçtır. Portu hiç yayınlamamak ise daraltılacak bir yüzey bırakmaz. Kural yazmak, yazılmamış bir kuralın yerini tutmaz.

Yönlendirilmiş port kapatılmadan sunucu kapanmaz

Sunucunun kendisini düzeltmek işin yarısı. Sunucu bir NAT’ın arkasındaysa ikinci yarısı yönlendirici tarafındadır.

Sahada en sık gördüğüm senaryo şu: yıllar önce birisi geçici olarak veritabanına dışarıdan bağlanmak istemiş, yönlendiriciye bir dst-nat kuralı eklenmiş ve o kural orada kalmış. Sunucu tarafında MySQL sonradan kapatılsa bile kural durmaya devam eder ve bir gün sunucu değişip aynı iç adres tekrar kullanıldığında port yeniden açılır.

MikroTik tarafında mevcut yönlendirmeleri okumak:

/ip firewall nat print where action=dst-nat

Veritabanı portlarına giden bir kural görürseniz silmek yerine önce devre dışı bırakın, sonucu ölçün, sonra silin:

/ip firewall nat disable [find dst-port=3306 action=dst-nat]

Bu sırayı öneriyorum çünkü bir kuralın gerçekten kullanılmadığını sayaçtan görmek mümkün. print stats çıktısındaki paket sayacı sıfırsa ve kural yıllardır oradaysa, silmek güvenlidir. Sayaç artıyorsa birisi o yolu hâlâ kullanıyor ve kuralı kaldırmak bir şeyi bozacak. Ne bozacağını önceden bilmek, bozduktan sonra öğrenmekten iyidir.

Port yönlendirmenin doğru ve yanlış kullanımını ayrıntılı ele aldığım yazı: MikroTik port yönlendirme ve dst-nat.

Portun kapalı olduğu nasıl doğrulanır?

Sunucunun dışından bağlanmayı deneyerek. Kural setini okumak niyeti gösterir, sonucu göstermez ve bu yazının konusu tam olarak niyet ile sonucun ayrıldığı yer.

Testi sunucunun ağının dışındaki bir makineden yapın. İç ağdan yapılan test yanıltır, çünkü iç trafik çoğu zaman NAT ve firewall kurallarının farklı bir yolundan geçer:

# Dışarıdaki bir makineden, sunucunun genel adresine
nc -vz -w 5 SUNUCU_GENEL_ADRESI 3306

Beklediğiniz cevap zaman aşımı veya bağlantı reddi. succeeded görüyorsanız port açıktır.

Bir uyarı: bu testin tek başına negatif sonucu kesin kanıt sayılmaz. Bazı ağlarda ve bazı araçlarda TCP taraması yanlış negatif verebilir, yani kapalı görünen bir port aslında açıktır. Bu yüzden erişimi, kullanacağınız protokolle ölçmek daha güvenilir:

mysql -h SUNUCU_GENEL_ADRESI -u root --connect-timeout=5 -e "SELECT 1"

Burada Access denied cevabı almak bile kötü haberdir: kimlik doğrulama reddedildi demek, TCP bağlantısının kurulduğu ve veritabanının sizinle konuştuğu anlamına gelir. İstediğiniz cevap Can't connect olmalı.

Boş parola neden bu kadar önemli?

Çünkü envanter taramasının tek aradığı şey o. Honeypot verisinde denenen kullanıcılar root (2.431 deneme), admin (2.308) ve sa (2.299) idi ve parolaların tamamı boştu.

Bu listede sa kullanıcısının bulunması aslında bir ipucu: sa, MySQL’in değil Microsoft SQL Server’ın varsayılan yönetici hesabıdır. Yani tarayıcı hedefe göre uyarlanmış bir sözlük kullanmıyor, jenerik bir liste deniyor. Maliyeti düşük tutup çok sayıda hedefi elemeye çalışıyor.

Pratikte bunun anlamı şu: parolasız veya varsayılan parolalı bir örnek, ilk el sıkışmada listeye giriyor. Ölçmek kolay:

-- Parolası boş olan hesaplar
SELECT user, host FROM mysql.user WHERE authentication_string = '';

-- Her yerden bağlanabilen hesaplar
SELECT user, host FROM mysql.user WHERE host = '%';

İkinci sorgunun çıktısındaki her satır, kimlik katmanının o hesap için hiçbir kısıt uygulamadığı anlamına gelir. host alanını daraltmak, firewall kuralı kadar etkili ve ondan daha kalıcı bir kısıttır.

Portu kapatmak yetmezse: saldırgan geride ne bırakıyor?

Portu kapatmak bugünden sonrasını korur. Daha önce girildiyse kapı zaten içeriden açık bırakılmış olabilir.

Honeypot ağının 9-13 Ağustos penceresinde bunun ölçülmüş üç imzası var ve üçü de klasik dosya bütünlüğü kontrolüyle yakalanır.

Birincisi, authorized_keys. Pencerede en çok tekrarlanan tek artefakt bir dosya değil, tek bir SSH açık anahtarıydı: aynı anahtar farklı IP’lerden farklı kullanıcı dizinlerine 76 kez yazıldı. Bot playbook’unun kalıcılık yöntemi budur, çünkü parola değiştirmek gürültülü, anahtar eklemek sessizdir. Yanında /etc/hosts.deny dosyasının boş satırla ezilmesi geliyor: saldırgan kendisinden önceki erişim kısıtlarını temizliyor.

# Beklenmeyen anahtar var mı
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
  [ -f "$f" ] && echo "--- $f" && cat "$f"
done

Bu dosyaları dosya bütünlüğü izlemesine almak, bir anahtar eklendiğinde alarm üretmek anlamına gelir. Tek satırlık bir kural, en yaygın kalıcılık yöntemini görünür kılar.

İkincisi, süreç adı kamuflajı. Pencerede beş farklı kaynaktan sshd adıyla ikili yüklendi ve çalıştırma komutu gizli sayısal bir dizinden geliyordu:

chmod +x ./.1924026081157900083/sshd; nohup ./.1924026081157900083/sshd &

Bir süreç listesinde sshd görmek kimseyi rahatsız etmez, isim tam olarak bu yüzden seçilmiş. Ayrım basit: gerçek sshd /usr/sbin/sshd üzerinden çalışır. Süreç adına değil yoluna bakın:

ls -l /proc/*/exe 2>/dev/null | grep -E "/tmp|/dev/shm|/var/tmp|/\."

Kalıcı çözüm dizinleri çalıştırılamaz hale getirmek. /etc/fstab içinde:

/tmp      /tmp      tmpfs  defaults,noexec,nosuid,nodev  0 0
/dev/shm  /dev/shm  tmpfs  defaults,noexec,nosuid,nodev  0 0

Uygulamadan önce test edin: bazı paket yöneticileri ve derleyiciler /tmp’de geçici çalıştırma yapar, noexec onları kırabilir.

Üçüncüsü, teslim yönteminin değişmesi. Aynı madenci kampanyası önceki pencerede dosyaları wget ve curl ile indiriyordu; bu pencerede tamamını SFTP ile yükledi. Yani yalnız çıkış HTTP trafiğinde wget arayan bir tespit kuralı bu dalgayı hiç görmez. Tespiti indirme aracına değil, sonuca bağlayın: yazılabilir dizinde yeni çalıştırılabilir dosya.

Bu üç imzanın ölçüldüğü bülten: zirve sanılan şey plato çıktı.

Sıralı kontrol listesi

Bir sunucuyu bu yazıya göre denetlerken izlediğim sıra:

  1. ss -ltnp | grep 3306 ile fiilen dinlenen adresi öğren. Yıldız görüyorsan devam et.
  2. docker compose config çıktısında veritabanı servisinin ports bloğunu oku. Host adresi yazılmamış bir yayın varsa bulduğun şey budur.
  3. Yayını kaldır veya 127.0.0.1: önekiyle daralt, konteyneri --force-recreate ile yeniden oluştur.
  4. Yönlendiricide veritabanı portlarına giden dst-nat kuralı olup olmadığını kontrol et. Varsa sayacına bak, önce devre dışı bırak.
  5. mysql.user tablosunda boş parolalı ve host = '%' olan hesapları listele, daralt.
  6. Sunucunun dışından bağlanmayı dene. İstediğin cevap Can't connect.
  7. Başarısız kimlik doğrulama denemelerinin loglandığını doğrula. Loglanmıyorsa envanter taraması size hiç görünmez.
  8. authorized_keys dosyalarını oku ve dosya bütünlüğü izlemesine al. Beklenmeyen anahtar, portun kapatılmasından önce girildiğinin kanıtıdır.
  9. /tmp, /dev/shm ve /var/tmp altından çalışan süreç olup olmadığını kontrol et, sonra bu dizinleri noexec ile bağla.

Yedinci madde en çok atlanan madde. Bu taramanın sessiz olmasının sebebi tam olarak bu: saldırgan bağlanıp boş parola deneyip çıkıyor, hiçbir şey bozmuyor ve hiçbir alarm üretmiyor. Envanterde olduğunuzu istismar dalgası geldiğinde öğrenirsiniz.

Bu tarama neden Türkiye’den geliyor görünüyor?

Görünüyor ama yönetildiği yer orası değil. Honeypot verisinde baskın kaynaklar Türk barındırma sağlayıcılarına ait ASN’lerdi ve bunun sebebi Türkiye’nin hedef olması değil, tarama düğümlerinin saatlik ve ucuz kiralanabildiği bir altyapı olması.

Bu ayrımı yapmak savunma açısından işe yarar: tek tek IP engellemek bu ölçekte anlamsızdır, çünkü kiralanan düğüm yarın başkasının olur. Engellemeyi en az ASN düzeyine taşımak veya davranış tabanlı sınırlama uygulamak daha ekonomiktir.

Kurumsal ölçekte bu işin envanter ve sahiplik tarafını SiberKale yazısında ele aldık: veritabanı maruziyetinin kurumsal denetimi. Ham veri ve saldırganın davranış analizi ise Tellal’ın Ağustos bülteninde: TR hosting seli MySQL’e pivotladı.

Sonuç

Bu yazının tek cümlesi şu: portun kapalı olduğu, kural okunarak değil dışarıdan denenerek bilinir.

Yanlış giden şey genelde bir hata değil, iki makul varsayılanın üst üste gelmesi. MySQL tüm arayüzleri dinliyor çünkü varsayılanı öyle. Docker portu tüm adreslere yayınlıyor çünkü varsayılanı öyle. İkisi de tek başına savunulabilir kararlar ve ikisi birlikte, kimsenin açmadığı bir kapı üretiyor.

Sahada bulduğum açık veritabanı portlarının neredeyse tamamı böyle açılmıştı. Kötü bir karar sonucu değil, hiç verilmemiş bir karar sonucu.

Aynı mantığın firewall tarafındaki karşılığı için: Linux firewall: ufw ve nftables · konteyner sertleştirmesi için: Docker güvenlik sertleştirmesi · yeni bir sunucuyu ilk dakikadan doğru kurmak için: Yeni VPS’te ilk 30 dakika.

#docker#mysql#firewall#guvenlik#iptables#ufw#mikrotik#routeros#veritabani#sunucu
Paylaş:𝕏 TwitterLinkedIn

Sıkça Sorulan Sorular

ufw'de portu kapattım ama Docker konteynerine dışarıdan erişiliyor, neden?

Docker yayınlanan portlar için kuralları nat tablosuna yazar ve paket ufw'nin kullandığı INPUT zincirine hiç ulaşmadan yönlendirilir. Docker'ın kendi dokümantasyonu bunu açıkça söylüyor: paketler firewall kuralları uygulanmadan önce yönlendirilir. ufw status çıktısı temiz görünse bile port dışarıdan açıktır.

MySQL varsayılan olarak internete açık mı gelir?

MySQL sunucusunun bind_address değişkeninin varsayılan değeri yıldızdır, yani sunucu tüm IPv4 ve IPv6 arayüzlerini dinler. Portun dışarıdan erişilebilir olup olmadığını belirleyen şey bundan sonraki katmanlardır: firewall, NAT ve konteyner port yayını. Yani MySQL kendi başına bir kısıt uygulamaz.

Docker'da bir portu yalnız sunucunun kendisine nasıl açarım?

Yayın adresini açıkça yazarsınız: -p 127.0.0.1:3306:3306 biçiminde. Host adresi belirtilmezse Docker portu tüm adreslere yayınlar. Docker dokümantasyonuna göre localhost adresi verildiğinde yayınlanan porta yalnız Docker host'u erişebilir.

Docker kullanırken firewall kurallarımı nereye yazmalıyım?

DOCKER-USER zincirine. Bu zincir Docker'ın kendi DOCKER-FORWARD ve DOCKER zincirlerinden önce işlenen, kullanıcı kuralları için ayrılmış bir yerdir. Docker'ın önerdiği kalıp, dış arayüzden gelen ve izin verilen kaynak dışındaki trafiği düşürmektir.

Portun gerçekten kapalı olduğunu nasıl doğrularım?

Kuralı okuyarak değil, sunucunun dışından bağlanmayı deneyerek. Kural setini okumak yalnız niyeti gösterir, sonucu göstermez. Ölçümü sunucunun dışındaki bir makineden yapın ve ağın kendi içinden test etmeyin, çünkü iç ağdan gelen trafik çoğu zaman farklı bir kural yolundan geçer.

Portu kapattım, daha önce girilmiş olabilir mi diye nasıl kontrol ederim?

Üç yere bakın. Birincisi authorized_keys dosyaları: /root/.ssh/ ve tüm /home/*/.ssh/ altında beklenmeyen anahtar, portun kapatılmasından önce girildiğinin en yaygın kanıtıdır. İkincisi süreç yolları: ls -l /proc/*/exe çıktısında /tmp, /dev/shm veya nokta ile başlayan gizli dizinden çalışan bir şey varsa meşru değildir, adı sshd olsa bile. Üçüncüsü /etc/hosts.deny dosyasının boşaltılmış olması.

Neden süreç adına değil yoluna bakmak gerekiyor?

Çünkü saldırgan ikilisine meşru bir servis adı veriyor. Ölçülen örnekte beş farklı kaynaktan sshd adıyla dosya yüklendi ve gizli sayısal bir dizinden çalıştırıldı. Bir süreç listesinde sshd görmek kimseyi rahatsız etmez. Gerçek sshd /usr/sbin/sshd üzerinden çalışır, yazılabilir bir dizinden çalışan aynı isim tanım gereği sahtedir.

Veritabanı sunucusuna uygulama sunucusundan erişim gerekiyorsa ne yapmalıyım?

Erişimi kaynak adrese bağlayın, porta değil. Yalnız uygulama sunucusunun adresine izin veren bir kural, herkese açık bir portla aynı işi yapar ama saldırı yüzeyi bırakmaz. Aynı sunucu üzerindeyseler port yayınlamaya hiç gerek yoktur, konteynerler ortak bir Docker ağında birbirini servis adıyla bulur.

Kaynaklar

  1. 1

    Docker konteyner trafiğini nat tablosunda yönlendirir; paketler ufw'nin kullandığı INPUT ve OUTPUT zincirlerine ulaşmadan saptırılır

    Docker Docs, Packet filtering and firewalls (2026) ↗
  2. 2

    Varsayılan olarak tüm dış kaynak IP'lerinin, Docker host adreslerine yayınlanmış portlara bağlanmasına izin verilir; DOCKER-USER zinciri kullanıcı kuralları için ayrılmıştır ve DOCKER-FORWARD ile DOCKER zincirlerinden önce işlenir

    Docker Docs, Docker with iptables (2026) ↗
  3. 3

    Konteyner portları belirli bir host adresi verilmeden eşlendiğinde Docker daemon portları tüm host adreslerine yayınlar; localhost adresi verilirse yayınlanan porta yalnız Docker host'u erişebilir

    Docker Docs, Port publishing and mapping (2026) ↗
  4. 4

    bind_address sistem değişkeninin varsayılan değeri yıldızdır: sunucu tüm IPv4 arayüzlerinde, IPv6 destekliyorsa tüm IPv6 arayüzlerinde bağlantı kabul eder

    MySQL 8.4 Reference Manual, Server System Variables (2026) ↗

Yazan: Erdem Özyurt

Bilişim Danışmanı · ISP Altyapı · Siber Güvenlik

Kablo döşemekten kod yazmaya, ağ tasarımından web geliştirmeye; Layer 1'den Layer 7'ye bütünlük içinde çalışıyorum.

Hakkımda daha fazla →