openresty的unescape_uri函数处理百分号后面字符的小特性

我们的 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 创作,采用 知识共享 署名-非商业性使用-相同方式共享(3.0未本地化版本)许可协议进行许可。基于http://www.cnxct.com上的作品创作。转载请注明转自:openresty的unescape_uri函数处理百分号后面字符的小特性

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.