1. 引言

CTF(Capture The Flag,夺旗赛)是网络安全领域最具代表性的竞技形式之一,参赛者需要在限定时间内破解题目、获取 Flag 并提交得分。随着高校网络安全实验室、企业安全团队内部训练的需求日益增长,自建一套轻量、可控、可定制的 CTF 实战平台成为许多团队的选择。

市面上成熟的 CTF 平台(如 CTFd、H1ve、Cardinal)大多基于 Python/Flask 或 Node.js 构建,部署方便但底层细节被高度封装。对于想要深入理解「系统如何工作」的开发者来说,使用 C 语言从零设计并实现一个 CTF 实战系统,是一次极佳的系统编程与网络编程训练。C 语言贴近操作系统底层,能够帮助开发者透彻掌握 socket 通信、并发处理、内存管理、权限控制等关键机制,同时保持极高的运行性能和极低的资源占用。

本文将完整介绍如何用 C 语言设计并开发一套可实际运行的 CTF 实战系统,涵盖系统架构、核心模块、关键代码实现、题目设计与安全性分析。

2. 系统总体设计

2.1 需求分析

一个最小可用的 CTF 实战系统,至少需要满足以下需求:

  • 用户系统:支持注册、登录、身份认证与会话管理;
  • 题目系统:支持题目的添加、展示、按类型与难度分类;
  • 判题系统:接收用户提交的 Flag,校验正确性并实时更新得分;
  • 排行榜:按照积分与提交时间动态排名;
  • 管理后台:支持管理员维护题目、监控用户状态。

在此基础上,还可以扩展实时动态分值(题目分值随解题人数增加而衰减)、提示系统队伍模式等进阶功能。

2.2 系统架构

本系统采用经典的「客户端-服务器」架构。服务器是核心,题目以「独立二进制容器」的形式在本地运行,用户通过 Web 浏览器或自制客户端与服务器交互。整体架构如下:

用户浏览器/客户端

CTF 服务器主进程

用户认证模块

题目管理模块

Flag 判题模块

排行榜模块

用户数据文件

题目目录(二进制 + 描述)

动态分值计算

管理员 CLI

2.3 技术选型

模块 技术方案 说明
网络通信 POSIX Socket(TCP) 跨平台、稳定、易控制
并发模型 pthread + 线程池 兼顾性能与实现复杂度
数据持久化 自定义二进制文件 / SQLite C 接口 轻量、无外部依赖
序列化协议 自定义文本协议 便于调试与扩展
题目运行 fork + ptrace 沙箱 限制题目进程权限,防止系统被破坏
构建工具 Makefile 一键编译部署

选择 C 语言实现的核心价值在于:所有资源(内存、文件描述符、线程、进程)都由开发者显式管理,能够精确控制系统的行为边界与安全边界。

3. 核心数据结构与协议设计

3.1 用户数据结构

typedef struct {
    uint32_t uid;             /* 用户唯一 ID */
    char username[32];        /* 用户名 */
    unsigned char salt[16];   /* 密码盐值 */
    unsigned char pwd_hash[32]; /* SHA-256 密码散列 */
    uint32_t score;           /* 当前总积分 */
    uint32_t solved_map[128];/* 位图:已解题目集合 */
    int64_t last_solve_ts;    /* 最后一次解题时间戳 */
} user_t;

score 用于排行榜排序,solved_map 采用位图结构,每一位对应一道题目,空间效率高且查询为 O(1)。

3.2 题目数据结构

typedef enum {
    CAT_PWN = 0,      /* 二进制漏洞利用 */
    CAT_REVERSE,      /* 逆向工程 */
    CAT_CRYPTO,       /* 密码学 */
    CAT_WEB,          /* Web 安全 */
    CAT_MISC          /* 杂项 */
} chall_category_t;

typedef struct {
    uint32_t cid;            /* 题目 ID */
    char title[64];          /* 标题 */
    char flag[64];           /* 标准 Flag */
    chall_category_t category;/* 分类 */
    uint32_t base_score;     /* 基础分值 */
    uint16_t min_score;      /* 动态分值下限 */
    uint32_t solved_count;   /* 解题人数 */
    char desc_file[128];     /* 题目描述文件路径 */
    char bin_file[128];      /* 题目二进制文件路径 */
} chall_t;

3.3 通信协议

服务器与客户端之间采用行文本协议,每条消息以 \n 结尾,格式为「命令 + 空格 + 参数」:

