Example: MCR UDP

This is a step‑by‑step example of how to write an MCR countermeasure with UDP authentication (protocol description) to get hands‑on experience with BPF countermeasure programming. The final program will be under 100 lines of code.

You will need:

  • MITIGATOR v20.06 or higher.
  • Ability to run commands from the terminal.
  • Basic knowledge of the C language (explanations will be provided as we go).
  • Understanding that packets have protocols, headers, and payloads.

Environment Setup

Any operating system will work, but Linux is recommended. You will need a text editor for coding and a compiler. You can use Visual Studio Code or any other editor. To compile to EBPF, you need to install Clang. On Debian and Ubuntu, you can do this with the following command:

apt install -y clang llvm make

First Program

Create a directory for your programs and download mitigator_bpf.h (ref.) into it.

In the same directory, create mcr.c with the following code:

#include "mitigator_bpf.h"

ENTRYPOINT enum Result
mcr(Context ctx) {
    return RESULT_DROP;
}

PROGRAM_DISPLAY_ID("MCR UDP tutorial v0.1")

ENTRYPOINT marks the function where execution starts. It accepts the filtering context ctx, which must be passed to most functions called by the program. The program returns the desired action with the packet: to drop it (RESULT_DROP). So our program drops all packets. The function name doesn’t matter, but it cannot be main().

PROGRAM_DISPLAY_ID(...) defines short description displayed in the user interface when the program is loaded. The parameter is mandatory; the format is arbitrary. It helps users identify which program is currently loaded.

To compile the program, open a terminal in the directory containing it and enter:

clang -c -emit-llvm -fno-stack-protector -O2 mcr.c -o - | \
    llc -march=bpf -filetype=obj -o=mcr.o

If everything goes well, there will be no output, but mcr.o (the object file) will be created.

Now you can upload mcr.o file into the BPF countermeasure and enable it. Any traffic sent through the policy will now be dropped. By capturing it with the PCAP function, you can verify that it was dropped by the BPF countermeasure i.e., by our program.

Development Automatization

To avoid typing or searching for the command every time, you can put it in a script: build.sh (Linux and Mac) or build.bat (Windows). On Linux and Mac, you’ll need to make the script executable:

chmod +x build.sh

A more professional approach is to create Makefile with the following content (note: use TABs, not spaces, for indentation — this is important):

.PHONY: all
all: mcr.o

%.o: %.c: mitigator_bpf.h
	clang -c -emit-llvm -fno-stack-protector -O2 $< -o - | \
	    llc -march=bpf -filetype=obj -o=$@

After this, you can trigger the build with the make command. Some editors automatically detect Makefile and enable hotkeys for building.

Tip

Production programs usually go through many versions, fixes, and variants. Instead of creating multiple files like mcr_1.c, mcr_2_fix.c, etc., we recommend to learn Git version control system (not required to continue).

Traffic Passing Based On The Verified Clients Table

The countermeasure must pass packets only from clients whose IP addresses have passed MCR verification. To achieve this, you need to store a list (table) of verified clients.

MITIGATOR provides a table containing up to 1 million entries to each program. Each entry has an 8‑byte key, an 8‑byte value and a timestamp of the last update.

The keys are used for entry lookups, the associated data is stored in value field. Entries can be added or updated by the key, last-modified timestamp is updated automatically.

Why is the timestamp update needed? Entries are removed from the table only in the background when they haven’t been updated for too long (the lifetime of entries is configurable per protection policy). The program must regularly update entries that should remain in the table even if their value did not change.

To pass only the packets from IPs present in the table, you must:

  1. Extract the source IP address from the packet.
  2. Look up that address in the table.
  3. If the address is found, pass the packet; otherwise, drop it.

Getting Source IP

Source IP address is located in the IPv4 header. MITIGATOR provides a function to retrieve it:

void* packet_network_header(Context ctx);

Like all other functions, packet_network_header() is documented in API reference.

The header is described by the following structure:

struct IpHeader {
    ...
    uint32_t ip_src;
    ...
};

Add header retrieval to the beginning of the mcr() function:

struct IpHeader* ip = packet_network_header(ctx);
Info

We use packet_network_header() directly because we’re processing only IPv4 traffic now. Otherwise, we’d first need to check packet_network_proto() and handle IPv4 or IPv6 depending on the response.

Table Look Up

There are two functions to look up an entry by key:

Bool table_find(Context ctx, TableKey key, struct TableRecord* record);
Bool table_get(Context ctx, TableKey key, struct TableRecord* record);

They differ in that table_get() updates the entry’s timestamp if the entry is found. This is exactly what we need for MCR: the entry should stay alive as long as packets keep coming from the client.

