DGZ's Blog.

hgame2020 二发入魂 及mt_rand()函数的记录

Word count: 3.2kReading time: 14 min
2020/02/22 Share

原链接:https://www.ambionics.io/blog/php-mt-rand-prediction

只用两个输出值破解mt_rand函数,无需爆破

介绍

当在对一个旧网站做渗透测试时,我们遇到了一些很久没见到过的代码:

1
2
3
4
5
6
7
8
<?php
function resetPassword($email)
{
[...]
$superSecretToken = md5($email . ':' . mt_rand());
[...]
$this->sendEmailWithResetToken($email, $superSecretToken);
}

利用PHP实现Mersenne Twister(一种伪随机数生成器),输出生成了一个被认为是秘密且不可猜测的token。
在过去十年里,一场因为其内部对话(通信/处理?)的美好时代里关于PRNG(pseudorandom number generator伪随机数生成器)的预测,以及它在过去十年早期产生的大量文章和工具。(太奇怪了这句话)。当时,许多应用程序使用rand()或mt_rand()来生成秘密令牌或密码。这些函数被标记为密码不安全的,因此不应该用于密码目的。为了预测这些值,你必须找到种子,也就是所有这些值的最初值。大家一致认为,您需要获得整个内部状态(mt_rand()的624个值),或者使用这些值的一部分来对种子进行bruteforce处理。

然而,我们的一名研究人员坚持认为,仅使用mt_rand()函数的两个输出就可以恢复Mersenne Twister算法的种子,而且不需要任何形式的暴力破解。然而,我们找不到任何支持这一理论的资料,他关于这个问题的笔记早就丢失了。

在对这些数据进行了一番分析之后,在PRNG预测闹剧结束多年之后,我们证明了他是对的。看在过去的份上,我们现在将演示如何做到这一点。

只用两个输出值和一点数学知识破解PHP的Mersenne Twister算法

使用mt_rand()函数在生成随机数的第一步,是使用一个种子,以无符号整型的数据类型生成一个624个值的状态数组。通过调用mt_srand($seed),或者在通过PHP请求第一个随机数时自动完成。在这之后,每次调用mt_rand()都将获取下一个状态值,并将其打乱,在将其返回给用户。

所有事情发生在文件ext/standard/mt_rand.c中对于较新的PHP版本(PHP7+),或者文件ext/standard/rand.c中,对于更久的PHP版本(PHP5-)。

知道种子的值使您能够预测PRNG生成的每个数字,直到再次调用mt_srand()为止。因此,从mt_rand()的一个或多个值生成的秘密随机值将不再是秘密的,从而导致明显的安全问题。

随机数生成过程分为三个步骤:

1.通过种子的模乘法生成一个初始化的状态数组
2.将状态值打乱得到一个打乱的状态数组
3.调用mt_rand()时,返回一个修改后的打乱的状态值

对于每个步骤,我们将展示代码,简要描述其工作方式,然后介绍如何从输出到输入。

