Cambios en el popular BattlEye antichita y formas de evitarlos


Principales actualizaciones del código de shell BattlEye


A medida que pasa el tiempo, los anti-trampas cambian, y para aumentar la efectividad del producto, las funciones aparecen y desaparecen en ellos. Hace un año, preparé una descripción detallada del código de shell BattlEye en mi blog [ traducción en Habré], y esta parte del artículo será un simple reflejo de los cambios realizados en el código de shell.

Marcas de tiempo en la lista negra


En un análisis reciente de BattlEye, solo había dos marcas de tiempo de compilación en la lista de prohibición de sombras, y parece que los desarrolladores decidieron agregar mucho más:

0x5B12C900 (action_x64.dll)
0x5A180C35 (TerSafe.dll, Epic Games)
0xFC9B9325 (?)
0x456CED13 (d3dx9_32.dll)
0x46495AD9 (d3dx9_34.dll)
0x47CDEE2B (d3dx9_32.dll)
0x469FF22E (d3dx9_35.dll)
0x48EC3AD7 (D3DCompiler_40.dll)
0x5A8E6020 (?)
0x55C85371 (d3dx9_32.dll)
0x456CED13 (?)
0x46495AD9 (D3DCompiler_40.dll)
0x47CDEE2B (D3DX9_37.dll)
0x469FF22E (?)
0x48EC3AD7 (?)
0xFC9B9325 (?)
0x5A8E6020 (?)
0x55C85371 (?)


No pude identificar las marcas de tiempo restantes, y los dos 0xF ******* son los hashes creados por los ensambles deterministas de Visual Studio. Gracias a @mottikraus y T0B1 por identificar algunas marcas de tiempo.

Verificaciones del módulo


Como lo mostró el análisis principal, la característica clave de BattlEye es la enumeración de módulos, y desde el momento del último análisis, se agregó otro módulo a la lista:

 void battleye::misc::module_unknown1() { if (!GetProcAddress(current_module, "NSPStartup")) return; if (optional_header.data_directory[4].size == 0x1B20 || optional_header.data_directory[4].size == 0xE70 || optional_header.data_directory[4].size == 0x1A38 || timestamp >= 0x5C600000 && timestamp < 0x5C700000) { report_module_unknown report = {}; report.unknown = 0; report.report_id = 0x35; report.val1 = 0x5C0; report.timestamp = timestamp; report.image_size = optional_header.size_of_image; report.entrypoint = optional_header.address_of_entry_point; report.directory_size = optional_header.data_directory[4].size; battleye::report(&report, sizeof(report), false); } } 

Esta es probablemente la detección de ciertos dlls proxy, ya que aquí se marca el tamaño de la tabla de redireccionamiento.

Títulos de ventanas


En el análisis anterior, varios proveedores de trucos fueron marcados con nombres de ventanas, pero desde entonces el shellcode ha dejado de verificar estos encabezados de ventanas. La lista de títulos de ventanas ha sido completamente reemplazada por:

Chod's
Satan5


Nombres de imagen


BattlEye es conocido por usar métodos de detección muy primitivos, y uno de ellos es una lista negra de nombres de imágenes. Cada año, la lista de nombres prohibidos de imágenes se está alargando, y en los últimos 11 meses se han agregado cinco nuevos:

frAQBc8W.dll
C:\\Windows\\mscorlib.ni.dll
DxtoryMM_x64.dll
Project1.dll
OWClient.dll

Vale la pena señalar que la presencia de un módulo con un nombre correspondiente a cualquiera de los elementos de la lista no significará que se le prohibirá de inmediato. El motor de informes también transmite información básica del módulo, que probablemente se usa para distinguir trampas de colisiones en el servidor BattlEye.

7 cremalleras