We already have the context; the key will be the source address (TableKey is a 64‑bit integer). If an entry with that key exists in the table, the function returns true and writes the found value and last‑update timestamp into a struct TableRecord variable. In that case, the program should return RESULT_PASS indicating the packet should be passed. The function now looks like this:

ENTRYPOINT enum Result
mcr(Context ctx) {
    struct IpHeader* ip = packet_network_header(ctx);

    struct TableRecord record;
    if (table_get(ctx, ip->ip_src, &record)) {
        return RESULT_PASS;
    }

    return RESULT_DROP;
}

Compile the program and upload the new version to MITIGATOR.

Testing Filtration By Table

Our program doesn’t yet add verified clients to the table, but it can be done manually via the BPF countermeasure interface. The entry key is entered as a sequence of bytes in hexadecimal form. For example, to add the address 192.0.2.1, its bytes are: 0a 00 02 01. You can leave the value as zero, the program doesn’t use it.

Convenient commands for conversion:

  • If you’re typing the address manually: printf "%02x %02x %02x %02x\n" 192 0 2 1
  • If you can paste the address or have a list of addresses: tr -d \n | tr . ' ' | xargs -n4 printf "%02x %02x %02x %02x\n"

If you now send traffic from the added address, it will be passed; any other traffic will be dropped.

Info

When you upload a new program, the old table is kept. This lets you change the logic without losing the list of verified clients. If you do not need this, for example, during debugging, you should manually reset the table.

Packet Classification

The packets processed by the countermeasure fall into one of five cases:

  • If the protocol is not UDP, check the source IP address and either pass or drop the packet.

  • If the protocol is UDP:

    • If the packet length is not 400, perform a table lookup (see above).
    • If the payload starts with MCRH3110, send back a challenge packet.
    • If the payload starts with MCRR, verify the challenge response.
    • Otherwise, perform a table lookup (see above).

Let’s outline the program structure: we’ll handle each case in a separate function. The existing program already implements the table lookup logic. Since this logic is reused, it has been moved to the process_other() function.

#include "mitigator_bpf.h"

LOCAL enum Result
process_other(Context ctx) {
    struct IpHeader* ip = packet_network_header(ctx);

    struct TableRecord record;
    if (table_get(ctx, ip->ip_src, &record)) {
        return RESULT_PASS;
    }

    return RESULT_DROP;
}

LOCAL enum Result
process_mcr_request(Context ctx, void* payload) {
    return RESULT_PASS; /* TODO */
}

LOCAL enum Result
process_mcr_response(Context ctx, void* payload) {
    return RESULT_PASS; /* TODO */
}

LOCAL enum Result
process_udp(Context ctx) {
    /* TODO */
}

ENTRYPOINT enum Result
mcr(Context ctx) {
    if (packet_transport_proto(ctx) == IP_PROTO_UDP) {
        return process_udp(ctx);
    }
    return process_other(ctx);
}

PROGRAM_DISPLAY_ID("MCR UDP tutorial v0.2")

The LOCAL macro is required for all helper functions, otherwise, the compiler might generate code that fails validation.

The protocol is checked using packet_transport_proto() instead of IPv4 header analysis. This approach is more universal, slightly faster, and demonstrates one more available function.

When processing UDP packets, you need to analyze both the packet length and its payload. A dedicated function is provided to retrieve these:

LOCAL enum Result
process_udp(Context ctx) {
    uint16_t length = 0;
    void* payload = packet_transport_payload(ctx, &length);

First, let’s check the data length:

    if (length != 400) {
        return process_other(ctx);
    }

We will write the MCR message signatures into constants, for convenience:

    const char MCRH3110[] = {'M', 'C', 'R', 'H', '3', '1', '1', '0'};
    const char MCRR[] = {'M', 'C', 'R', 'R'};

To avoid comparing each byte of the signature with the corresponding data byte, we use a trick. We assume that payload doesn’t point to 8 bytes of the signature, but to a single 8‑byte number (uint64_t), just like the MCRH3110 array. Then we compare these numbers i.e., the 8‑byte blocks:

    if (*(uint64_t*)payload == *(uint64_t*)MCRH3110) {
        return process_mcr_request(ctx, payload);
    }

Similarly, for the 4‑byte MCRR signature:

    if (*(uint32_t*)payload == *(uint32_t*)MCRR) {
        return process_mcr_response(ctx, payload);
    }

If none of the signatures are detected, the packet is processed in the general way:

    return process_other(ctx);
}

Testing Packet Classification

Compile the program and upload it to MITIGATOR. You will notice that the displayed version has changed to ...v0.2.

Ping packets should always be dropped. If they aren’t, then make sure the table is empty.

