浅谈单文件通信题防作弊

· · 科技·工程

本文部分代码使用了 DeepSeek 和 Gemini 辅助实现。

0x01 基本原理

假设有一个通信题:Alice 处理输入数据,生成信息传递给 Bob,由 Bob 输出最终答案。传统 grader 直接顺序调用函数:

string a_msg = Alice();
string b_msg = Bob(a_msg);

两个函数共享内存空间,可能通过全局变量等隐蔽方式传递额外信息,这在通信题中是不允许的。

那怎么办呢?让每个角色在独立进程中运行,通过管道传递数据就可以了。

先讲一下 fork 的用法:

再讲一下 pipe:

实现如下:

  1. 主进程读取所有输入数据
  2. 创建 Alice 进程 → 运行 Alice 函数 → 结果写入管道
  3. 主进程收集 Alice 的结果
  4. 创建 Bob 进程 → 传入收集的结果 → 输出最终答案

那你可能就会问了,Bob 只能在 Alice 完成后执行,为什么不能读取全局变量实现作弊呢?

原因是每个进程都有完全独立的内存空间, 只能通过 pipe(管道)传递信息,不能直接读取其他进程的全局变量等信息。

原理是:

假设选手试图作弊:

// Alice.cpp
static int secret_msg; // 全局变量

string Alice() {
    secret_msg = 114514; // 设置暗号
    return "...";
}

// Bob.cpp
extern int secret_msg; // 试图读取Alice的暗号

string Bob() {
    cout << secret_msg; // 输出 0(独立内存空间)
    return "...";
}

实际效果是:

示例:

// 创建管道
int alice_pipe[2];
pipe(alice_pipe);

// 创建子进程
if (fork() == 0) {
    close(alice_pipe[0]);  // 关闭读端
    string msg = Alice(data);
    write(alice_pipe[1], msg.c_str(), 3000);
    exit(0);
}
// 主进程关闭写端
close(alice_pipe[1]);

char buffer[3001];
read(alice_pipe[0], buffer, 3000);
string a_msg(buffer, 3000);
// a_msg 中就是 Alice 的返回结果,可以通过验证 a_msg 确保反作弊

需要注意,这种方法并不是多线程线程共享内存空间,无法防止通过全局变量作弊,而进程之间的内存是独立的。

https://www.luogu.com.cn/problem/P12509 的 grader 就配置了这种反作弊。

此题第一版 grader(并不能完全防作弊):https://www.luogu.me/paste/4da1no0o。

0x02 更多的作弊方式 & 防范策略

1. memfd

// by Moeebius
#include <iostream>
#include <string>

#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

namespace {
int _mem_fd;
struct _Hack {
  _Hack() { _mem_fd = memfd_create("qwq", 0); }
} _hack;
char _buf[1 << 20];
} // namespace

int Alice(std::string S) {
  lseek(_mem_fd, 0, SEEK_SET);
  ftruncate(_mem_fd, S.length());
  write(_mem_fd, S.data(), S.length());
  return 0;
}
int Bob(std::string T, int X) {
  lseek(_mem_fd, 0, SEEK_SET);
  read(_mem_fd, _buf, (1 << 20));
  for (int i = 0; i < T.length(); i++)
    if (_buf[i] != T[i])
      return i + 1;
  return 0;
}

对于这份代码,_Hack 结构体的构造函数在 grader 启动时运行。它会调用 memfd_create("qwq", 0),创建一个匿名内存文件并返回一个文件描述符。

当 grader 创建 Alice(或 Bob)子进程时:

为防范此类基于文件描述符的隐蔽通道及类似攻击,grader 必须确保子进程仅保留明确需要的文件描述符处于打开状态。

于是我们就可以得到一个策略:

  1. 保留必要文件描述符 STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO
    • 许多系统调用和库函数(如 printfperror)隐式依赖这三个基础文件描述符。若完全关闭它们,当程序意外尝试读写标准流时,可能触发 SIGPIPE 信号或 EBADF 错误,导致非预期崩溃。
  2. 保留父子进程通信需要的关闭文件描述符,并关闭从 STDERR_FILENO+1 到系统最大文件描述符上限(通过 sysconf(_SC_OPEN_MAX) 获取)的其他描述符。