REG 用户名 密码散列          -- 注册
LOGIN 用户名 密码散列        -- 登录
LIST                         -- 获取题目列表
SUBMIT 题目ID Flag           -- 提交 Flag
SCORE                        -- 获取排行榜
BYE                          -- 断开连接

响应格式统一为 状态码 + 空格 + 消息体,状态码 200 表示成功,400 系列表示客户端错误,500 表示服务器错误。文本协议便于用 telnetnc 直接调试。

4. 关键模块实现

4.1 网络通信层

服务器主循环采用「监听 + 线程池」模型。主线程负责 accept 新连接,将客户端套接字交给线程池中的工作线程处理,避免为每个连接反复创建销毁线程的开销。

/* 线程池任务结构 */
typedef struct {
    void (*func)(void *);
    void *arg;
} task_t;

/* 服务器主循环 */
int server_run(int listen_fd, threadpool_t *pool) {
    while (1) {
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);
        int cli_fd = accept(listen_fd,
                            (struct sockaddr *)&cli_addr, &cli_len);
        if (cli_fd < 0) {
            perror("accept");
            continue;
        }

        /* 将处理函数投递到线程池 */
        client_ctx_t *ctx = malloc(sizeof(client_ctx_t));
        ctx->fd = cli_fd;
        ctx->addr = cli_addr;
        threadpool_submit(pool, handle_client, ctx);
    }
    return 0;
}

工作线程读取命令并解析,核心处理逻辑如下:

void handle_client(void *arg) {
    client_ctx_t *ctx = (client_ctx_t *)arg;
    char buf[1024];
    ssize_t n;

    while ((n = recvline(ctx->fd, buf, sizeof(buf))) > 0) {
        char cmd[16] = {0}, arg1[256] = {0}, arg2[256] = {0};
        sscanf(buf, "%15s %255s %255s", cmd, arg1, arg2);

        if (strcmp(cmd, "LOGIN") == 0) {
            do_login(ctx, arg1, arg2);
        } else if (strcmp(cmd, "LIST") == 0) {
            do_list(ctx);
        } else if (strcmp(cmd, "SUBMIT") == 0) {
            do_submit(ctx, arg1, arg2);
        } else if (strcmp(cmd, "SCORE") == 0) {
            do_score(ctx);
        } else if (strcmp(cmd, "BYE") == 0) {
            break;
        } else {
            send_msg(ctx->fd, "400 未知命令\n");
        }
    }
    close(ctx->fd);
    free(ctx);
}

4.2 用户认证模块

密码存储使用「盐值 + SHA-256」散列,绝不保存明文。注册时生成随机盐值,登录时对输入密码做同样的散列并与存储值比较。

int register_user(const char *username, const char *password) {
    user_t u;
    memset(&u, 0, sizeof(u));

    /* 生成随机盐值 */
    if (RAND_bytes(u.salt, sizeof(u.salt)) != 1)
        return -1;

    /* 计算 SHA-256(salt || password) */
    SHA256_CTX sha;
    SHA256_Init(&sha);
    SHA256_Update(&sha, u.salt, sizeof(u.salt));
    SHA256_Update(&sha, password, strlen(password));
    SHA256_Final(u.pwd_hash, &sha);

    strncpy(u.username, username, sizeof(u.username) - 1);
    u.uid = next_uid();

    return user_store_save(&u);
}

密码校验时采用恒定时间比较CRYPTO_memcmp),避免通过时序侧信道泄露散列信息。

4.3 Flag 判题与动态分值

提交 Flag 后,系统校验题目归属与重复解题,成功后更新积分并重新计算动态分值。动态分值公式采用经典的 CTFd 模型:

得分 = max(最低分, 基础分 - 解出人数 * 衰减步长)

实现如下:

int submit_flag(uint32_t uid, uint32_t cid,
                const char *flag, int *first_blood) {
    chall_t *ch = chall_get(cid);
    user_t  *u  = user_get(uid);
    if (!ch || !u)
        return -1;

    /* 已解过该题 */
    if (u->solved_map[cid / 32] & (1u << (cid % 32)))
        return ERR_ALREADY_SOLVED;

    /* 恒定时间比较 Flag */
    if (CRYPTO_memcmp(ch->flag, flag, strlen(ch->flag)) != 0)
        return ERR_WRONG_FLAG;

    *first_blood = (ch->solved_count == 0);
    ch->solved_count++;

    /* 计算动态分值 */
    uint32_t dynamic = ch->base_score -
        ch->solved_count * SCORE_DECAY_STEP;
    if (dynamic < ch->min_score)
        dynamic = ch->min_score;

    /* 更新用户积分 */
    u->score += dynamic;
    u->solved_map[cid / 32] |= (1u << (cid % 32));
    u->last_solve_ts = time(NULL);

    chall_store_update(ch);
    user_store_update(u);
    return 0;
}

