The popular anti-cheat BattlEye is widely used by modern online games such as Escape from Tarkov and is considered an industry standard anti-cheat by many. In this article I will demonstrate a method I have been utilizing for the past year, which enables you to play any BattlEye-protected game online without even having to install BattlEye.
BattlEye initialisation
BattlEye is dynamically loaded by the respective game on startup to initialize the software service (“BEService”) and kernel driver (“BEDaisy”). These two components are critical in ensuring the integrity of the game, but the most critical component by far is the usermode library (“BEClient”) that the game interacts with directly. This module exports two functions: GetVer
and more importantly Init
.
The Init routine is what the game will call, but this functionality has never been documented before, as people mostly focus on BEDaisy or their shellcode. Most important routines in BEClient, including Init, are protected and virtualised by VMProtect, which we are able to devirtualise and reverse engineer thanks to vtil by secret club member Can Boluk, but the inner workings of BEClient is a topic for a later part of this series, so here is a quick summary.
Init and its arguments have the following definitions:
// BEClient_x64!Init
__declspec(dllexport)
battleye::instance_status Init(std::uint64_t integration_version,
battleye::becl_game_data* game_data,
battleye::becl_be_data* client_data);
enum instance_status
{
NONE,
NOT_INITIALIZED,
SUCCESSFULLY_INITIALIZED,
DESTROYING,
DESTROYED
};
struct becl_game_data
{
char* game_version;
std::uint32_t address;
std::uint16_t port;
// FUNCTIONS
using print_message_t = void(*)(char* message);
print_message_t print_message;
using request_restart_t = void(*)(std::uint32_t reason);
request_restart_t request_restart;
using send_packet_t = void(*)(void* packet, std::uint32_t length);
send_packet_t send_packet;
using disconnect_peer_t = void(*)(std::uint8_t* guid, std::uint32_t guid_length, char* reason);
disconnect_peer_t disconnect_peer;
};
struct becl_be_data
{
using exit_t = bool(*)();
exit_t exit;
using run_t = void(*)();
run_t run;
using command_t = void(*)(char* command);
command_t command;
using received_packet_t = void(*)(std::uint8_t* received_packet, std::uint32_t length);
received_packet_t received_packet;
using on_receive_auth_ticket_t = void(*)(std::uint8_t* ticket, std::uint32_t length);
on_receive_auth_ticket_t on_receive_auth_ticket;
using add_peer_t = void(*)(std::uint8_t* guid, std::uint32_t guid_length);
add_peer_t add_peer;
using remove_peer_t = void(*)(std::uint8_t* guid, std::uint32_t guid_length);
remove_peer_t remove_peer;
};
As seen, these are quite simple containers for interopability between the game and BEClient. becl_game_data
is defined by the game and contains functions that BEClient needs to call (for example, send_packet) while becl_be_data
is defined by BEClient and contains callbacks used by the game after initialisation (for example, received_packet). Note that these two structures slightly differ in some games that have special functionality, such as the recently introduced packet encryption in Escape from Tarkov that we’ve already cracked. Older versions of BattlEye (DayZ, Arma, etc.) use a completely different approach with function pointer swap hooks to intercept traffic communication, and therefore these structures don’t apply.
A simple Init implementation would look like this:
// BEClient_x64!Init
__declspec(dllexport)
battleye::instance_status Init(std::uint64_t integration_version,
battleye::becl_game_data* game_data,
battleye::becl_be_data* client_data)
{
// CACHE RELEVANT FUNCTIONS
battleye::delegate::o_send_packet = game_data->send_packet;
// SETUP CLIENT STRUCTURE
client_data->exit = battleye::delegate::exit;
client_data->run = battleye::delegate::run;
client_data->command = battleye::delegate::command;
client_data->received_packet = battleye::delegate::received_packet;
client_data->on_receive_auth_ticket = battleye::delegate::on_receive_auth_ticket;
client_data->add_peer = battleye::delegate::add_peer;
client_data->remove_peer = battleye::delegate::remove_peer;
return battleye::instance_status::SUCCESSFULLY_INITIALIZED;
}
This would allow our custom BattlEye client to receive packets sent from the game server’s BEServer module.
Packet handling
The function received_packet
is by far the most important routine used by the game, as it handles incoming packets from the BattlEye server component. BattlEye communication is extremely simple compared to how important the integrity of it is. In recent versions of BattlEye, packets follow the same general structure:
#pragma pack(push, 1)
struct be_fragment
{
std::uint8_t count;
std::uint8_t index;
};
struct be_packet_header
{
std::uint8_t id;
std::uint8_t sequence;
};
struct be_packet : be_packet_header
{
union
{
be_fragment fragment;
// DATA STARTS AT body[1] IF PACKET IS FRAGMENTED
struct
{
std::uint8_t no_fragmentation_flag;
std::uint8_t body[0];
};
};
inline bool fragmented()
{
return this->fragment.count != 0x00;
}
};
#pragma pack(pop)
All packets have an identifier and a sequence number (which is used by the requests/response communication and the heartbeat). Requests and responses have a fragmentation mode which allows BEServer and BEClient to send packets in chunks of 0x400
bytes (seemingly arbitrary) instead of sending one big packet.
In the current iteration of BattlEye, the following packets are used for communication:
INIT (00
)
This packet is sent to the BEClient module as soon as the connection with the game server has been established. This packet is only transmitted once, contains no data besides the packet id 00
and the response to this packet is simply 00 05
.
START (‘02’)
This packet is sent right after the ‘INIT’ packets have been exchanged, and contains the server-generated guid of the client. The response of this packet is simply the header: 02 00
REQUEST (04
) / RESPONSE (05
)
This type of packet is sent from BEServer to BEClient to request (and in rare cases, simply transmit) data, and BEClient will send back data for that request using the RESPONSE
packet type.
The first request contains crucial information such as service- and integration version, not responding to it will get you disconnected by the game server. Afterwards, requests are game specific.
HEARTBEAT (09
)
This type of packet is used by the BEServer module to ensure that the connection hasn’t been dropped. It is sent every 30 seconds using a sequential index, and if the client doesn’t respond with the same packet, the client is disconnected from the game server. This heartbeat packet is only three bytes long, with the sequential index used for synchronization being incremental and therefore easily emulated. An example heartbeat could be: 09 01 00
, which is the second heartbeat (sequence starts at zero) transmitted.
Emulation
With this knowledge, it is possible by emulating the entire BattlEye anti-cheat with only two proprietary points of data: the responses for request sequence one and two. These can be intercepted using a tool such as wireshark and replayed as many times as you want for the respective game, because the packet encryption used by BattlEye is static and contextless.
Emulating the INIT
packet is as stated simply responding with the sequence number five:
case battleye::packet_id::INIT:
{
auto info_packet = battleye::be_packet{};
info_packet.id = battleye::packet_id::INIT;
info_packet.sequence = 0x05;
battleye::delegate::o_send_packet(&info_packet, sizeof(info_packet));
break;
}
Emulating the START
packet is done by replying with the received packet’s header:
case battleye::packet_id::START:
{
battleye::delegate::o_send_packet(received_packet, sizeof(battleye::be_packet_header));
break;
}
Emulating the HEARTBEAT
packets is done by replying with the received packet:
case battleye::packet_id::HEARTBEAT:
{
battleye::delegate::o_send_packet(received_packet, length);
break;
}
Emulating the REQUEST
packets can be done by replaying previously generated responses, which can be logged with code hooks or man-in-the-middle software. These packets are game specific and some games might disconnect you for not handling a specific request, but most games only require the first two requests to be handled, afterwards simply replying with the packet header is enough to not get disconnected by the game server. It is important to notice that all REQUEST
packets are immediately responded to with the header, to let the server know that the client is aware of the request. This is how BottlEye emulates them:
case battleye::packet_id::REQUEST:
{
// IF NOT FRAGMENTED RESPOND IMMEDIATELY, ELSE ONLY RESPOND TO THE LAST FRAGMENT
const auto respond =
!header->fragmented() ||
(header->fragment.index == header->fragment.count - 1);
if (!respond)
return;
// SEND BACK HEADER
battleye::delegate::o_send_packet(received_packet, sizeof(battleye::be_packet_header));
switch (header->sequence)
{
case 0x01:
{
battleye::delegate::respond(header->sequence,
{
// REDACTED BUFFER
});
break;
}
case 0x02:
{
battleye::delegate::respond(header->sequence,
{
// REDACTED BUFFER
});
break;
}
default:
break;
}
break;
}
Which uses the following helper function for responses:
void battleye::delegate::respond(
std::uint8_t response_index,
std::initializer_list<std::uint8_t> data)
{
// SETUP RESPONSE PACKET WITH TWO-BYTE HEADER + NO-FRAGMENTATION TOGGLE
const auto size = sizeof(battleye::be_packet_header) +
sizeof(battleye::be_fragment::count) +
data.size();
auto packet = std::make_unique<std::uint8_t[]>(size);
auto packet_buffer = packet.get();
packet_buffer[0] = (battleye::packet_id::RESPONSE); // PACKET ID
packet_buffer[1] = (response_index - 1); // RESPONSE INDEX
packet_buffer[2] = (0x00); // FRAGMENTATION DISABLED
for (size_t i = 0; i < data.size(); i++)
{
packet_buffer[3 + i] = data.begin()[i];
}
battleye::delegate::o_send_packet(packet_buffer, size);
}
BottlEye
The full BottlEye project can be found on our GitHub repository. Below you can see this specific project being used in various popular video games.
Fortnite
The following video contains a live demonstration of my BottlEye project being used in the BattlEye-protected game Fortnite. In the video I live debug fortnite while playing online to prove that BattlEye is not loaded.
Insurgency
The following screenshot shows the BattlEye-protected game Insurgency running on Arch in Wine.
Escape from Tarkov
The following screenshot shows the usage of Cheat Engine in the popular, battleye-protected game Escape from Tarkov. This is possible because BattlEye has been replaced with BottlEye on disk.
Thanks to
- Sabotage
- Tamimego
- Atex
- namazso