示例代码:

// pipe_for_child_to_write 是子进程需要与父进程通信的文件描述符
void sanitize_child_fds_comprehensively(int pipe_for_child_to_write) {
    long max_fd = sysconf(_SC_OPEN_MAX);

    for (int fd = STDERR_FILENO + 1; fd < max_fd; ++fd) {
        if (fd != pipe_for_child_to_write) { // 不要关闭必要管道!
            close(fd);
        }
    }
}

但是这份代码还是可以绕过我们的处理:

// by Moeebius
#include <iostream>
#include <string>
#include <cassert>

#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

namespace {
int _mem_fd;
struct _Hack {
  _Hack() {
    _mem_fd = _mem_fd = memfd_create("qwq", 0); 
    dup2(_mem_fd, 2); // 将 _mem_fd 复制到标准错误(FD 2)
    close(_mem_fd), _mem_fd = 2; // 关闭原 _mem_fd,将 _mem_fd 指向标准错误(FD 2)
  }
} _hack;
char _buf[1 << 20];
} // namespace

int Alice(std::string S) {
  lseek(_mem_fd, 0, SEEK_SET);
  ftruncate(_mem_fd, S.length());
  assert(write(_mem_fd, S.data(), S.length()) == S.length());
  return 0;
}
int Bob(std::string T, int X) {
  lseek(_mem_fd, 0, SEEK_SET);
  read(_mem_fd, _buf, (1 << 20));
  for (int i = 0; i < T.length(); i++)
    if (_buf[i] != T[i])
      return i + 1;
  return 0;
}

我们不能关闭必要文件描述符 STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO,但是子进程并不需要这些文件描述符,所以我们可以把这些文件描述符直接指向 /dev/null

/dev/null 读入:

写入到 /dev/null

示例代码:

// pipe_for_child_to_write 是子进程需要与父进程通信的文件描述符
// 该函数需在子进程中运行。
void sanitize_child_fds_comprehensively(int pipe_for_child_to_write) {
    // 将 STDIN、STDOUT、STDERR 重定向到 /dev/null
    int dev_null_fd_read = open("/dev/null", O_RDONLY);
    if (dev_null_fd_read == -1) { _exit(EXIT_FAILURE); }
    if (dup2(dev_null_fd_read, STDIN_FILENO) == -1) { _exit(EXIT_FAILURE); }
    close(dev_null_fd_read);

    int dev_null_fd_write = open("/dev/null", O_WRONLY);
    if (dev_null_fd_write == -1) { _exit(EXIT_FAILURE); }
    if (dup2(dev_null_fd_write, STDOUT_FILENO) == -1) { _exit(EXIT_FAILURE); }
    if (dup2(dev_null_fd_write, STDERR_FILENO) == -1) { _exit(EXIT_FAILURE); }
    close(dev_null_fd_write);

    long max_fd = sysconf(_SC_OPEN_MAX);

    for (int fd = STDERR_FILENO + 1; fd < max_fd; ++fd) {
        if (fd != pipe_for_child_to_write) { // 不要关闭必要管道!
            close(fd);
        }
    }
}

2. System V 共享内存 / POSIX / 修改 /proc 目录文件

我们可以实现一个基于 seccomp-bpf 的安全过滤器,用于限制进程可以执行的系统调用。通过这种方法,以上三种 hack 均可被防范。我们禁止了以下系统调用:

由于思路比较简单,直接放代码吧。

