我们的 WAF (Web Application Firewall)是搭建在 nginx 上,使用 lua modules 来实现的。也就是 openresty 的 luajit 功能来实现WEB 攻击判断。处理收到的请求时,一般会进行unescape_uri 处理后,再走规则匹配。
在离线分析的机器学习识别结果里,发现有这么一条漏报/aa%20a?openId=%%3Cscript%3E%,这个问题却出乎我们意料,为此,开始排查产生该现象的原因。
问题描述
源字符串:
/aa%20a?openId=%%3Cscript%3E%
openresty的unescape_uri函数处理后结果
/aa a?openId=%3Cscript>
期望的结果:
/aa a?openId=%<script>%
问题现象:
转义不对;丢了百分号。
如何重现
通过浏览器,构造一个包含如上特征的 uri,按回车提交到 nginx?浏览器会自动把字符串转码吗?转码是按照什么规范来的?IE9、IE10、chrome、safari是分别如何处理的? 习惯用postman的人,知道postman是否会escape uri呢?
如果你忽略了这一点,那么会对你的测试造成很大的影响,耽误时间经历,让你在重试阶段都对问题产生了怀疑。
从HTTP协议角度考虑,只要确保通过协议发送到 http server 的请求中,uri 是符合我们需要的即可。至于浏览器的 escape做法,可以忽略,直接构造 http 协议来重现。
- 重现时,通过浏览器 提交「问题字符串」,在 nginx 层 输出 unescape 后的结果。
- 注意浏览器是否对 URI 进行了 URI_ENCODE,不同浏览器执行的 RFC 标准也不一样。
- 最根本的方式是,跳过浏览器,直接构造 http request 包,确保源字符串完整发送只 nginx。
重现该问题,开始排查 nginx 对 uri decode 的实现方式。
分析问题:
waf的处理方式:
使用ngx.unescape_uri函数
local unescape = ngx.unescape_uri local CONTENT = unescape(ngx.var.request_uri)
确认处理 uri 的原始函数ngx.unescape_uri
该函数在 nginx lua modules 模块中,在src/ngx_http_lua_string.c 的64、65行:
void ngx_http_lua_inject_string_api(lua_State *L) { lua_pushcfunction(L, ngx_http_lua_ngx_escape_uri); lua_setfield(L, -2, "escape_uri"); lua_pushcfunction(L, ngx_http_lua_ngx_unescape_uri); lua_setfield(L, -2, "unescape_uri");
如上代码,可知ngx.unescape_uri函数是ngx_http_lua_ngx_unescape_uri实现,对于真正 decode 字符串功能,是使用了src/ngx_http_lua_util.c文件内的ngx_http_lua_unescape_uri函数做 decode,该函数代码如下:
/* XXX we also decode '+' to ' ' */ void ngx_http_lua_unescape_uri(u_char **dst, u_char **src, size_t size, ngx_uint_t type) { u_char *d, *s, ch, c, decoded; enum { sw_usual = 0, sw_quoted, sw_quoted_second } state; d = *dst; s = *src; state = 0; decoded = 0; while (size--) { ch = *s++; switch (state) { case sw_usual: if (ch == '?' && (type & (NGX_UNESCAPE_URI|NGX_UNESCAPE_REDIRECT))) { *d++ = ch; goto done; } if (ch == '%') { state = sw_quoted; break; } if (ch == '+') { *d++ = ' '; break; } *d++ = ch; break; case sw_quoted: if (ch >= '0' && ch <= '9') { decoded = (u_char) (ch - '0'); state = sw_quoted_second; break; } c = (u_char) (ch | 0x20); if (c >= 'a' && c <= 'f') { decoded = (u_char) (c - 'a' + 10); state = sw_quoted_second; break; } /* the invalid quoted character */ state = sw_usual; *d++ = ch; break; case sw_quoted_second: state = sw_usual; if (ch >= '0' && ch <= '9') { ch = (u_char) ((decoded << 4) + ch - '0'); if (type & NGX_UNESCAPE_REDIRECT) { if (ch > '%' && ch < 0x7f) { *d++ = ch; break; } *d++ = '%'; *d++ = *(s - 2); *d++ = *(s - 1); break; } *d++ = ch; break; } c = (u_char) (ch | 0x20); if (c >= 'a' && c <= 'f') { ch = (u_char) ((decoded << 4) + c - 'a' + 10); if (type & NGX_UNESCAPE_URI) { if (ch == '?') { *d++ = ch; goto done; } *d++ = ch; break; } if (type & NGX_UNESCAPE_REDIRECT) { if (ch == '?') { *d++ = ch; goto done; } if (ch > '%' && ch < 0x7f) { *d++ = ch; break; } *d++ = '%'; *d++ = *(s - 2); *d++ = *(s - 1); break; } *d++ = ch; break; } /* the invalid quoted character */ break; } } done: *dst = d; *src = s; }
代码阅读:
- 对 uri 的字符串按照从左往右挨个处理
- 初始化 state 为 sw_usual,当state 为sw_usual时
- 若第一个字符是「?」,则把「?」赋值给 dst 字符串,然后直接返回
- 若第一个字符串是「+」,则替换成空格,赋值给 dst 字符串,然后跳出流程,走到下一个字符判断。(nginx 函数ngx_unescape_uri是没这个处理的)
- 若第一个字符是「%」,则跳过当前字符,对src 源字符串的下一个字符判断,走到sw_quoted流程处理下一个字符。
- sw_quoted判断当前字符是符合[0-9a-f],则进入下一个字符的处理。
- 若不在[0-9a-f]范围内,则赋值给 dst 字符串,跳出当前流程处理下一个字符
- sw_quoted_second 处理两个字符串,转换,赋值到 dst 中
结合案例分析:
结合案例中的源字符串%%3Cscript%3E%来看,
- 第一个字符% 走到上面第5条,不会被赋值到dst中,会继续判断下一个字符
- 第二个字符也是% ,走到上面第7条,直接被赋值 dst 中
- 第3、4个字符是普通字符,直接赋值到 dst 中。
- 第11个字符%走到上面第5条,跳过,继续判断下一个字符
- 第12、13个字符走到上面第8条,正确unescape转码。
- 最后一个字符% ,走到上面第5条,跳过,进行下一个字符判断
- 下一个字符没了,结束
所以,%%3Cscript%3E% 转换为 %3Cscript> ,这里的% 是源字符串的第二个百分号,第一个丢了(因为后面的字符也是%,不符合[0-9a-z]规则);最后一个% 也丢了,因为后面没字符了,while 跳出循环,结束了。
总结问题:
- 百分号后面还是百分号问题的修复
- 百分号是最后一个字符串的修复
修复问题:
#include <stdio.h> #include <stdint.h> #include <string.h> #include <stdlib.h> #define NGX_UNESCAPE_URI 1 #define NGX_UNESCAPE_REDIRECT 2 typedef unsigned char u_char; void ngx_http_lua_unescape_uri(u_char **dst, u_char **src, size_t size, uint8_t type); void ngx_http_lua_unescape_uri(u_char **dst, u_char **src, size_t size, uint8_t type) { u_char *d, *s, ch, c, decoded; enum { sw_usual = 0, sw_quoted, sw_quoted_second } state; d = *dst; s = *src; state = 0; decoded = 0; while (size--) { ch = *s++; switch (state) { case sw_usual: if (ch == '?' && (type & (NGX_UNESCAPE_URI|NGX_UNESCAPE_REDIRECT))) { *d++ = ch; goto done; } //原代码 //if (ch == '%') { //变化代码,解决百分号是最后一个字符问题 if (ch == '%' && size > 1) { state = sw_quoted; break; } if (ch == '+') { *d++ = ' '; break; } *d++ = ch; break; case sw_quoted: if (ch >= '0' && ch <= '9') { decoded = (u_char) (ch - '0'); state = sw_quoted_second; break; } c = (u_char) (ch | 0x20); if (c >= 'a' && c <= 'f') { decoded = (u_char) (c - 'a' + 10); state = sw_quoted_second; break; } /* the invalid quoted character */ state = sw_usual; //如果遇到quoted的字符不是0-9,a-f ,则将*s++ 前一个字符赋值到,并将size--操作回退一次,s++操作回退1次,d++重新赋值 { size++; *s--; *(d++)=*(s-1); } //原代码 //*d++ = ch; break; case sw_quoted_second: state = sw_usual; if (ch >= '0' && ch <= '9') { ch = (u_char) ((decoded << 4) + ch - '0'); if (type & NGX_UNESCAPE_REDIRECT) { if (ch > '%' && ch < 0x7f) { *d++ = ch; break; } *d++ = '%'; *d++ = *(s - 2); *d++ = *(s - 1); break; } *d++ = ch; break; } c = (u_char) (ch | 0x20); if (c >= 'a' && c <= 'f') { ch = (u_char) ((decoded << 4) + c - 'a' + 10); if (type & NGX_UNESCAPE_URI) { if (ch == '?') { *d++ = ch; goto done; } *d++ = ch; break; } if (type & NGX_UNESCAPE_REDIRECT) { if (ch == '?') { *d++ = ch; goto done; } if (ch > '%' && ch < 0x7f) { *d++ = ch; break; } *d++ = '%'; *d++ = *(s - 2); *d++ = *(s - 1); break; } *d++ = ch; break; } /* the invalid quoted character */ break; } } done: *dst = d; *src = s; }
测试代码:
int main() { u_char *str; u_char *str1; u_char *oldptr; str = (u_char *)"/aa%20a?op+enId=%%3Cscript%3E%%"; size_t l = strlen(str); printf("before:%s \t\tlen:%d\n",str,l); str1=malloc(l); oldptr = str1; ngx_http_lua_unescape_uri(&str1, &str, l, 0); str1 = oldptr; printf("after:%s\t\tlen:%d\n",str1,strlen(str1)); return 0; }
备注:
nginx 底层的 ngx_unescape_uri 函数(/src/core/ngx_string.c)实现部分,跟 lua modules 实现不太一样,openresty 里多了对「+」转空格的处理。()
疑问:
- nginx的做法是按照哪个 RFC 标准转换的?
- lua modules的做法符合 RFC 标准吗?
- uri_decode在其他语言里的是如何实现的?
解答:
- 翻阅 nginx 官方文档,并没找到 uri unescape的规范文档,只是代码注释里看到了是RFC3986
- 符合 RFC 规范,但RFC3986 规范并没提到百分号%后面不符合的字符该如何处理,也没提到单独的百分号该如何处理。
- 你自己去试试?
结论
nginx 的这个处理,并不是 bug,RFC3986 规范里只是规定了百分号%后面的字符哪些字符,转换成哪种格式,并没有规定不符合规范的字符改如何处理,没规定是丢弃还是保留。所以,这只是 nginx 的一个特性feature,特性feature,特性feature,并不是BUG,不是 BUG,不是 BUG。
参考:
Uniform Resource Identifier (URI): Generic Syntax
CFC4N的博客 由 CFC4N 创作,采用 署名—非商业性使用—相同方式共享 4.0 进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:openresty的unescape_uri函数处理百分号后面字符的小特性
大神的文章每次都是干货,膜拜,学习了。