正则表达式安全:防止ReDoS攻击、限制匹配长度、超时控制、输入验证

正则表达式这东西,用好了是利器,用不好就是定时炸弹。我见过太多项目因为一个“看似完美”的正则,把整个服务拖垮了。今天咱们就聊聊正则的安全问题——说白了,就是怎么让你的正则既好用又不会成为系统瓶颈。

ReDoS攻击:正则表达式的“死循环”

ReDoS,全称是Regular Expression Denial of Service。什么意思呢?就是攻击者构造一个特殊的字符串,让你的正则引擎陷入“灾难性回溯”,CPU直接飙到100%。

为什么会这样?我举个例子你就明白了。

// 危险的正则:嵌套量词 + 重叠匹配
const regex = /^(a+)+b$/;
// 测试字符串: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"
// 这个字符串没有匹配到b,但引擎会尝试所有可能的组合
// 时间复杂度从O(n)变成了O(2^n)

嗯,这里要注意。上面这个正则看起来人畜无害,但如果你传入一个很长的、不匹配的字符串,它就会疯狂回溯。我在项目中遇到过类似的情况——一个日志解析服务,因为一个正则写得不够严谨,线上CPU直接打满,排查了半天才发现是正则的问题。

⚠️ 高危模式识别:
  • (a+)+ — 嵌套的重复量词
  • (a|aa)+ — 重叠的交替分支
  • (.*)* — 贪婪匹配嵌套
  • (a+)*b — 重复量词后跟非可选字符

如何防止ReDoS?三个实用策略

我个人习惯从三个层面来防范ReDoS。你想想看,这就像给正则表达式上了三道锁。

策略一:重写正则,消除回溯陷阱

最直接的办法,就是把容易引发灾难性回溯的模式改掉。说白了,就是让正则引擎“一眼就能看出结果”,不需要反复尝试。

// ❌ 危险写法
const badRegex = /^(a+)+b$/;

// ✅ 安全写法:去掉嵌套量词
const safeRegex = /^a+b$/;

// ❌ 危险写法
const badRegex2 = /^(\d+|\w+)+$/;

// ✅ 安全写法:使用原子组(如果支持)
const safeRegex2 = /^(?>\d+|\w+)+$/;
💡 小技巧: 如果你用的语言支持原子组(Atomic Group)或占有量词(Possessive Quantifier),优先使用它们。比如Java的?>、PCRE的*+。这些特性告诉引擎“匹配过的就不要回溯了”。

策略二:限制匹配长度

我曾经接手过一个项目,用户可以在输入框里粘贴任意长度的文本,然后系统用正则去匹配。你猜怎么着?有人贴了一篇10万字的论文进去……

从那以后,我养成了一个习惯:任何正则匹配之前,先检查输入长度

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

#define MAX_INPUT_LEN 1024

int safe_regex_match(const char *pattern, const char *input) {
    // 第一步:限制输入长度
    if (strlen(input) > MAX_INPUT_LEN) {
        fprintf(stderr, "输入过长,拒绝匹配\n");
        return -1;
    }
    
    regex_t regex;
    int ret = regcomp(&regex, pattern, REG_EXTENDED);
    if (ret != 0) {
        fprintf(stderr, "正则编译失败\n");
        return -2;
    }
    
    ret = regexec(&regex, input, 0, NULL, 0);
    regfree(&regex);
    return ret;
}

策略三:超时控制

就算你限制了长度,有些正则还是可能跑很久。这时候就需要“定时炸弹拆除器”——超时控制。

在C语言里,没有内置的正则超时机制。我一般用两种方式实现:

方法 实现方式 适用场景
信号量(SIGALRM) 设置定时器,超时发送信号中断匹配 单线程、简单场景
线程+超时 在子线程中执行匹配,主线程等待超时 多线程、生产环境
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <regex.h>

volatile int timeout_flag = 0;

void timeout_handler(int sig) {
    timeout_flag = 1;
}

int regex_with_timeout(const char *pattern, const char *input, int seconds) {
    // 设置超时信号
    signal(SIGALRM, timeout_handler);
    alarm(seconds);
    
    regex_t regex;
    regcomp(&regex, pattern, REG_EXTENDED);
    
    int ret = regexec(&regex, input, 0, NULL, 0);
    
    // 取消定时器
    alarm(0);
    
    if (timeout_flag) {
        fprintf(stderr, "正则匹配超时!\n");
        regfree(&regex);
        return -3;
    }
    
    regfree(&regex);
    return ret;
}
⚠️ 注意: 信号量方式在多线程环境下要小心。信号是进程级的,一个线程的信号可能会影响其他线程。生产环境我建议用线程池+超时控制的方式。

输入验证:第一道防线

其实,很多ReDoS攻击是可以从源头避免的。我的习惯是:永远不要信任用户输入。在正则匹配之前,先做一层输入验证。

验证什么?三个维度:

  • 长度验证:设定合理的最大长度,比如邮箱不超过254字符
  • 字符集验证:只允许预期的字符范围,比如只允许字母数字
  • 格式预检:用简单的规则先过滤掉明显不合法的输入
// 输入验证示例
int validate_input(const char *input) {
    // 1. 长度检查
    if (strlen(input) > 256) return 0;
    
    // 2. 字符集检查:只允许字母、数字、下划线、点、@
    for (int i = 0; input[i] != '\0'; i++) {
        if (!isalnum(input[i]) && 
            input[i] != '_' && 
            input[i] != '.' && 
            input[i] != '@') {
            return 0;
        }
    }
    
    // 3. 格式预检:不能以特殊字符开头
    if (input[0] == '.' || input[0] == '@') return 0;
    
    return 1;
}

知识体系总览

下面这张图,是我整理的正则表达式安全防护体系。你可以把它当作一个检查清单。

正则表达式安全防护体系 第一层:输入验证(第一道防线) 长度限制 → 字符集过滤 → 格式预检 第二层:正则优化(消除回溯陷阱) 避免嵌套量词 → 使用原子组 → 简化交替分支 第三层:运行时保护(最后一道屏障) 超时控制 → 资源限制 → 异常处理 防止ReDoS攻击 限制匹配长度 输入验证

实战中的避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路。

我曾经犯过的错:

  • 坑1:在日志分析系统中,用了一个(.*?)去匹配任意内容,结果用户日志里有一行特别长的数据,直接导致CPU 100%。后来我改成了[^,]+,问题解决。
  • 坑2:没有做输入长度限制,用户传了一个1MB的字符串进来,正则跑了30秒才返回。从那以后,我所有正则匹配前都会先检查长度。
  • 坑3:在信号处理函数里调用了非线程安全的函数,导致多线程环境下程序崩溃。记住:信号处理函数里只能做简单的事情。

嗯,正则安全这块,说白了就是“防人之心不可无”。你永远不知道用户会输入什么奇怪的东西。做好输入验证、优化正则写法、加上超时控制——这三板斧用好了,你的正则就能安安稳稳地跑在生产环境里。

📌 我的个人建议: 每次写完正则,都问自己三个问题:
1. 这个正则会不会因为一个不匹配的字符串而疯狂回溯?
2. 我有没有限制输入的长度?
3. 如果匹配时间超过1秒,系统会怎么样?
这三个问题想清楚了,ReDoS基本就防住了。

公众号:蓝海资料掘金营,微信deep3321