Мне стало известно, что датское правительство не просто приостановило действие программы Digital Exam Monitor, которую мы проанализировали и полностью обошли в предыдущей статье, а, возможно, полностью закрыло эту систему через неделю после того, как мы сообщили им способ взлома. Не хочу думать, что чисто из-за нас датское правительство отказалось от идеи мониторинга экзаменов, но нашу работу явно заметили.
В этой статье мы изложим технические детали, как работает другой инструмент слежки за школьниками: ExamCookie. Если вас интересует только обход системы, пролистайте вниз до соответствующего раздела.
ExamCookie
Недавно этот инструмент попал в новости из-за расследования на предмет нарушения GDPR. Мы решили взглянуть на второго по величине конкурента вышеупомянутой системы слежки за школьниками во время сдачи экзаменов: ExamCookie. Это коммерческая система слежения, которую используют более 20 датских школ. На сайте нет никакой документации, кроме следующего описания:
ExamCookie — простое программное обеспечение, которое контролирует компьютерную активность школьника во время экзамена, чтобы убедиться, что соблюдаются правила. Программа запрещает учащимся использовать любые незаконные формы получения помощи.
ExamCookie сохраняет всю активность на компьютере: активные URL, сетевые подключения, процессы, буфер обмена и скриншоты при изменении размера окна.
Программа работает просто: зайдя на экзамен, вы запускаете её на своём компьютере, и она контролирует вашу активность. Когда экзамен завершён, программа закрывается, и вы можете удалить её с компьютера.
Для запуска слежки нужно использовать свой UNI-логин, который работает на различных образовательных сайтах, или вручную ввести учётные данные. Мы не использовали инструмент, поэтому не можем сказать, в каких случаях применяется вход вручную. Возможно, это сделано для студентов, у которых нет UNI-логина, что мы не считаем возможным.
Информация о бинарнике
Программу можно загрузить с главной страницы сайта ExamCookie. Она представляет собой приложение x86 .NET. Для справки, у анализируемого бинарника MD5-хеш 63AFD8A8EC26C1DC368D8FF8710E337D
, подпись EXAMCOOKIE APS от 24 апреля 2019 года. Как показала последняя статья, анализ бинарника .NET вряд ли можно назвать обратной разработкой, потому что сочетание легко читаемого кода IL и метаданных даёт идеальный исходный код.
В отличие от предыдущей программы наблюдения, разработчики этого инструмента не только удалили его из журнала отладки, но ещё и обфусцировали. По крайней мере, попытались :-)
Обфускация (смех до слёз)
Открыв приложение в dnSpy, мы быстро заметили недостающую точку входа:
// Token: 0x0600003D RID: 61 RVA: 0x00047BB0 File Offset: 0x00045FB0
[STAThread]
[DebuggerHidden]
[EditorBrowsable(EditorBrowsableState.Advanced)]
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
internal static void Main(string[] Args)
{
}
Странно, обычно предполагается какой-то упаковщик, он изменяет тела методов из конструктора модуля, который выполняется до фактической точки входа, давайте посмотрим:
// Token: 0x06000001 RID: 1 RVA: 0x00058048 File Offset: 0x00055048
static <Module>()
{
<Module>.u206Bu202Bu200Bu206Fu206Cu202Du200Du200Eu202Du206Bu206Fu206Fu202Cu202Au206Bu202Eu202Au206Cu202Au206Cu200Bu206Au202Du206Cu202Cu206Cu200Fu202Cu206Cu202Cu200Cu206Au200Cu206Cu200Bu206Bu202Bu206Eu202Cu202Bu202E();
<Module>.u206Cu200Du200Fu200Eu200Cu200Cu200Fu200Fu206Eu206Au206Au200Bu202Cu206Au206Bu200Du206Eu200Eu202Du206Bu202Cu206Cu202Du206Du200Cu200Fu206Eu200Fu206Eu206Au202Bu206Bu200Eu206Bu202Eu206Fu206Au202Eu202Cu202Au202E();
<Module>.u200Bu202Du200Fu200Fu202Au206Du202Cu206Bu206Eu202Au206Fu206Cu200Du200Cu202Du200Fu202Bu202Cu202Bu206Du206Du202Du206Eu200Du206Du206Au202Au202Cu200Cu206Fu206Bu206Eu200Du202Eu206Fu200Cu206Bu200Eu206Du206Au202E();
}
Круто. Сейчас 2019 год, а люди всё ещё используют Confuser(Ex).
Мы мгновенно распознали этот код распаковки и проверили заголовки ассемблера:
[module: ConfusedBy("Confuser.Core 1.1.0+a36320377a")]
На данный момент мы полагали, что код реально будет обфусцирован, поскольку вышеупомянутый конструктор расшифровывает тела и ресурсы метода. Но, к нашему удивлению, разработчик обфускации решил… не переименовывать метаданные:
Это убивает весь кайф реверс-инжиниринга. Как мы говорили в прошлой статье, хотелось бы столкнуться с реальной проблемой должным образом защищённого, высококачественного инструмента наблюдения, анализ которого займёт больше пяти минут.
В любом случае, распаковка любого бинарника, защищённого confuser(ex), очень проста: используйте дампер бинарников .NET или точку останова инструкции ret в <MODULE>.ctor и сделайте дамп самостоятельно. Процесс занимает 30 секунд, а этот упаковщик всегда останется моим любимым, потому что защита против дебага вообще никогда не работает.
Мы решили использовать MegaDumper: это немного быстрее, чем дамп вручную:
После дампа бинарника ExamCookie должно появиться следующее сообщение:
Теперь у вас есть каталог со всеми фрагментами ассемблера, которые загружаются в соответствующий процесс, на этот раз с расшифрованными телами методов.
Кто бы ни реализовал эту обфускацию, слава богу, он хотя бы зашифровал строки:
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.SymbolicLink))
{
Module1.DebugPrint(<Module>.smethod_5<string>(1582642794u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.Tiff))
{
Module1.DebugPrint(<Module>.smethod_2<string>(4207351461u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.UnicodeText))
{
Module1.DebugPrint(<Module>.smethod_5<string>(3536903244u), new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.WaveAudio))
{
Module1.DebugPrint(<Module>.smethod_2<string>(2091555364u), new object[0]);
}
Да, старое доброе шифрование строк Confuser(Ex), самая лучшая псевдобезопасность в мире .NET. Хорошо, что Confuser(Ex) так часто взламывали, что в интернете доступны инструменты деобфускации для каждого механизма, поэтому мы не тронем ничего, связанного с .NET. Запустим на дампе бинарника ConfuserExStringDecryptor от CodeCracker:
Он преобразует предыдущий фрагмент в это:
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.SymbolicLink))
{
Module1.DebugPrint("ContainsData.SymbolicLink", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.Tiff))
{
Module1.DebugPrint("ContainsData.Tiff", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.UnicodeText))
{
Module1.DebugPrint("ContainsData.UnicodeText", new object[0]);
}
else if (System.Windows.Forms.Clipboard.ContainsData(DataFormats.WaveAudio))
{
Module1.DebugPrint("ContainsData.WaveAudio", new object[0]);
}
Вот и вся защита приложения, разорванная менее чем за минуту… Не будем тут выкладывать наши инструменты, потому что не мы их разработали и у нас нет исходного кода. Но каждый, кто хочет повторить работу, может найти их на Tuts4You. У нас больше нет аккаунта tuts4you, поэтому не можем поставить ссылку на зеркала.
Функциональность
Удивительно, но никакой реальной «скрытой функциональности» обнаружено не было. Как указано на сайте, на сервер периодически отправляется следующая информация:
- Список процессов (каждые 5000 мс)
- Активное приложение (каждые 1000 мс)
- Буфер обмена (каждые 500 мс)
- Скриншот (каждые 5000 мс)
- Список сетевых адаптеров (каждые 20000 мс)
Остальная часть приложения очень скучна, поэтому мы решили пропустить всю процедуру инициализации и перейти непосредственно к функциям, ответственным за захват информации.
Адаптер
Сетевые адаптеры собираются функцией .NET NetworkInterface.GetAllNetworkInterfaces()
, точно как в прошлой статье:
NetworkInterface[] allNetworkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (NetworkInterface networkInterface in allNetworkInterfaces)
{
try
{
// ...
// TEXT FORMATTING OMITTED
// ...
dictionary.Add(networkInterface.Id, stringBuilder.ToString());
stringBuilder.Clear();
}
catch (Exception ex)
{
AdapterThread.OnExceptionEventHandler onExceptionEvent = this.OnExceptionEvent;
if (onExceptionEvent != null)
{
onExceptionEvent(ex);
}
}
}
result = dictionary;
Активное приложение
Это становится интересным. Вместо того, чтобы регистрировать все открытые окна, утилита контролирует только активное приложение. Реализация раздута, поэтому приводим бьютифицированный псевдокод:
var whiteList = {
"devenv",
"ExamCookie.WinClient",
"ExamCookie.WinClient.vshost",
"wermgr",
"ShellExperienceHost" };
// GET WINDOW INFORMATION
var foregroundWindow = ApplicationThread.GetForegroundWindow();
ApplicationThread.GetWindowRect(foregroundWindow, ref rect);
ApplicationThread.GetWindowThreadProcessId(foregroundWindow, ref processId);
var process = Process.GetProcessById(processId);
if (process == null)
return;
// LOG BROWSER URL
if (IsBrowser(process))
{
var browserUrl = UiAutomation32.GetBrowserUrl(process.Id, process.ProcessName);
// SEND BROWSER URL TO SERVER
if (ValidBrowserUrl(browserUrl))
{
ReportToServer(browserUrl);
}
}
else if (!whiteList.contains(process.ProcessName, StringComparer.OrdinalIgnoreCase))
{
ReportToServer(process.MainWindowTitle);
}
Отлично… люди всё ещё используют имена процессов для их дифференциации. Они никогда не останавливаются и не думают: «Погодите, ведь можно как угодно изменить имена процессов», поэтому мы можем спокойно обходить эту защиту.
Если вы прочитали прошлую статью о другой программе слежки за экзаменом, вероятно, вы узнаете эту реализацию subpar для поиска браузеров:
private bool IsBrowser(System.Diagnostics.Process proc)
{
bool result;
try
{
string left = proc.ProcessName.ToLower();
if (Operators.CompareString(left, "iexplore", false) != 0 &&
Operators.CompareString(left, "chrome", false) != 0 &&
Operators.CompareString(left, "firefox", false) != 0 &&
Operators.CompareString(left, "opera", false) != 0 &&
Operators.CompareString(left, "cliqz", false) != 0)
{
if (Operators.CompareString(left, "applicationframehost", false) != 0)
{
result = false;
}
else
{
result = proc.MainWindowTitle.Containing("Microsoft Edge");
}
}
else
{
result = true;
}
}
catch (Exception ex)
{
result = false;
}
return result;
}
private string GetBrowserName(string name)
{
if (Operators.CompareString(name.ToLower(), "iexplore", false) == 0)
{
return "IE-Explorer";
}
else if (Operators.CompareString(name.ToLower(), "chrome", false) == 0)
{
return "Chrome";
}
else if (Operators.CompareString(name.ToLower(), "firefox", false) == 0)
{
return "Firefox";
}
else if (Operators.CompareString(name.ToLower(), "opera", false) == 0)
{
return "Opera";
}
else if (Operators.CompareString(name.ToLower(), "cliqz", false) == 0)
{
return "Cliqz";
}
else if (Operators.CompareString(name.ToLower(), "applicationframehost", false) == 0)
{
return "Microsoft Edge";
}
return "";
}
И вишенка на торте:
private static string GetBrowserUrlById(object processId, string name)
{
// ...
automationElement.GetCurrentPropertyValue(/*...*/);
return url;
}
Это буквально та же реализация, что и в прошлой статье. Трудно понять, как разработчики до сих пор не поняли, насколько она плоха. Кто угодно может редактировать URL в браузере, это даже не стоит демонстрировать.
Обнаружение виртуальной машины
Вопреки тому, что говорится на веб-сайте, запуск на виртуальной машине устанавливает флаг. Реализация… интересная.
File.WriteAllBytes("ecvmd.exe", Resources.VmDetect);
using (Process process = new Process())
{
process.StartInfo = new ProcessStartInfo("ecvmd.exe", "-d")
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true
};
process.Start();
try
{
using (StreamReader standardOutput = process.StandardOutput)
{
result = standardOutput.ReadToEnd().Replace("rn", "");
}
}
catch (Exception ex3)
{
result = "-5";
}
}
Хорошо, по какой-то причине они записывают на диск внешний бинарник и выполняют его, а затем полностью полагаются на результаты ввода-вывода. Такое действительно встречается частенько, но передача такой важной работы другому незащищённому процессу — так себе. Посмотрим, с каким файлом мы имеем дело:
Так теперь мы используем C++? Что ж, функциональная совместимость на самом деле не обязательно плоха. И это может означать, что нам теперь реально придётся потрудиться над обратной разработкой (!!). Посмотрим в IDA:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // ecx
BOOL v4; // ebx
int v5; // ebx
int *v6; // eax
int detect; // eax
bool vbox_key_exists; // bl
char vpcext; // bh
char vmware_port; // al
char *vmware_port_exists; // ecx
char *vbox_detected; // edi
char *vpcext_exists; // esi
int v14; // eax
int v15; // eax
int v16; // eax
int v17; // eax
int v18; // eax
int v20; // [esp+0h] [ebp-18h]
HKEY result; // [esp+Ch] [ebp-Ch]
HKEY phkResult; // [esp+10h] [ebp-8h]
if ( argc != 2 )
goto LABEL_20;
v3 = strcmp(argv[1], "-d");
if ( v3 )
v3 = -(v3 < 0) | 1;
if ( !v3 )
{
v4 = (unsigned __int8)vm_detect::vmware_port() != 0;
result = 0;
v5 = (vm_detect::vpcext() != 0 ? 2 : 0) + v4;
RegOpenKeyExA(HKEY_LOCAL_MACHINE, "HARDWARE\ACPI\DSDT\VBOX__", 0, 0x20019u, &result);
v6 = sub_402340();
LABEL_16:
sub_404BC0((int)v6, v20);
return 0;
}
detect = strcmp(argv[1], "-s");
if ( detect )
detect = -(detect < 0) | 1;
if ( !detect )
{
LABEL_20:
phkResult = 0;
vbox_key_exists = RegOpenKeyExA(HKEY_LOCAL_MACHINE, "HARDWARE\ACPI\DSDT\VBOX__", 0, 0x20019u, &phkResult) == 0;
vpcext = vm_detect::vpcext();
vmware_port = vm_detect::vmware_port();
vmware_port_exists = "1";
vbox_detected = "1";
if ( !vbox_key_exists )
vbox_detected = "0";
vpcext_exists = "1";
if ( !vpcext )
vpcext_exists = "0";
if ( !vmware_port )
vmware_port_exists = "0";
result = (HKEY)vmware_port_exists;
v14 = std::print((int)&dword_433310, "VMW=");
v15 = std::print(v14, (const char *)result);
v16 = std::print(v15, ",VPC=");
v17 = std::print(v16, vpcext_exists);
v18 = std::print(v17, ",VIB=");
v6 = (int *)std::print(v18, vbox_detected);
goto LABEL_16;
}
return 0;
}
Здесь проверяется присутствие порта ввода-вывода 'VX' от VMWare:
int __fastcall vm_detect::vmware_port()
{
int result; // eax
result = __indword('VX');
LOBYTE(result) = 0;
return result;
}
Далее проверяется выполнение инструкции virtual pc extension, которая должна работать только при запуске в виртуализированной среде, если не приведёт к сбою машины при неправильной обработке ;):
char vm_detect::vpcext()
{
char result; // al
result = 1;
__asm { vpcext 7, 0Bh }
return result;
}
…никакого реального реверс-инжиниринга, всего 30 секунд на переименование двух функций :(
Эта программа просто читает раздел реестра и запускает две проверки гипервизора, которые выглядят странно по сравнению с их другой программой. Интересно, где они это скопировали? О, посмотрите, статья под названием «Методы обнаружения виртуальных (sic) машин», которая объясняет эти методы :). Во всяком случае, эти векторы обнаружения можно обойти путём редактирования файла .vmx или с помощью усиленной версии любого гипервизора на ваш вкус.
Защита данных
Как упоминалось ранее, сейчас идёт расследование на предмет несоблюдения GDPR, а их веб-сайт заявляет:
Данные шифруются и отправляются на безопасный сервер Microsoft Azure, к которому можно получить доступ только с правильными учётными данными. После экзамена данные хранятся до трёх месяцев.
Мы не совсем уверены, как они определяют «безопасность» сервера, поскольку учётные данные жёстко закодированы в приложении и хранятся совершенно открытым текстом в ресурсах метаданных:
Endpoint: https://examcookiewinapidk.azurewebsites.net Username: VfUtTaNUEQ Password: AwWE9PHjVc
Мы не изучали содержимое сервера (это незаконно), но можем предполагать, что там предоставляется полный доступ. Поскольку учётка жёстко закодирована в приложении, нет никакой изоляции между контейнерами данных учащихся.
Юридическая оговорка: мы имеем право публиковать учётные данные API, поскольку они хранятся в общедоступном двоичном файле и, следовательно, не получены незаконно. Однако их использование со злым умыслом явно нарушает закон, поэтому настоятельно рекомендуем читателям никак не использовать вышеупомянутые учётные данные, и не несём ответственности за любые потенциальные действия.
Обход
Поскольку это приложение невероятно напоминает Digital Exam Monitor, мы просто обновили код ayyxam для поддержки ExamCookie.
Список процессов
Интерфейс процесса .NET внутренне кеширует данные процесса с помощью системного вызова ntdll!NtQuerySystemInformation
. Скрыть от него процессы требует некоторой работы, потому что информация о процессе указана во многих местах. К счастью, .NET извлекает только один определённый тип информации, поэтому не придётся применять все методы latebros.
Код для обхода проверки активных процессов.
NTSTATUS WINAPI ayyxam::hooks::nt_query_system_information(
SYSTEM_INFORMATION_CLASS system_information_class, PVOID system_information,
ULONG system_information_length, PULONG return_length)
{
// DONT HANDLE OTHER CLASSES
if (system_information_class != SystemProcessInformation)
return ayyxam::hooks::original_nt_query_system_information(
system_information_class, system_information,
system_information_length, return_length);
// HIDE PROCESSES
const auto value = ayyxam::hooks::original_nt_query_system_information(
system_information_class, system_information,
system_information_length, return_length);
// DONT HANDLE UNSUCCESSFUL CALLS
if (!NT_SUCCESS(value))
return value;
// DEFINE STRUCTURE FOR LIST
struct SYSTEM_PROCESS_INFO
{
ULONG NextEntryOffset;
ULONG NumberOfThreads;
LARGE_INTEGER Reserved[3];
LARGE_INTEGER CreateTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER KernelTime;
UNICODE_STRING ImageName;
ULONG BasePriority;
HANDLE ProcessId;
HANDLE InheritedFromProcessId;
};
// HELPER FUNCTION: GET NEXT ENTRY IN LINKED LIST
auto get_next_entry = [](SYSTEM_PROCESS_INFO* entry)
{
return reinterpret_cast<SYSTEM_PROCESS_INFO*>(
reinterpret_cast<std::uintptr_t>(entry) + entry->NextEntryOffset);
};
// ITERATE AND HIDE PROCESS
auto entry = reinterpret_cast<SYSTEM_PROCESS_INFO*>(system_information);
SYSTEM_PROCESS_INFO* previous_entry = nullptr;
for (; entry->NextEntryOffset > 0x00; entry = get_next_entry(entry))
{
constexpr auto protected_id = 7488;
if (entry->ProcessId == reinterpret_cast<HANDLE>(protected_id) && previous_entry != nullptr)
{
// SKIP ENTRY
previous_entry->NextEntryOffset += entry->NextEntryOffset;
}
// SAVE PREVIOUS ENTRY FOR SKIPPING
previous_entry = entry;
}
return value;
}
Буфер
За внутреннюю реализацию буферов в .NET отвечает ole32.dll!OleGetClipboard
, который очень поддаётся на хуки. Вместо того, чтобы тратить много времени на анализ внутренних структур, вы можете просто вернуть S_OK
, а обработка ошибок .NET сделает всё остальное:
std::int32_t __stdcall ayyxam::hooks::get_clipboard(void* data_object[[maybe_unused]])
{
// LOL
return S_OK;
}
Это скроет от инструмента наблюдения ExamCookie весь буфер, не нарушая функциональность программы.
Скриншоты
Как всегда, люди берут готовую .NET-реализацию нужной функции. Чтобы обойти эту функцию, нам не пришлось даже ничего менять в прошлом коде. Скриншоты управляются функцией Graphics.CopyFromScreen
.NET. Она по сути является оболочкой для передачи битовых блоков, которая вызывает gdi32!BitBlt
. Как и в видеоиграх для борьбы с античитерскими системами, которые делают скриншоты, мы можем применять хук на BitBlt и скрыть любую нежелательную информацию перед съёмкой скриншота.
Открытие сайтов
Граббер URL полностью скопирован с прошлой программы, так что мы опять можем повторно использовать свой код для обхода защиты. В прошлой статье мы задокументировали структуру AutomationElement, в результате выполнения которой запускается такой хук:
std::int32_t __stdcall ayyxam::hooks::get_property_value(void* handle, std::int32_t property_id, void* value)
{
constexpr auto value_value_id = 0x755D;
if (property_id != value_value_id)
return ayyxam::hooks::original_get_property_value(handle, property_id, value);
auto result = ayyxam::hooks::original_get_property_value(handle, property_id, value);
if (result != S_OK) // SUCCESS?
return result;
// VALUE URL IS STORED AT 0x08 FROM VALUE STRUCTURE
class value_structure
{
public:
char pad_0000[8]; //0x0000
wchar_t* value; //0x0008
};
auto value_object = reinterpret_cast<value_structure*>(value);
// ZERO OUT OLD URL
std::memset(value_object->value, 0x00, std::wcslen(value_object->value) * 2);
// CHANGE TO GOOGLE.COM
constexpr wchar_t spoofed_url[] = L"https://google.com";
std::memcpy(value_object->value, spoofed_url, sizeof(spoofed_url));
return result;
}
Обнаружение виртуальной машины
Ленивое обнаружение виртуальной машины можно обойти двумя способами: 1) патч программы, которая сбрасывается на диск; или 2) редирект процедуры создания процесса на фиктивное приложение. Последнее кажется явно проще :). Итак, внутренне Process.Start()
вызывает CreateProcess
, поэтому достаточно сделать хук и перенаправить его на любое фиктивное приложение, которое печатает символ '0'.
BOOL WINAPI ayyxam::hooks::create_process(
LPCWSTR application_name,
LPWSTR command_line,
LPSECURITY_ATTRIBUTES process_attributes,
LPSECURITY_ATTRIBUTES thread_attributes,
BOOL inherit_handles,
DWORD creation_flags,
LPVOID environment,
LPCWSTR current_directory,
LPSTARTUPINFOW startup_information,
LPPROCESS_INFORMATION process_information
)
{
// REDIRECT PATH OF VMDETECT TO DUMMY APPLICATION
constexpr auto vm_detect = L"ecvmd.exe";
if (std::wcsstr(application_name, vm_detect))
{
application_name = L"dummy.exe";
}
return ayyxam::hooks::original_create_process(
application_name, command_line, process_attributes,
thread_attributes, inherit_handles, creation_flags,
environment, current_directory, startup_information,
process_information);
}
Скачать
Весь проект доступен в репозитории Github. Программа работает путём инъекции двоичного файла x86 в соответствующий процесс.
Автор: m1rko