4.4 排行榜模块

排行榜按积分降序、最后解题时间升序排序。考虑到参与人数通常有限,直接使用 qsort 在内存中排序即可满足实时性要求,无需引入复杂的堆结构。

int score_cmp(const void *a, const void *b) {
    const user_t *ua = (const user_t *)a;
    const user_t *ub = (const user_t *)b;
    if (ua->score != ub->score)
        return ub->score - ua->score;    /* 积分高者在前 */
    return ua->last_solve_ts < ub->last_solve_ts ? -1 : 1;
}

void build_ranklist(user_t *list, size_t n) {
    qsort(list, n, sizeof(user_t), score_cmp);
}

5. 题目运行沙箱

CTF 平台最大的安全风险来自题目二进制本身。PWN、逆向类题目需要在参赛者可交互的情况下运行,若直接以服务器权限运行,一个恶意构造的 payload 就可能攻陷整个平台。因此必须为题目进程建立隔离沙箱。

本系统采用 fork 创建子进程,并通过 ptrace 限制系统调用,核心思路如下:

int run_chall_sandbox(chall_t *ch, int in_fd, int out_fd) {
    pid_t pid = fork();
    if (pid == 0) {
        /* 子进程:重定向标准输入输出 */
        dup2(in_fd,  STDIN_FILENO);
        dup2(out_fd, STDOUT_FILENO);

        /* 请求父进程跟踪,拦截危险系统调用 */
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);

        /* 降低权限:切换工作目录、限制资源 */
        chdir(CHALL_RUN_DIR);
        alarm(60);               /* 超时强制终止 */

        execl(ch->bin_file, ch->bin_file, NULL);
        _exit(127);
    }

    /* 父进程:监控系统调用,阻塞 execve 危险路径 */
    monitor_child(pid);
    return pid;
}

监控进程通过 PTRACE_SYSCALL 捕获每次系统调用,通过寄存器读取系统调用号,若发现 execveopen 越权路径、socket 外连等危险操作,立即终止子进程:

void monitor_child(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    while (WIFSTOPPED(status)) {
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, pid, NULL, &regs);

        /* x86-64 下系统调用号在 orig_rax */
        long syscall_no = regs.orig_rax;

        if (is_dangerous_syscall(syscall_no)) {
            ptrace(PTRACE_KILL, pid, NULL, NULL);
            break;
        }
        ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
        waitpid(pid, &status, 0);
    }
}

在真实生产环境中,建议进一步叠加 Linux seccomp-bpfnamespacescgroups 限制资源,将题目进程彻底隔离在受限容器内。

6. 实战题目示例

下面给出三类典型 CTF 题目的设计思路与 C 语言实现示例。

6.1 PWN:栈溢出入门

经典的栈溢出题目,考察选手对函数调用栈与返回地址的理解。

#include <stdio.h>
#include <string.h>

void win() {
    printf("已获取权限!Flag: flag{pwn_stack_overflow_2026}\n");
}

void vuln() {
    char buf[32];
    puts("请输入内容:");
    gets(buf);                  /* 不安全的 gets */
    printf("你输入了:%s\n", buf);
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}

选手需要构造超长输入覆盖返回地址,将控制流跳转到 win 函数。编译时建议使用以下参数保留漏洞特性,同时关闭现代编译器的栈保护以便教学:

gcc -m32 -fno-stack-protector -no-pie -o chall chall.c

6.2 Reverse:简单异或校验

逆向题目通常给出一段二进制,要求选手还原算法逻辑。下面是一个简单的异或校验示例,Flag 隐藏在数组中:

#include <stdio.h>

int main() {
    unsigned char enc[] = {0x66,0x63,0x63,0x6e,0x7f,0x60,
                           0x7d,0x63,0x18,0x76,0x7a,0x19,
                           0x7a,0x63,0x60,0x6e};
    char input[64];

    printf("请输入 Flag:");
    scanf("%63s", input);

    int ok = 1;
    for (int i = 0; i < 16; i++) {
        if ((unsigned char)(input[i] ^ 0x0d) != enc[i]) {
            ok = 0;
            break;
        }
    }
    puts(ok ? "正确!" : "错误!");
    return 0;
}

选手通过逆向循环逻辑可还原 Flag:flag{xor_reverse_2026}

6.3 Crypto:简易流密码

密码学题目检验选手对加密算法的分析能力。下面是基于线性反馈移位寄存器(LFSR)的流密码示例:

