浅谈单文件通信题防作弊
CuteMurasame · · 科技·工程
本文部分代码使用了 DeepSeek 和 Gemini 辅助实现。
0x01 基本原理
假设有一个通信题:Alice 处理输入数据,生成信息传递给 Bob,由 Bob 输出最终答案。传统 grader 直接顺序调用函数:
string a_msg = Alice();
string b_msg = Bob(a_msg);
两个函数共享内存空间,可能通过全局变量等隐蔽方式传递额外信息,这在通信题中是不允许的。
那怎么办呢?让每个角色在独立进程中运行,通过管道传递数据就可以了。
先讲一下 fork 的用法:
-
执行
fork()
会创建当前进程的副本; -
父进程获得子进程的 PID,子进程获得 0;
-
示例:
pid_t pid = fork(); if (pid == 0) { // 子进程执行这里 exit(0); // 必须退出 } else { // 父进程执行这里 }
再讲一下 pipe:
-
创建两个文件描述符:读端和写端;
-
数据单向流动。
int pipefd[2]; pipe(pipefd); // pipefd[0] 读,pipefd[1] 写
实现如下:
- 主进程读取所有输入数据
- 创建 Alice 进程 → 运行 Alice 函数 → 结果写入管道
- 主进程收集 Alice 的结果
- 创建 Bob 进程 → 传入收集的结果 → 输出最终答案
那你可能就会问了,Bob 只能在 Alice 完成后执行,为什么不能读取全局变量实现作弊呢?
原因是每个进程都有完全独立的内存空间, 只能通过 pipe(管道)传递信息,不能直接读取其他进程的全局变量等信息。
原理是:
- 当父进程调用
fork()
时,操作系统会完整复制内存页,但从此之后,两个进程完全独立,互不影响。 - 通过管道发送数据时,只传递数据本身,不共享内部的任何内存。
假设选手试图作弊:
// 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 "...";
}
实际效果是:
- Alice 修改的是自己进程的
secret_msg
。 - Bob 进程中的
secret_msg
是 fork 时复制的初始值(0)。 - 两者内存地址不同,修改互不可见。
- 就算先创建 Alice 的进程,再创建 Bob 进程,Alice 对于全局变量的修改也不会影响父进程,而 Bob 是在父进程下创建的,复制的是父进程的内存,不能访问 Alice 修改过后的全局变量。
示例:
// 创建管道
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)子进程时:
-
子进程会继承父进程所有已打开文件描述符的副本。这意味着,如果父进程中 _mem_fd 的值是 3,那么子进程中的文件描述符 3 将指向完全相同的内存文件对象。
-
变量 _mem_fd 作为进程内存映像的一部分,也会被复制到子进程中。因此,Alice 和 Bob 在各自的进程中都能正确使用该文件描述符编号。
为防范此类基于文件描述符的隐蔽通道及类似攻击,grader 必须确保子进程仅保留明确需要的文件描述符处于打开状态。
于是我们就可以得到一个策略:
- 保留必要文件描述符
STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
。- 许多系统调用和库函数(如
printf
、perror
)隐式依赖这三个基础文件描述符。若完全关闭它们,当程序意外尝试读写标准流时,可能触发 SIGPIPE 信号或 EBADF 错误,导致非预期崩溃。
- 许多系统调用和库函数(如
- 保留父子进程通信需要的关闭文件描述符,并关闭从
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_FILENO
、STDOUT_FILENO
和 STDERR_FILENO
,但是子进程并不需要这些文件描述符,所以我们可以把这些文件描述符直接指向 /dev/null
。
从 /dev/null
读入:
- 当你尝试从
/dev/null
读取数据时,它会立即返回 EOF。 - 这意味着你无法从中读取到任何实际数据。
写入到 /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
目录文件
- System V 共享内存可以参考这份代码:https://www.luogu.me/paste/x1y047ue;
- POSIX:https://www.luogu.com.cn/discuss/1082347 和 https://www.luogu.me/paste/3canq0tn;
- 修改
/proc
目录文件:https://www.luogu.com.cn/discuss/1082497。
我们可以实现一个基于 seccomp-bpf 的安全过滤器,用于限制进程可以执行的系统调用。通过这种方法,以上三种 hack 均可被防范。我们禁止了以下系统调用:
- System V 共享内存:禁止
shmget
,shmat
,shmdt
,shmctl
; - POSIX 消息队列:禁止
mq_open
,mq_timedsend
,mq_timedreceive
,mq_unlink
,mq_notify
,mq_getsetattr
; - 进程创建:禁止
fork
,clone
,clone3
; - 内存文件描述符:禁止
memfd_create
。
由于思路比较简单,直接放代码吧。
// --- 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。