To test, run a script that simulates the MCR verifier side on the protected server:

python3 mcr.py --host 0.0.0.0 --port 1234 --key_raw mysecret --udp server

Then, an MCR client script should be able to authenticate, since at this stage the program passes MCR client packets (192.0.2.1 is the server’s address):

python3 mcr.py --host 192.0.2.1 --port 1234 --key_raw mysecret --udp client

MCR Protocol Implementation

Issuing a Challenge

Let’s define the MCR challenge message as a structure:

struct Challenge {
    char signature[4];
    uint32_t cookie;
};

We’ll use this structure to fill the response payload:

LOCAL enum Result
process_mcr_request(Context ctx, void* payload) {
    struct Challenge* challenge = (struct Challenge*)payload;

The first four bytes must be filled with the MCRC signature:

    challenge->signature[0] = 'M';
    challenge->signature[1] = 'C';
    challenge->signature[2] = 'R';
    challenge->signature[3] = 'C';

Now we need to generate a cookie that:

  • is tied to the client (i.e., to its UDP traffic);
  • is valid for a limited time;
  • includes secret data, so that it cannot be counterfeited.

This is a typical task for challenge‑response mechanisms, so MITIGATOR provides a dedicated function:

Cookie cookie_make(Context ctx, const struct Flow* flow);

Flow structure describes the data flow the packet belongs to. For UDP, this includes the source and destination IP addresses, source and destination ports. Here, flow defines the binding to the client. For MCR UDP, we can use all flow attributes. Conversely, if the client could change ports in the response, we wouldn’t need to include the port in the flow. To make the cookie independent of certain struct Flow fields, zero out those fields. The Cookie type is a 32‑bit integer (uint32_t), which matches the packet format.

To form the struct Flow, use:

    struct Flow flow;
    packet_flow(ctx, &flow);

Fill in the cookie in the response:

    challenge->cookie = cookie_make(ctx, &flow);

The packet must be sent back, so the function result is RESULT_BACK. However, first trim the packet to 8 bytes:

    set_packet_length(ctx, sizeof(Challenge)); /* sizeof(Challenge) == 8 */
    return RESULT_BACK;
}

Verifying the Response

Define the response packet for process_mcr_response() similarly to the challenge packet:

struct Response {
    char signature[4];
    uint32_t cookie;
    uint32_t hash1;
    uint32_t hash2;
};

LOCAL enum Result
process_mcr_response(Context ctx, void* payload) {
    struct Response* response = (struct Response*)payload;

The response signature has already been validated in process_udp(). Now we need to check whether the cookie from the response matches the value generated by cookie_make() for this client (its struct Flow). The cookie_check() function is designed for this:

    struct Flow flow;
    packet_flow(ctx, &flow);
    if (!cookie_check(ctx, &flow, response->cookie)) {
        return RESULT_DROP;
    }

Next, we must verify the challenge result using the secret key. For simplicity, let’s first define the key as a constant with fixed length:

    const uint8_t key_data[] = {'m', 'y', 's', 'e', 'c', 'r', 'e', 't'};
    const uint32_t key_size = 8;

Perform the calculations according to the protocol specification:

    uint32_t cookie_hash = crc32_32(response->cookie, 0);
    uint32_t hash1 = crc32_data(key_data, key_data + key_size, cookie_hash);
    uint32_t hash2 = crc32_32(response->cookie, hash1);
    if ((response->hash1 == hash1) && (response->hash2 == hash2)) {
        table_put(ctx, flow.saddr, 0);
    }
    return RESULT_DROP;
}

We use 0 as the value because it isn’t checked during validation.

Program Parameters

Changing a constant key is inconvenient: you have to recompile the program and reload it into MITIGATOR. MITIGATOR provides a 1 KB of memory for parameters to each program. You can obtain a pointer to this memory as follows:

    const uint8_t* key_data = parameters_get(ctx);

After this change, you can set the secret key via the BPF countermeasure settings without modifying the program. If parameters aren’t specified, or if the program attempts to read more data than was provided in the parameters, the missing portion is filled with zeros.

Testing MCR Operation

Compile the program and upload it into the countermeasure, then enable it.

Set the program parameters through the countermeasure’s configuration. Like table keys, parameters are specified as hexadecimal bytes. For example, mysecret is written as 6D 79 73 65 63 72 65 74. Use the following command to convert text to hex:

echo -n 'mysecret' | hexdump -v -e '1/1 "%02X "'

If you run ping 192.0.2.1 (the server’s address), all packets will be dropped.

If you run the client script with the correct key, the client’s IP address will be added to the verified clients table. A subsequent ping command will show that packets from the client’s address are now being passed.