7-Zip fue ampliamente utilizado y sigue siendo utilizado por los participantes en la escena de trucos como relleno de memoria para huecos de código (cuevas de código). BattlEye intenta lidiar con esto realizando una verificación de integridad muy pobre, que ha cambiado desde mi artículo anterior:

 void module::check_7zip() { const auto module_handle = GetModuleHandleA("..\\..\\Plugins\\ZipUtility\\ThirdParty\\7zpp\\dll\\Win64\\7z.dll"); // --- REMOVED --- // if (module_handle && *(int*)(module_handle + 0x1000) != 0xFF1441C7) // --- ADDED --- if (module_handle && *(int*)(module_handle + 0x1008) != 0x83485348) { sevenzip_report.unknown_1 = 0; sevenzip_report.report_id = 0x46; sevenzip_report.unknown_2 = 0; sevenzip_report.data1 = *(__int64*)(module_handle + 0x1000; sevenzip_report.data2 = *(__int64*)(module_handle + 0x1008; battleye::report(&sevenzip_report, sizeof(sevenzip_report), false); } } 

Parece que los desarrolladores de BattlEye han adivinado que mi artículo anterior ha llevado a muchos usuarios a omitir esta verificación simplemente copiando los bytes deseados en la ubicación marcada por BattlEye. ¿Cómo arreglaron la situación? Cambiamos la verificación por ocho bytes y continuamos usando el mismo método incorrecto para verificar la integridad. La partición ejecutable de solo lectura, y todo lo que necesita hacer es descargar 7-Zip del disco y comparar las particiones movidas entre sí; Si hay alguna discrepancia, entonces algo está mal. En serio, muchachos, realizar verificaciones de integridad no es tan difícil.

Verificación de red


Enumerar la tabla TCP todavía funciona, pero después de que publiqué un análisis anterior que criticaba a los desarrolladores por marcar las direcciones IP de Cloudflare, aún eliminaron esta verificación. Anti-cheat aún informa el puerto que xera.ph usa para la conexión, pero los desarrolladores agregaron una nueva verificación para determinar si el proceso con la conexión tiene protección activa (presumiblemente esto se hace usando el controlador).

 void network::scan_tcp_table { memset(local_port_buffer, 0, sizeof(local_port_buffer); for (iteration_index = 0; iteration_index; < 500 ++iteration_index) { // GET NECESSARY SIZE OF TCP TABLE auto table_size = 0; GetExtendedTcpTable(0, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0); // ALLOCATE BUFFER OF PROPER SIZE FOR TCP TABLE auto allocated_ip_table = (MIB_TCPTABLE_OWNER_MODULE*)malloc(table_size); if (GetExtendedTcpTable(allocated_ip_table, &table_size, false, AF_INET, TCP_TABLE_OWNER_MODULE_ALL, 0) != NO_ERROR) goto cleanup; for (entry_index = 0; entry_index < allocated_ip_table->dwNumEntries; ++entry_index) { // --- REMOVED --- // const auto ip_address_match_1 = // allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656B1468; // 104.20.107.101 // // const auto ip_address_match_2 = // allocated_ip_table->table[entry_index].dwRemoteAddr == 0x656C1468; // 104.20.108.101 // +++ ADDED +++ const auto target_process = OpenProcess(QueryLimitedInformation, 0, ip_table->table[entry_index].dwOwningPid); const auto protected = target_process == INVALID_HANDLE && GetLastError() == 0x57; if (!protected) { CloseHandle(target_process); return; } const auto port_match = allocated_ip_table->table[entry_index].dwRemotePort == 20480; for (port_index = 0; port_index < 10 && allocated_ip_table->table[entry_index].dwLocalPort != local_port_buffer[port_index]; ++port_index) { if (local_port_buffer[port_index]) continue tcp_table_report.unknown = 0; tcp_table_report.report_id = 0x48; tcp_table_report.module_id = 0x5B9; tcp_table_report.data = BYTE1(allocated_ip_table->table[entry_index].dwLocalPort) | (LOBYTE(allocated_ip_table->table[entry_index.dwLocalPort) << 8; battleye::report(&tcp_table_report, sizeof(tcp_table_report), false); local_port_buffer[port_index] = allocated_ip_table->table[entry_index].dwLocalPort; break } } cleanup: // FREE TABLE AND SLEEP free(allocated_ip_table); Sleep(10 } } 

Gracias IChooseYou y resumen

BattlEye Stack Bypass


Hackear juegos es un juego constante de gato y ratón, por lo que los rumores de nuevos trucos se están extendiendo como un fuego. En esta parte, veremos nuevas técnicas heurísticas que un gran proveedor de anti-trampas BattlEye agregó recientemente a nuestro arsenal. Muy a menudo, estas técnicas se llaman andar en apilamiento. Por lo general, se implementan procesando una función y revisando la pila para averiguar quién llamó específicamente a esta función. ¿Por qué necesitas hacer esto? Al igual que cualquier otro programa, los hacks de videojuegos tienen un conjunto de funciones bien conocidas que utilizan para obtener información del teclado, enviarla a la consola o calcular ciertas expresiones matemáticas. Además, a los piratas informáticos les encanta ocultar su existencia, ya sea en la memoria o en el disco, para que el software anti-trampa no los encuentre. Pero lo que olvidan los programas de trucos es que regularmente llaman funciones de otras bibliotecas, y esto se puede usar para detectar heurísticamente trucos desconocidos. Al implementar el motor transversal de la pila para funciones como std::print , podemos encontrar estos trucos incluso si están enmascarados.

BattlEye implementó un "bypass de pila", a pesar de que esto no se anunció públicamente y en el momento de la publicación del artículo solo había rumores. Preste atención a las comillas: lo que verá aquí no es realmente un recorrido real de la pila, sino solo una combinación de verificar la dirección de retorno y el volcado del programa de llamadas. Una implementación transversal real de la pila pasaría por la pila y generaría una pila de llamadas real.

Como expliqué en un artículo anterior sobre BattlEye, el sistema anti-trampa transmite dinámicamente el shellcode al juego cuando se está ejecutando. Estos códigos de shell tienen diferentes tamaños y tareas, y no se transmiten simultáneamente. Una propiedad notable de tal sistema es que los investigadores necesitan analizar dinámicamente el anti-trampa durante la partida multijugador, lo que complica la determinación de las características de este anti-trampa. También permite que el anti-trampa aplique varias medidas a diferentes usuarios, por ejemplo, para transferir un módulo más profundamente invasivo solo a una persona que tiene una proporción inusualmente alta de asesinatos y muertes, y similares.

Uno de estos códigos de shell, BattlEye, es responsable de realizar este análisis de pila; lo llamaremos shellcode8kb porque es un poco más pequeño en comparación con shellcodemain , que documenté aquí . Este pequeño código de shell que utiliza la función AddVectoredExceptionHandler prepara un controlador de excepciones vectorizado y luego establece trampas de interrupción en las siguientes funciones:

GetAsyncKeyState
GetCursorPos
IsBadReadPtr
NtUserGetAsyncKeyState
GetForegroundWindow
CallWindowProcW
NtUserPeekMessage
NtSetEvent
sqrtf
__stdio_common_vsprintf_s
CDXGIFactory::TakeLock
TppTimerpExecuteCallback


Para hacer esto, simplemente itera alrededor de la lista de funciones usadas de manera estándar, configurando la primera instrucción de la función correspondiente en int3 , que se usa como punto de interrupción. Después de establecer un punto de interrupción, todas las llamadas a la función correspondiente pasan por el controlador de excepciones, que tiene acceso completo a los registros y la pila. Al tener este acceso, el controlador de excepciones volca la dirección del programa de llamada desde la parte superior de la pila, y si se cumple una de las condiciones heurísticas, se vuelcan 32 bytes de la función de llamada y se envían al servidor BattlEye con el identificador de informe 0x31 :

 __int64 battleye::exception_handler(_EXCEPTION_POINTERS *exception) { if (exception->ExceptionRecord->ExceptionCode != STATUS_BREAKPOINT) return 0; const auto caller_function = *(__int64 **)exception->ContextRecord->Rsp; MEMORY_BASIC_INFORMATION caller_memory_information = {}; auto desired_size = 0; // QUERY THE MEMORY PAGE OF THE CALLER const auto call_failed = NtQueryVirtualMemory( GetCurrentProcess(), caller_function, MemoryBasicInformation, &caller_memory_information, sizeof(caller_memory_information), &desired_size) < 0; // IS THE MEMORY SOMEHOW NOT COMMITTED? (WOULD SUGGEST VAD MANIPULATIUON) const auto non_commit = caller_memory_information.State != MEM_COMMIT; // IS THE PAGE EXECUTABLE BUT DOES NOT BELONG TO A PROPERLY LOADED MODULE? const auto foreign_image = caller_memory_information.Type != MEM_IMAGE && caller_memory_information.RegionSize > 0x2000; // IS THE CALL BEING SPOOFED BY NAMAZSO? const auto spoof = *(_WORD *)caller_function == 0x23FF; // jmp qword ptr [rbx] // FLAG ALL ANBORMALITIES if (call_failed || non_commit || foreign_image || spoof) { report_stack.unknown = 0; report_stack.report_id = 0x31; report_stack.hook_id = hook_id; report_stack.caller = (__int64)caller_function; report_stack.function_dump[0] = *caller_function; report_stack.function_dump[1] = caller_function[1]; report_stack.function_dump[2] = caller_function[2]; report_stack.function_dump[3] = caller_function[3]; if (!call_failed) { report_stack.allocation_base = caller_memory_information.AllocationBase; report_stack.base_address = caller_memory_information.BaseAddress; report_stack.region_size = caller_memory_information.RegionSize; report_stack.type_protect_state = caller_memory_information.Type | caller_memory_information.Protect | caller_memory_information.State; } battleye::report(&report_stack, sizeof(report_stack), false); return -1; } } 

Como podemos ver, el controlador de excepciones volca todas las funciones de llamada en caso de un cambio sin ceremonias en la página de memoria o cuando la función no pertenece a un módulo de proceso conocido (el tipo de página de memoria MEM_IMAGE no fue establecido por manualmappers). También volca las funciones de llamada cuando no puede llamar a NtQueryVirtualMemory para que los trucos no se unan a esta llamada del sistema y oculten su módulo del volcado de la pila. La última condición es bastante interesante, marca todas las funciones de llamada que usan el gadget jmp qword ptr [rbx] , el método utilizado para "falsificar la dirección de retorno". Fue lanzado por mi co-secretario apodo namazso. Parece que los desarrolladores de BattlEye vieron que las personas usan este método de suplantación de identidad en sus juegos y decidieron apuntar directamente a él. Vale la pena mencionar aquí que el método descrito por namazsos funciona bien, solo use un dispositivo diferente, o completamente diferente, o simplemente un registro diferente, no importa.

Consejo para desarrolladores de BattlEye: El CDXGIFactory::TakeLock en su memoria es incorrecto porque habilitó (accidental o intencionalmente) el relleno CC, que es muy diferente cada vez que compila. Para obtener la máxima compatibilidad, debe eliminar el relleno (el primer byte de la firma) y, por lo tanto, lo más probable es que atrape más tramposos :)

La estructura completa enviada al servidor BattlEye se ve así:

 struct __unaligned battleye_stack_report { __int8 unknown; __int8 report_id; __int8 val0; __int64 caller; __int64 function_dump[4]; __int64 allocation_base; __int64 base_address; __int32 region_size; __int32 type_protect_state; }; 

Reconocimiento de hipervisor en BattlEye


El juego del gato y el ratón en el campo de los juegos de piratería continúa siendo una fuente de innovación en las hazañas y la lucha contra los tramposos. El uso de la tecnología de virtualización en los juegos de hackeo comenzó a desarrollarse activamente después del advenimiento de hipervisores tan fáciles de usar como DdiMon Satoshi Tanda y hvpp Peter Benes. Estos dos proyectos son utilizados por la mayoría de los tramposos pagados de la escena de hackers subterráneos debido al bajo umbral de entrada y la documentación detallada. Es probable que estos lanzamientos aceleren la carrera armamentista en el campo de los hipervisores, que ahora comienza a manifestarse en la comunidad de piratas informáticos. Esto es lo que dice el administrador de una de las comunidades de piratería de juegos más grandes con el apodo wlan sobre esta situación:

Con el advenimiento de los sistemas de hipervisor listos para usar para piratear juegos, se hizo inevitable que los anti-trampas como BattlEye se enfocaran en el reconocimiento generalizado de la virtualización.

El uso generalizado de los hipervisores se debe a las recientes mejoras en la lucha contra las trampas, que dejaron a los piratas informáticos muy pocas oportunidades para modificar los juegos de manera tradicional. La popularidad de los hipervisores puede explicarse por la simplicidad de evitar el anti-engaño, porque la virtualización simplifica la ocultación de información mediante mecanismos como los ganchos de llamada al sistema y la virtualización MMU .

Recientemente, BattlEye ha implementado el reconocimiento de hipervisores comunes como las plataformas mencionadas anteriormente (DdiMon, hvpp) utilizando detección basada en el tiempo. Este reconocimiento intenta detectar valores de tiempo de instrucción CPUID no estándar. CPUID es una instrucción de costo relativamente bajo en equipos reales, que generalmente requiere solo doscientos ciclos, y en un entorno virtual, su ejecución puede tomar diez veces más tiempo debido a operaciones innecesarias causadas por el motor de introspección. El motor de introspección es diferente al equipo real, que simplemente realiza la operación de la manera esperada, porque sobre la base de un criterio arbitrario rastrea y cambia condicionalmente los datos devueltos al huésped.

Dato curioso : CPUID se usa activamente en estos procedimientos de reconocimiento temporal porque es una instrucción con una salida incondicional, así como una instrucción con serialización sin privilegios. Esto significa que el CPUID se usa como barrera y garantiza que se sigan las instrucciones antes y después; Al mismo tiempo, los tiempos se vuelven independientes del reordenamiento habitual de las instrucciones. También puede usar instrucciones como XSETBV , que también realiza una salida incondicional, pero para garantizar un tiempo independiente, esto requerirá algún tipo de instrucción de barrera para que no se produzca un reordenamiento antes o después, lo que afecta la confiabilidad de los tiempos.

Reconocimiento


El siguiente es el procedimiento de reconocimiento del módulo BattlEye "BEClient2"; Realicé su ingeniería inversa y recreé el código en pseudo-C, y luego lo publiqué en twitter . El día después de mi tweet, los desarrolladores de BattlEye cambiaron inesperadamente la ofuscación de BEClient2, aparentemente esperando que esto me impidiera analizar el módulo. La ofuscación anterior no cambió durante más de un año, pero cambió el día después de mi tuit al respecto: una velocidad impresionante.

 void battleye::take_time() { // SET THREAD PRIORITY TO THE HIGHEST const auto old_priority = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); // CALCULATE CYCLES FOR 1000MS const auto timestamp_calibrator = __rdtsc(); Sleep(1000); const auto timestamp_calibration = __rdtsc() - timestamp_calibrator; // TIME CPUID auto total_time = 0; for (std::size_t count = 0; count < 0x6694; count++) { // SAVE PRE CPUID TIME const auto timestamp_pre = __rdtsc(); std::uint32_t cpuid_data[4] = {}; __cpuid(cpuid_data, 0); // SAVE THE DELTA total_time += __rdtsc() - timestamp_pre; } // SAVE THE RESULT IN THE GLOBAL REPORT TABLE battleye::report_table[0x1A8] = 10000000 * total_time / timestamp_calibration / 0x65; // RESTORE THREAD PRIORITY SetThreadPriority(GetCurrentThread(), old_priority); } 

Como dije anteriormente, esta es la técnica de reconocimiento más común que utiliza instrucciones interceptadas incondicionalmente. Sin embargo, es vulnerable al tiempo falso, y hablaremos de esto en detalle en la siguiente sección.

Reconocimiento bypass


Este método de reconocimiento tiene problemas. En primer lugar, es propenso al tiempo falso, que generalmente se realiza de dos maneras: cambiando el TSC en VMCS o disminuyendo el TSC cada vez que se ejecuta el CPUID. Hay muchas otras formas de lidiar con ataques basados ​​en el tiempo, pero este último es mucho más fácil de implementar, porque puede garantizar que el tiempo de ejecución de la instrucción estará dentro de uno o dos ciclos de sincronización de ejecución en equipos reales. La dificultad de descubrir esta técnica de falsificación de tiempo depende de la experiencia del desarrollador. En la siguiente sección, veremos la falsificación de tiempo y la mejora de la implementación creada en BattlEye. La segunda razón de este defecto del método de reconocimiento es que el retraso de CPUID (tiempo de ejecución) en diferentes procesadores es muy diferente dependiendo del valor de la hoja. Puede tomar hasta 70-300 ciclos de reloj para completar. El tercer problema con este procedimiento de reconocimiento es usar SetThreadPriority. Esta función de Windows se usa para establecer el valor de prioridad de un descriptor de flujo dado, sin embargo, el sistema operativo no siempre escucha la solicitud. Esta función es simplemente una sugerencia para aumentar la prioridad del hilo, y no hay garantía de que suceda. Por lo tanto, es posible que este método se vea afectado por interrupciones u otros procesos.

En este caso, es fácil pasar por alto el reconocimiento, y la técnica descrita de falsificación de tiempo efectivamente derrota este método de reconocimiento. Si los desarrolladores de BattlEye desean mejorar este método, la siguiente sección proporciona algunas recomendaciones.

Mejora


Esta característica se puede mejorar de muchas maneras. Primero, puede deshabilitar intencionalmente las interrupciones y forzar la prioridad de un subproceso cambiando CR8 al IRQL más alto. También sería ideal aislar esta verificación en un núcleo de CPU. Otra mejora: debe usar diferentes temporizadores, pero muchos de ellos no son tan precisos como el TSC, pero existe un temporizador llamado APERF o Reloj de rendimiento real. Recomiendo este temporizador porque es más difícil hacer trampa con él y solo acumula un contador cuando el procesador lógico está en el estado de energía C0. Esta es una gran alternativa al uso de TSC. También puede usar el temporizador ACPI, HPET, PIT, GPU, NTP o PPERF, que es similar a APERF, pero cuenta las medidas que se perciben como instrucciones de ejecución. La desventaja de esto es que necesita habilitar HWP, que puede ser deshabilitado por el operador intermedio, y por lo tanto es inútil.

A continuación se muestra una versión mejorada del procedimiento de reconocimiento que se debe realizar en el núcleo:

 void battleye::take_time() { std::uint32_t cpuid_regs[4] = {}; _disable(); const auto aperf_pre = __readmsr(IA32_APERF_MSR) << 32; __cpuid(&cpuid_regs, 1); const auto aperf_post = __readmsr(IA32_APERF_MSR) << 32; const auto aperf_diff = aperf_post - aperf_pre; // CPUID IET ARRAY STORE // BATTLEYE REPORT TABLE STORE _enable(); } 

Nota: IET significa Tiempo de ejecución de instrucción.

Sin embargo, el procedimiento puede ser muy poco confiable para detectar hipervisores comunes, ya que los tiempos de ejecución de CPUID pueden variar mucho.Sería mejor comparar el IET de las dos instrucciones. Uno de ellos debería tener un retraso de ejecución más largo que el CPUID. Por ejemplo, puede ser FYL2XP1, una instrucción aritmética que tarda un poco más en completarse que el IET promedio de la instrucción CPUID. Además, no causa trampas en el hipervisor y su tiempo puede medirse de manera confiable. Usando estas dos funciones, la función de creación de perfiles podría crear una matriz para almacenar las instrucciones IET CPUID y FYL2XP1. Usando el temporizador APERF, sería posible obtener el reloj inicial de una instrucción aritmética, ejecutar la instrucción y calcular el delta del reloj para ella. Los resultados podrían almacenarse en la matriz IET para N ciclos de creación de perfiles, obtener el valor promedio y repetir el proceso para la CPUID. Si el tiempo de ejecución de la instrucción CPUID es más largo que la instrucción aritmética,entonces esta es una señal confiable de que el sistema es virtual, porque una instrucción aritmética bajo ninguna circunstancia podría pasar más tiempo que ejecutar el CPUID para obtener información sobre el fabricante o la versión. Tal procedimiento de reconocimiento también podrá detectar aquellos que usan el desplazamiento / escalado de TSC.

Repito, los desarrolladores tendrían que forzar la activación del enlace al núcleo computacional para realizar esta verificación en un núcleo, deshabilitar las interrupciones y forzar a IRQL a establecer el valor máximo para garantizar datos consistentes y confiables. Sería sorprendente si los desarrolladores de BattlEye decidieran implementar esto, porque requiere mucho más esfuerzo. En el controlador del kernel, BattlEye come otras dos rutinas de reconocimiento de máquinas virtuales, pero este es un tema para otro artículo.

Source: https://habr.com/ru/post/484420/


All Articles