Apex Legends itself has a distinct network message for exactly this purpose: Exchanging anti-cheat related traffic in both directions (client/server). This is why i have opted with using the already existing mechanism that EasyAntiCheat themselves use to communicate back and forth from game server to client (and vice versa). Since it is unused in R5Reloaded because of Easy Anti-Cheat being absent in R5R, we can freely use it to communicate.

Server Setup (Scout)

For subscribing to network messages of type CLC_AntiCheat, you can simply overwrite it’s handler in the read-only virtual method table. This will allow Scout to receive messages with ID CLC_AntiCheat.

void Drause_scout::init()
{
    auto svc_anticheat_vft = reinterpret_cast<u64*>(CURR_PROC_BASE() + 0x133DB28);
    tools::write_prot_val<u64>(BASE_OF(&svc_anticheat_vft[3]), 
      BASE_OF(CLC_AntiCheat_Process));
}
bool __fastcall CLC_AntiCheat_Process(CLC_SVC_AntiCheat* inst)
{
    gsc->process_ac_client_msg(inst);
    return true;
}

We’ll need to build some functionality around processing the raw anti-cheat network messages, and make them easier to deal with for our higher level code base.

void Drause_scout::process_ac_client_msg(CLC_SVC_AntiCheat* pmsg)
{
    auto client = pmsg->get_client() - 0x8;
    auto base_packet = reinterpret_cast<PacketHdr*>(pmsg->m_data);
    lock_guard_srw_exc _(&m_players.lock());
    msg("[ACC] message: %x, %lld", base_packet->id, base_packet->len);
    auto record = m_players.get(client);
    if (!record)
    {
        msg("Drause_scout::process_ac_client_msg: received client message for unknown client %p", client);
        return;
    }
    process_proto_msg(record, base_packet);
}

In order to not unecessarily over complicate this post, i’ll keep the handlers simple:

void Drause_scout::process_proto_msg(Player* record, PacketHdr* base_packet)
{
    auto client = record->base;
    switch (base_packet->id)
    {
    case DAC_PACKET_ID_HANDSHAKE:
    {
        auto packet = reinterpret_cast<CL_Handshake*>(base_packet);
        msg("cl_handshake_ack(%d, %d))", packet->client_version_generic, packet->client_version_proto);
        msg("  GBV='%s' GBID='%s'", packet->game_build_ver, packet->game_build_id);
        msg("client game base: %p", packet->client_game_base);
        record->game_base = packet->client_game_base;
        record->game_len = packet->client_game_length;
        break;
    }
    case DAC_PACKET_ID_HEARTBEAT:
    {
        auto packet = reinterpret_cast<Heartbeat*>(base_packet);
        record->hbp_last_recv_time = tools::curtime();
        if (!record->hbp_received) record->hbp_received = true;
        record->last_hbp = *packet;
        msg("heartbeat(%s, %f A:%i S:%lld)", r5s::get_client_name(client).c_str(),
            packet->time_stamp, packet->ack, packet->seq);
        if (packet->seq == record->heartbeat_seq)
            record->heartbeat_seq++;
        else
        {
            fun_msg("failed verification of the client's heartbeat: expected %lld but got %lld",
                record->heartbeat_seq, packet->seq);
            record->kick("The client is not running the anti-cheat, or has failed the anti-cheat authentication");
        }
        break;
    }
    case DAC_PACKET_ID_HARDWARE_INFO:
    {
        // ...
        break;
    }
    case DAC_PACKET_ID_OBJECT_INSTANCE_VMTP:
    {
        // ...
        break;
    }
    default:
        msg("unknown packet for client %p -> %x", client, base_packet->id);
        break;
    }
}

Client Setup (Client)

We can do something very similar for Client, but this time it will be for SVC_AntiCheat:

void Drause_client::init()
{
	msg("******* DRAUSE_CLIENT INIT ******");
	/* overwrite the SVC_AntiCheat vft so that we get notified each time the server sends an AC message. */
	auto svc_anticheat_vft = reinterpret_cast<u64*>(CURR_PROC_BASE() + 0x1344500);
	tools::write_prot_val<u64>(BASE_OF(&svc_anticheat_vft[3]), 
      BASE_OF(SVC_AntiCheat_Process));
}
bool __fastcall SVC_AntiCheat_Process(CLC_SVC_AntiCheat* inst)
	gcl->process_ac_server_msg(inst);
	return true;
}

Perfect! Now with everything we have set up so far, we’re ready to:

  • receive anti-cheat messages from a game client, on a game server, through the scout extension that is loaded into an active game server instance (r5apex_ds.exe)
  • receive anti-cheat messages from a game server, on a game client, through the client extension that is loaded into an active game client instance (r5apex.exe)

Lastly, let’s implement some handlers for basic anti-cheat operation.

void Drause_client::process_ac_server_msg(CLC_SVC_AntiCheat* pmsg)
{
	auto base_packet = reinterpret_cast<PacketHdr*>(pmsg->m_data);
	msg("[ACS] message: %x", base_packet->id);
	switch (base_packet->id)
	{
	case DAC_PACKET_ID_SRV_HANDSHAKE:
	{
		auto svpacket = reinterpret_cast<SV_Handshake*>(base_packet);
		std::thread(&Drause_client::bootstrap_communication, this, svpacket).detach();
		break;
	}
	case DAC_PACKET_ID_SRV_RUN_SCANS:
	{
		auto svpacket = reinterpret_cast<SV_RunScans*>(base_packet);
		msg("received run scan request from server for %llx.", svpacket->scan_id);
		switch (svpacket->scan_id)
		{
		    // implement client-side routines for game server to conditionally invoke.
		}
		break;
	}
	default:
		msg("[drause] unknown packet from server -> %x", base_packet->id);
		break;
	}
}

Conclusion

After figuring out how to send anti-cheat messages from both game server & client, we have a bidirectional communication channel, ready to use for anti-cheat traffic. It’s possible to sniff this traffic if somebody is hooking into the engine’s networking subsystem. Easy Anti-Cheat for example is applying additional transformations over the anti-cheat data before passing it into CLC_AntiCheat and finally sending it with C_NetChan::SendNetMessage. This makes it much harder to sniff, even with hooks.