注意:PHP7+对负责生成打乱状态的代码进行了一些更改(请参阅mt_randl .c中定义的twist和twist_php #)。这个变化不大,因为我们可以通过算法的每个变化得到种子。在这里,我们主要关注最初的实现,twist_php。底部提供的脚本可以处理两个版本。

种子到初始状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define MT_N (624)
#define N MT_N /* length of state vector */
#define M (397) /* a period parameter */

static inline void php_mt_initialize(uint32_t seed, uint32_t *state)
{
/* Initialize generator state with seed
See Knuth TAOCP Vol 2, 3rd Ed, p.106 for multiplier.
In previous versions, most significant bits (MSBs) of the seed affect
only MSBs of the state array. Modified 9 Jan 2002 by Makoto Matsumoto. */

register uint32_t *s = state;
register uint32_t *r = state;
register int i = 1;

*s++ = seed & 0xffffffffU;
for( ; i < N; ++i ) {
*s++ = ( 1812433253U * ( *r ^ (*r >> 30) ) + i ) & 0xffffffffU;
r++;
}
}

创建了一个包含624个值的初始状态数组。第一种状态是种子。每个后续的状态值都是由前一个状态值生成的。

现在,如果我们知道这些状态值中的任何一个,比如state[123],我们能否推出state{0},也就是种子?

python版本的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
N = 624
M = 397

MAX = 0xffffffff

STATE_MULT = 1812433253

def php_mt_initialize(seed):
"""Creates the initial state array from a seed.
"""
state = [None] * N
state[0] = seed & 0xffffffff;
for i in range(1, N):
r = state[i-1]
state[i] = ( STATE_MULT * ( r ^ (r >> 30) ) + i ) & MAX
return state

由于值是通过迭代生成的,所以我们需要做的第一件事是尝试从i和state[i]中找出state[i-1]。然后我们可以在i- 1,i -2上重复这个操作,直到我们找到state[0],也就是种子。用S代表状态,i代表区间[1,624],我们有:

我们可以计算出1812433253对0x100000000的模逆元:

在python中,我们可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
STATE_MULT_INV = 2520285293
MAX = 0xffffffff

def _undo_php_mt_initialize(s, i):
s = (STATE_MULT_INV * (s - i)) & MAX
return s ^ s >> 30

def undo_php_mt_initialize(s, p):
"""From an initial state value `s` at position `p`, find out seed.
"""
for i in range(p, 0, -1):
s = _undo_php_mt_initialize(s, i)
return s

这意味着如果我们得到任何初始状态值,我们可以计算任何其他状态值,以及种子。

从初始状态到打乱状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define hiBit(u)      ((u) & 0x80000000U)  /* mask all but highest   bit of u */
#define loBit(u) ((u) & 0x00000001U) /* mask all but lowest bit of u */
#define loBits(u) ((u) & 0x7FFFFFFFU) /* mask the highest bit of u */
#define mixBits(u, v) (hiBit(u)|loBits(v)) /* move hi bit of u to hi bit of v */

#define twist(m,u,v) (m ^ (mixBits(u,v)>>1) ^ ((php_uint32)(-(php_int32)(loBit(u))) & 0x9908b0dfU))

static inline void php_mt_reload(TSRMLS_D)
{
/* Generate N new values in state
Made clearer and faster by Matthew Bellew (matthew.bellew@home.com) */

register php_uint32 *state = BG(state);
register php_uint32 *p = state;
register int i;

for (i = N - M; i--; ++p)
*p = twist(p[M], p[0], p[1]);
for (i = M; --i; ++p)
*p = twist(p[M-N], p[0], p[1]);
*p = twist(p[M-N], p[0], state[0]);
BG(left) = N;
BG(next) = state;
}

初始状态数组通过混合值进行置乱,从而产生一个新的状态,即置乱状态。前226 (N-M)个值的计算与后面的值不同。在python中,我们有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
N = 624
M = 397

def php_mt_reload(s):
for i in range(0, N - M):
s[i] = _twist(s[i+M], s[i], s[i+1])
for i in range(N - M, N - 1):
s[i] = _twist(s[i+M-N], s[i], s[i+1])

def _twist_php(m, u, v):
"""Emulates the `twist` #define.
"""
mask = 0x9908b0df if u & 1 else 0
return m ^ (((u & 0x80000000) | (v & 0x7FFFFFFF)) >> 1) ^ mask

在这里,我们会证明我们可以从两个打乱的状态中推导出一个初始状态。

state[0]和state[227]之间有关系。如果我们用s来命名初始状态数组,用S来命名新的置乱的状态数组,我们有:

1
2
3
4
S[227] = _twist(S[227 - 227], s[227], s[228])
<=> S[227] = _twist(S[0], s[227], s[228])
<=> S[227] = S[0] ^ (((s[227] & 0x80000000) | (s[228] & 0x7FFFFFFF)) >> 1) ^ (0x9908b0df if s[227] & 1 else 0)
<=> S[227] ^ S[0] = (((s[227] & 0x80000000) | (s[228] & 0x7FFFFFFF)) >> 1) ^ (0x9908b0df if s[227] & 1 else 0)

作为一个攻击者,如果我们知道了置乱状态S[0]和S[227],以及它们的XOR,我们就会得到很多关于初始状态s[228]的信息,还有一些关于s[227]的信息。如果我们用X表示S[227] ^ S[0],我们有:

因为XOR表达式的左部分是右移的,所以它的MSB总是空的。因此,MSB(0x9908b0df)只由右边的掩码来决定,因为MSB(0x9908b0df)已经设置好了。因此,MSB(X) = LSB(s[227]),如果X有掩码,我们可以把它去掉。
然后,BIT(s[227], 31) = BIT(X, 30)。
其余的值是来自s[228]的位,即来自30到1的位。

*MSB:最高有效位 LSB:最低有效位
综上所述,我们有:
> 初始状态值s[227]的MSB和LSB
> 初始状态值s[228]从30到1的位

因此,我们有4个可能的s值[228]。但是,由于我们也知道s[227]的一些位,我们可以从s[228]的四个可能值中每一个计算一个候选s227,并验证得到的s[227]值的 LSB和MSB是否与我们已有的值匹配。此外,我们可以获得与候选状态s[228]相关联的种子(同样,参考第一部分了解如何关联)。然后我们可以使用这个种子来重新生成一个状态,并检查它是否匹配S[0]。下面是python代码:

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
def undo_php_mt_reload(S000, S227):
# m S000
# u S227
# v S228
X = S000 ^ S227

# This means the mask was applied, and as such that S227's LSB is 1
s22X_0 = bv(X, 31)
# remove mask if present
if s22X_0:
X ^= 0x9908b0df

# Another easy guess
s227_31 = bv(X, 30)
# remove bit if present
if s227_31:
X ^= 1 << 30

# We're missing bit 0 and bit 31 here, so we have to try every possibility
s228_1_30 = (X << 1)
for s228_0 in range(2):
for s228_31 in range(2):
s228 = s228_0 | s228_31 << 31 | s228_1_30

# Check if the results are consistent with the known bits of s227
s227 = _undo_php_mt_initialize(s228, 228)
if bv(s227, 0) != s22X_0:
continue
if bv(s227, 31) != s227_31:
continue

# Check if the guessed seed yields S000 as its first scrambled state
rand = undo_php_mt_initialize(s228, 228)
state = php_mt_initialize(rand)
php_mt_reload(state)

if not S000 == state[0]:
continue

return rand
return None

为了推导出原始种子,我们使用了置乱状态S[0]和S[227]之间的关系。可以看到,这种关系对于任何由226个值分隔的状态值都是有效的。因此,知道S[i]和S[i+227]这两个状态值,以及它们的偏移量i,我们就可以恢复种子。
现在我们可以检查mt_rand() PRNG的最后一步。

置乱状态值到mt rand()输出

当我们调用mt_rand()时,PHP从打乱的状态数组中获取一个值,稍微摆弄一下(作者在卖萌吗),然后返回它。

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
PHP_FUNCTION(mt_rand)
{
zend_long min;
zend_long max;
int argc = ZEND_NUM_ARGS();

if (argc == 0) {
// genrand_int31 in mt19937ar.c performs a right shift
RETURN_LONG(php_mt_rand() >> 1);
}
...
}

PHPAPI uint32_t php_mt_rand(void)
{
/* Pull a 32-bit integer from the generator state
Every other access function simply transforms the numbers extracted here */

register uint32_t s1;

if (UNEXPECTED(!BG(mt_rand_is_seeded))) {
php_mt_srand(GENERATE_SEED());
}

if (BG(left) == 0) {
php_mt_reload();
}
--BG(left);

s1 = *BG(next)++; // PICKS NEXT SCRAMBLED STATE VALUE
s1 ^= (s1 >> 11);
s1 ^= (s1 << 7) & 0x9d2c5680U;
s1 ^= (s1 << 15) & 0xefc60000U;
return ( s1 ^ (s1 >> 18) );
}

s1表示置乱的状态值。在php_mt_rand()应用了4个操作之后,mt_rand()将它向右移动一位。显然,最后这个操作是不能逆转的。其他四个可以。
我们最终得到以下代码来撤销这些转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def undo_php_mt_rand(s1):
"""Retrieves the merged state value from the value sent to the user.
"""
s1 ^= (s1 >> 18)
s1 ^= (s1 << 15) & 0xefc60000

s1 = undo_lshift_xor_mask(s1, 7, 0x9d2c5680)

s1 ^= s1 >> 11
s1 ^= s1 >> 22

return s1

def undo_lshift_xor_mask(v, shift, mask):
"""r s.t. v = r ^ ((r << shift) & mask)
"""
for i in range(shift, 32, shift):
v ^= (bits(v, i - shift, shift) & bits(mask, i, shift)) << i
return v

给定一个mt_rand()函数的输出,并猜测它的LSB,我们可以得到两个可能的置乱状态值:一个输出的LSB是0,另一个LSB是1。(有疑问有疑问)

将他们组合起来

让我们把这些逆向的步骤放在一起:

从mt_rand()中获取两个值,中间由226个其他值分隔:R000和R227;i, R000之前生成的值的数量。
从这些值中获取打乱的状态。
XOR那些状态并推断出初始状态228
从s228回到s0得到种子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def main(_R000, _R227, offset):
# Both were >> 1, so the leftmost byte is unknown
_R000 <<= 1
_R227 <<= 1

for R000_0 in range(2):
for R227_0 in range(2):
R000 = _R000 | R000_0
R227 = _R227 | R227_0
S000 = undo_php_mt_rand(R000)
S227 = undo_php_mt_rand(R227)
seed = undo_php_mt_reload(S000, S227, offset)
if seed:
print(seed)

因为我们缺少R000和R227的LSB,所以我们需要尝试每种情况的算法。通常,这四种组合中只有一种会产生种子,而其他的组合是不可能产生种子的。
输出:

完整的python代码可以在我们的GitHub上找到。

结论

我们证明了,给定两个mt_rand()输出值被226个其他值分隔,可以在不使用任何暴力破解的情况下计算原始种子,从而获得任何以前或以后的mt_rand()的输出,从而有效地破坏mt_rand的PRNG算法。

hgame 二发入魂


抽卡x次即生成x个随机数,再加上上面表情包的提示,判断题目应该是想让做题的人在cdkey处提交随机数种子,根据弹窗提示,需要在两秒内完成,最好写脚本完成(手速快点应该也行)
exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests
import os

url = r"https://twoshot.hgame.n3ko.co/random.php?times=228"
session = requests.session()
response = session.get(url)
# print(response.text)
list = response.text.replace('[','').replace(']','').split(',')
# print(list)
cmd =os.popen("python reverse_mt_rand.py "+list[0]+" "+list[227]+" 0 0")
seed = cmd.read().strip()
print(seed)
cmd.close()
urlv = r"https://twoshot.hgame.n3ko.co/verify.php"
response2 = session.post(urlv,data={'ans':seed})
print(response2.text)

CATALOG
  1. 1. 只用两个输出值破解mt_rand函数,无需爆破
    1. 1.1. 介绍
    2. 1.2. 只用两个输出值和一点数学知识破解PHP的Mersenne Twister算法
    3. 1.3. 种子到初始状态
    4. 1.4. 从初始状态到打乱状态
    5. 1.5. 置乱状态值到mt rand()输出
    6. 1.6. 将他们组合起来
    7. 1.7. 结论
  2. 2. hgame 二发入魂