#include <stdio.h>
#include <stdint.h>

/* 4 bit LFSR,反馈多项式 x^4 + x + 1 */
uint8_t lfsr_step(uint8_t *state) {
    uint8_t bit = ((*state >> 0) ^ (*state >> 1)) & 1;
    *state = (*state >> 1) | (bit << 3);
    return *state & 1;
}

int main() {
    uint8_t state = 0x9;         /* 初始种子 = 1001 */
    unsigned char pt[] = "flag{lfsr_stream}";
    unsigned char ct[sizeof(pt)];

    for (int i = 0; i < sizeof(pt) - 1; i++) {
        uint8_t ks = 0;
        for (int j = 0; j < 8; j++)
            ks = (ks << 1) | lfsr_step(&state);
        ct[i] = pt[i] ^ ks;
    }

    printf("密文:");
    for (int i = 0; i < sizeof(pt) - 1; i++)
        printf("%02x ", ct[i]);
    printf("\n");
    return 0;
}

7. 安全性与稳定性设计

CTF 平台自身的安全防护与题目沙箱同等重要,需要重点考虑:

  • 整数与边界检查:所有来自网络的输入长度、题目 ID、数组下标在使用前必须做范围校验,防止越界读写;
  • 格式字符串安全:始终使用 snprintf 替代 sprintf,外部输入不得作为格式字符串;
  • 资源限制:通过 setrlimit 限制单个连接的内存、CPU 时间与文件描述符数量,防止 DoS 攻击;
  • 权限最小化:服务器进程以专用低权限用户运行,题目二进制与数据文件设置只读权限;
  • 日志与审计:记录登录、提交、管理操作日志,便于事后审计;
  • 超时与断连处理:为每个连接设置读写超时,清理资源,防止僵尸连接消耗线程池。

线程安全方面,全局数据结构(用户列表、题目列表)采用读写锁保护:

pthread_rwlock_t users_lock = PTHREAD_RWLOCK_INITIALIZER;

user_t *user_get_safe(uint32_t uid) {
    pthread_rwlock_rdlock(&users_lock);
    user_t *u = user_find(uid);
    pthread_rwlock_unlock(&users_lock);
    return u;
}

8. 构建、部署与测试

提供完整的 Makefile,一键编译服务器与管理工具:

CC      = gcc
CFLAGS  = -Wall -Wextra -O2 -pthread
LDLIBS  = -lcrypto -pthread
TARGETS = ctf_server ctf_admin

all: $(TARGETS)

ctf_server: server.c user.c chall.c judge.c ranks.c net.c threadpool.c
	$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)

ctf_admin: admin.c chall.c user.c
	$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)

clean:
	rm -f $(TARGETS)

部署时建议的目录结构如下:

ctf-system/
├── ctf_server          # 服务器主程序
├── ctf_admin           # 管理员工具
├── data/
│   ├── users.dat       # 用户数据
│   └── challs.dat      # 题目数据
├── challenges/
│   ├── pwn_01/
│   │   ├── desc.md
│   │   └── chall
│   └── rev_01/
│       ├── desc.md
│       └── chall
└── Makefile

测试阶段建议按层次进行:先对认证、判题、动态分值等核心模块编写单元测试;再进行并发压力测试,使用 ab 或自定义脚本模拟大量并发提交,观察线程池、锁竞争与内存占用情况;最后进行安全测试,模拟恶意输入、超长 Flag、越权访问等场景。

9. 总结

本文完整介绍了基于 C 语言的 CTF 实战系统设计与开发流程:从需求分析与系统架构入手,设计了用户、题目核心数据结构与文本通信协议,详细实现了网络通信、用户认证、Flag 判题、动态分值、排行榜等功能模块,并通过「fork + ptrace」沙箱保障题目运行安全。同时给出了 PWN、逆向、密码学三类典型题目的实现示例,以及构建部署与测试方案。

使用 C 语言开发 CTF 系统的过程,本身就是一次深入理解操作系统与网络编程的实战训练。它逼迫开发者直面内存管理、并发控制、安全边界等底层问题,这些经验对后续从事漏洞挖掘、系统安全研究具有重要价值。

当然,本文实现的系统仍属于教学级示范,距离生产环境还有一定距离。未来可以继续扩展的方向包括:TLS 加密通信、Web 管理界面、容器化题目隔离(Docker/nsjail)、插件化题目加载器、实时动态分值衰减曲线等。如果你正在学习信息安全与系统编程,动手实现这样一个系统,将是一段非常有价值的经历。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