// --- seccomp-bpf 所需的头文件 ---
#include <errno.h>                // 错误号定义
#include <stddef.h>               // 提供 offsetof 宏,用于获取结构体成员偏移量  
#include <sys/prctl.h>           // prctl() 系统调用接口,用于进程控制
#include <sys/syscall.h>         // 系统调用号定义(如 __NR_shmget)
#include <linux/audit.h>         // 系统架构常量(如 AUDIT_ARCH_X86_64)
#include <linux/filter.h>        // BPF 过滤器指令定义(如 BPF_STMT)
#include <linux/seccomp.h>       // seccomp 模式定义(如 SECCOMP_MODE_FILTER)

// --- 架构检测(检查当前 CPU 架构)---
#if defined(__x86_64__)
#define ARCH_NR AUDIT_ARCH_X86_64  // x86_64 架构
#elif defined(__i386__)
#define ARCH_NR AUDIT_ARCH_I386    // 32 位 x86 架构
#elif defined(__aarch64__)
#define ARCH_NR AUDIT_ARCH_AARCH64 // ARM64 架构
#elif defined(__arm__)
#define ARCH_NR AUDIT_ARCH_ARM     // ARM 架构(32 位)
#else
#warning "当前架构未明确支持,seccomp 过滤器可能无法正常工作"
#define ARCH_NR AUDIT_ARCH_X86_64  // 默认使用 x86_64,可能不兼容
#endif

// 定义BPF过滤器:专门拦截 System V IPC 和进程创建等危险系统调用
struct sock_filter ipc_block_filter[] = {
    /* 第 1 步:验证系统调用架构 */
    // 加载 seccomp_data 结构中的 arch 字段(系统调用的架构)
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))),
    // 如果架构不匹配,跳转到下一条指令(即 KILL)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH_NR, 1, 0),
    // 架构不匹配时直接杀死进程(安全防护)
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

    /* 第 2 步:加载系统调用号 */
    // 获取 seccomp_data 结构中的 nr 字段(系统调用号)
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr))),

    /* 第 3 步:禁止 System V 共享内存相关系统调用 */
    // 拦截 shmget(创建共享内存段)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_shmget, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),  // 返回 EPERM 错误
    // 拦截 shmat(附加共享内存段)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_shmat, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 shmdt(分离共享内存段)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_shmdt, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 shmctl(控制共享内存段)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_shmctl, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    /* 第 4 步:禁止 POSIX 消息队列相关系统调用 */
    // 拦截 mq_open(打开消息队列)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_open, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 mq_timedsend(发送消息,mq_send 的底层实现)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_timedsend, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 mq_timedreceive(接收消息,mq_receive 的底层实现)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_timedreceive, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 mq_unlink(删除消息队列)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_unlink, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 mq_notify(注册消息通知)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_notify, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 mq_getsetattr(获取/设置消息队列属性)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mq_getsetattr, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    /* 第 5 步:禁止进程创建相关系统调用 */
    // 拦截 fork(创建子进程)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fork, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 clone(创建线程/进程)
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clone, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    // 拦截 clone3(新版进程创建,部分系统可能不支持)
#ifdef __NR_clone3
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clone3, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
#endif

    /* 第 6 步:禁止 memfd_create(匿名内存文件)*/
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_memfd_create, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),

    /* 第 7 步:默认允许其他所有系统调用 */
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

// 定义完整的 BPF 程序(包含指令数组和长度)
struct sock_fprog ipc_block_prog = {
    .len = (unsigned short)(sizeof(ipc_block_filter) / sizeof(ipc_block_filter[0])),  // 计算指令数量
    .filter = ipc_block_filter,  // 指向过滤器指令数组
};

// 应用 seccomp 过滤器的函数
bool apply_ipc_seccomp_filter() {
    // 1. 禁止进程获得新权限(安全必需)
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { 
        return false;  // 失败返回 false
    }
    // 2. 加载 BPF 过滤器
    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &ipc_block_prog) == -1) { 
        return false; 
    }
    return true;  // 成功返回 true
}
// --- seccomp 过滤器设置结束 ---

我们在子进程里执行 apply_ipc_seccomp_filter() 即可达成防范目的。

0x03 最终 grader

请直接查看 https://www.luogu.me/paste/ruf0tujc,里面是 P12509 的 grader。