Khởi động nhẹ nhàng: How To Predict Random Numbers Generated By Computers
1. Python
Hàm random của Python sử dụng thuật toán Mersenne Twister - với 624 số nguyên 32 bit làm trạng thái, sau đó cứ mỗi lần xuất ra một số nguyên 32 bit, nó sẽ thay đổi trạng thái.
Mersenne Twister
Ta sẽ phân tích thuật toán tạo số ngẫu nhiên trong khoảng [0,0xffffffff]
: Link
|
|
Nếu index >= N (= 624)
, nghĩa là đã dùng hết số trong mảng mt[]
và do đó phải tạo lại 624 số tiếp theo. Mảng mt[]
được thay đổi bằng quá trình sau:
|
|
Sau đó, số y
ngẫu nhiên được tạo như sau:
|
|
Do đó, nếu ta có thể đảo ngược được quá trình này thì ta có thể dự đoán giá trị tiếp theo khi biết được 624 đầu ra 32 bit.
Khôi phục từ đầu ra đầy đủ 32-bit
Sau đây là các bước đảo ngược chi tiết:
y ^= (y >> 18)
Đây là dịch phải 18 bit, ảnh hưởng từ bit cao tới bit thấp và sau bước này thì bit cao không thay đổi cùng với việc $18*2 = 36 > 32$ nên ta chỉ cần 1 lần đảo là được.
|
|
y ^= (y << 15) AND 0xefc60000
Ở đây, mask 0xefc60000 có cấu trúc đặc biệt với làm phép biển đổi trở thành 1 hàm tự nghịch đảo, nghĩa là $f(f(x))=x$, với $f(x) = x \oplus ((x « 15)$ AND 0xefc60000). Chứng minh:
- Đặt A = 0xefc60000. Khi đó:
$f(y)=f(f(x)) = f(x \oplus ((x « 15) AND \ A))$
$= (x \oplus ((x « 15) AND \ A)) \oplus (((x \oplus ((x « 15) AND \ A)) « 15) AND \ A)$
$= (x \oplus ((x « 15) AND \ A)) \oplus (((x « 15) AND \ A) \oplus((x « 30) AND (A « 15) AND \ A))$
$= x \oplus (((x « 15) AND \ A) \oplus ((x « 15) AND \ A)) \oplus((x « 30) AND (A « 15) \ AND \ A) = x$
(vì tính chất $P \oplus P = 0$ và $A$ có 17 bit thấp là 0 nên nếu dịch trái 15 bit thì 32 bit cuối của $A « 15$ sẽ đều là 0 nên $(A « 15) AND \ A = 0$)
Do đó, để đảo ngược thì ta chỉ cần 1 lần biến đổi hàm nữa:
|
|
y ^= (y << 7) AND 0x9d2c5680
Ở bước này thì ta sẽ thiết lập một hàm dịch bit gần tương tự như bước trên:
- Đặt B = 0x9d2c5680. Với $y = x \oplus ((x « 7) AND \ B)$ , ta sẽ dùng hàm $f(x’) = y \oplus ((x’ « 7) AND \ B)$ Khi đó:
$f_4(y) = f_4(x \oplus ((x « 7) AND \ B)) = f_3(y \oplus (((x \oplus ((x « 7) AND \ B)) « 7)AND \ B))$
$= f_3(x \oplus (((x « 7) AND \ B) \oplus ((x « 7) AND \ B)) \oplus((x « (7\times2))AND (B « 7) AND \ B))$
$= f_3(x \oplus((x « (7\times2))AND (B « 7) AND \ B))$
$= f_2(x \oplus((x « (7\times3))AND (B « (7\times2)) AND (B « 7) AND \ B))$
$= f(x \oplus((x « (7\times4))AND (B« (7\times3)) AND (B « (7\times2)) AND (B « 7) AND \ B))$
$= x \oplus((x « (7\times5))AND (B« (7\times4)) AND (B« (7\times3)) AND (B « (7\times2)) AND (B « 7) AND \ B)$
$=x$
(vì B có 7 bit thấp là 0 nên nếu dịch trái 28 bit thì 32 bit cuối của $B « 28$ sẽ đều là 0 nên đẳng thức trên xảy ra.)
Do đó, để đảo ngược thì ta chỉ cần 4 lần biến đổi hàm như sau:
|
|
y ^= (y >> 11)
Đây là dịch phải 11 bit, ảnh hưởng từ bit cao tới bit thấp và sau bước này thì bit cao không thay đổi cùng với việc $11*3 = 33 > 32$ nên ta chỉ cần 2 lần đảo để khôi phục từ bit cao đến bit thấp.
|
|
Tổng hợp lại thì ta có hàm đảo (untemper):
|
|
Ví dụ cụ thể:
|
|
- State được phục hồi có 3 phần tử:
- Số nguyên 3, là chỉ số “vị trí trạng thái” được module random của Python sử dụng để chỉ ra vị trí của bộ tạo trong mảng trạng thái.
- Bộ dữ liệu gồm 624 số nguyên, biểu diễn mảng trạng thái đã phục hồi.
- None là “giá trị giữ chỗ” để đảm bảo tính tương thích với định dạng trạng thái mà module random của Python mong đợi.
Khi chạy đoạn kiểm thử trên thì dòng lệnh đối chiếu với assert
không bị lỗi, chứng tỏ đã khôi phục thành công state ban đầu.
2. Javascript
Phương thức tĩnh Math.random()
trả về một số dấu phẩy động, ngẫu nhiên lớn hơn hoặc bằng 0 và nhỏ hơn 1, với phân phối gần như đồng đều trong phạm vi đó — sau đó bạn có thể điều chỉnh theo phạm vi mong muốn. Việc triển khai sẽ chọn seed
ban đầu cho thuật toán tạo số ngẫu nhiên; người dùng không thể chọn hoặc đặt lại seed
này.
- XorShift128 trả về trạng thái 128-bit qua
state0, state1
và được triển khai như sau:
|
|
- Khi
cache
trống, 1 chuỗi 64 giá trị ngẫu nhiên mới sẽ được tạo thành qua xs128 và lưu trữ trongcache
với chỉ số được đặt ở cuối.
|
|
- Lúc gọi
Math.random()
thìnext()
được gọi và đưa ra 1 giá trị thập phân từ 0.0 đến 1.0. Ở đây thì v8 chuyển đổi số từ xs128 sang double bằng cách bỏ 12 bit cuối, còn 52 bit thì đưa vào phần Mantissa của số double trong chuẩn IEEE754. Sau đó trừ đi 1 và có được giá trị thập phân từ 0.0 đến 1.0.
|
|
Khôi phục trạng thái từ một lượng giá trị ngẫu nhiên được cho
Math.random()
của V8 sử dụng xs128
gồm các biểu thức tuyến tính trong hệ GF(2). Do đó, ta có thể xây dựng một hệ các phương trình tuyến tính trên GF(2) với 1 lượng giá trị ngẫu nhiên cho trước vừa đủ và từ đó, ta được nghiệm của hệ này là hai trạng thái ban đầu (seed) của thuật toán và dự đoán được giá trị ngẫu nhiên tiếp theo.
Chúng ta sẽ xây dựng hệ như sau:
|
|
- Đây là những giá trị ngẫu nhiên cho trước
|
|
state0, state1
là haisymbolic unknown
có 64-bit và nằm trong hệ tuyến tính.Random
sẽ tạo những biểu thức tuyến tính chính làout
với ẩn làstate0, state1
để xây dựng hệ.
|
|
- Với mỗi cặp giá trị và phương trình ẩn, ta khôi phục 52 bit mantissa của đầu ra và đưa về chuẩn IEEE754 cho dạng double + 1.0 rồi XOR với biểu thức ẩn đã cho.
- Nếu biểu thức này trùng với giá trị đầu ra thì sau khi XOR sẽ bằng 0. Vậy là ta đã có hệ tuyến tính ẩn
state0, state1
, giải hệ này và ta nhận được trạng thái ban đầu.
|
|
3. C/C++
Bên ngôn ngữ C/C++, hàm rand() xây dựng dựa trên GLIBC random number generator
Hàm random()
của thư viện GNU C cung cấp các số giả ngẫu nhiên thông qua phương pháp phản hồi cộng tuyến tính. Phản hồi này tuyến tính theo modulo $2^{32}$. Tính phi tuyến tính nhỏ duy nhất xuất hiện trong giai đoạn seeding
, do phép này được thực hiện theo modulo $2^{31} - 1$ chứ không phải modulo $2^{31}$ hay $2^{32}$. Điều này có nghĩa là, mặc dù mỗi số ngẫu nhiên được tạo ra phụ thuộc tuyến tính vào các số trước đó trong chuỗi, nhưng toàn bộ các số ngẫu nhiên không phụ thuộc tuyến tính vào seed
.
Thuật toán
Như mô tả ở trên, ta có: $2147483647 = 2^{31} - 1$ và $4294967296 = 2^{32}$
Với mỗi seed
cho trước, 1 initialization vector
(iv) $r_0…r_{33}$ được tính như sau:
- $r_0$ =
seed
- $r_i\equiv (16807 * r_{i-1}) \mod 2147483647 \ ( i \in [1,30])$
- $r_i=r_{i-31}\ (i\in [31, 33])$
Sau đó, 1 chuỗi các số ngẫu nhiên $r_{34}…$ được tạo bằng vòng lặp phản hồi tuyến tính:
- $r_i\equiv r_{i-3}+r_{i-31}\mod 4294967296 \ (i \ge 34)$
$r_0…r_{343}$ không được sử dụng mà đầu ra $o_i$ của hàm rand()
là: $o_i =r_{i+344} \gg 1$
Code in C:
|
|
Khôi phục
Phân tích thuật toán trên thì ta thấy rằng:
- Từ $i\ge34$, $r_i\equiv r_{i-3}+r_{i-31}$ là tuyến tính và có nghĩa là khi ta biết được 2 trong 3 giá trị thì sẽ suy ra được giá trị còn lại.
- Với đầu ra $o_i =r_{i+344} \gg 1$, nghĩa là nó có 31 bit và mất bit bên phải ngoài cùng $r_{i+344}$
- Nghĩa là nếu ta có đủ đầu ra liên tiếp thì hoàn toàn có thể lan truyền để khôi phục lại mảng trạng thái ban đầu (iv).
Các bước để khôi phục iv như sau:
- Đoán bit thấp bị bỏ rồi ghép thành một phần trạng thái:
|
|
Nếu ta có $o_i, o_{i-31},o_{i-3}$ và chúng không thỏa:
|
|
Đoán trạng thái của nỏ ở dạng:
$$ \begin{cases} s_{i-31} = o_{i-31} << 1 + 1 \\ s_{i-3} = o_{i-3} << 1 + 1 \\ s_i = o_i << 1 + 0 \end{cases} $$Nếu không, gắn $s_i =$ None vì chưa biết dạng chuẩn.
- Khôi phục trạng thái:
Chạy ngược công thức cộng để khôi phục các giá trị (nếu có) trong 344 giá trị đầu
|
|
Nếu có 1 giá trị trong khoảng 31 giá trị đầu, ta có thể tính tất cả các giá trị còn lại vì tính chất tuyến tính bằng công thức: $r_i\equiv (16807 * r_{i-1}) \mod 2147483647 \Leftrightarrow r_{i-1}\equiv (16807^{-1} * r_i) \mod 2147483647$
|
|
Vì tính chất tuyến tính $r_i \equiv r_{i-3}+r_{i-31}, \forall i \ge 34$ nên nếu ta biết 2 giá trị thì suy ra được giá trị còn lại. Do đó, ta viết hàm lặp đến khi không thể khôi phục giá trị nào nữa để điền tất cả các giá trị còn thiếu.
|
|
Điền 3 giá trị đầu bằng các sao chép từ thứ tự 31 và lấy được seed
ban đầu:
|
|
Full code ở đây…
4. Golang
PRNG của Golang xây dựng dựa trên Lagged Fibonacci Generator.
Thuật toán
Hàm tạo seed
cho rng: $x_{n+1} = 48271 * x_n \mod (2^{31} - 1)$
|
|
Để tránh tràn số thì code dùng Schrage’s Method (hi = x // Q, lo = x % Q).
Tiếp theo, ta có class RNGSource
là PRNG chính.
|
|
Hai con trỏ là tap
và feed
chạy ngược nhau, thực hiện phép cộng sinh số ngẫu nhiên rồi sau đó lưu trữ trạng thái này vào mảng vec
, tổng 607 phần tử 64-bit.
|
|
Hàm seed()
sẽ nhận giá trị seed
và tạo ra mảng trạng thái vec
như sau:
- x được gọi
seedrand()
20 lần để tạo số ngẫu nhiên. - Ghép 3 giá trị 31-bit thành số 64-bit qua hàm
seedrand()
với $x « 40, x « 20, x$. - XOR với 1 giá trị trong bảng
rng_cooked[]
.
Tiếp theo, ta sẽ sinh số ngẫu nhiên 64-bit qua vec
bằng hàm uint64()
:
|
|
- Hai con trỏ
tap
vàfeed
đi lùi trong vòng có độ dài 607. - Theo đó là công thức Additive Lagged-Fibonacci Generator: $$ vec_{feed}=(vec_{feed} + vec_{tap}) AND 0xFFFFFFFFFFFFFFFF $$
|
|
Khôi phục
Như ta thấy, trong golang thì từ sau giai đoạn sinh seed
, giai đoạn sinh số ngẫu nhiên chỉ gồm phép cộng tuyến tính trên chuỗi seed
lấy được. Nghĩa là nếu ta có chuỗi đầu ra đủ dài, ta có thể xây dựng hệ phương trình tuyến tính để tính chuỗi seed
ban đầu.
Để xây dựng được hệ như vậy, mình nghĩ ngay tới z3-solver và sau đây là phần crack:
|
|
5. Bash
Bash dùng linear congruential generator (LCG) là biến thể của thuật toán Park–Miller “minimal standard” để tạo số ngẫu nhiên 31-bit.
Thuật toán
Hàm next_seed
sinh seed
mới bằng cách áp dụng công thức Park-Miller LCG:
với $a = 16807,\ m = 2^{31} - 1$
|
|
Giống Golang, nhằm tránh bị tràn số thì ta dùng Schrage’s Method để tách h và l.
Với seed
đó, ta sinh giá trị ngẫu nhiên bằng hàm next_16
:
|
|
Có 2 phiên bản khác nhau:
- Từ bản Bash 5.0 trở xuống, ta chỉ lấy 15 bit thấp.
- Bash 5.1 trở lên: XOR 2 giá trị 16 bit cao và 16 bit thấp của
seed
rồi trả về 15 bit cuối.
Bên cạnh đó, nếu số mới giống số cũ, ta bỏ qua và tính số mới.
Khôi phục
Vì ở đây, ta không có được trực tiếp $X_n$ mà chỉ lấy được giá trị của nó sau hàm next_16
nên không thể truy ngược được Park-Miller LCG. Do đó, kỹ thuật mình có thể sử dụng ở đây để crack là brute-force.
Mặt khác output
chỉ cho 15 bit, trong khi state
có 31 bit, thế nên chắc chắn có rất nhiều state
cho ra cùng 1 giá trị output
. Để lọc được state
phù hợp, ta cần nhiều giá trị output
liên tiếp để chắc chắn tìm được duy nhất 1 state.
Nếu không có nhiều output
như vậy, ta có thể tìm những state
phù hợp để thử và tìm ra kết quả cuối cùng.
Do seed
có 32 bit, ta nên sử dụng nhiều luồng nhằm chia các khoảng của nó và chạy các task song song để giảm thời gian thực hiện và tối ưu hóa bộ nhớ. Ở đây, mình sử dụng Pool của thư viện multiprocessing trong Python để chia luồng, rồi thực hiện các task duyệt, so sánh với đầu ra cho trước và trả về seed
nếu khớp.
Full code ở đây…
All code in Github
https://github.com/R1MURUN0PR0/Crack_random/tree/main
Tài liệu
- https://soon.haari.me/import-random/
- https://rbtree.blog/posts/2021-05-18-breaking-python-random-module/
- https://github.com/JorianWoltjer/BashRandomCracker?tab=readme-ov-file
- https://en.wikipedia.org/wiki/Xorshift
- https://github.com/maple3142/gf2bv
- https://www.mscs.dal.ca/~selinger/random/
- https://github.com/d0nutptr/v8_rand_buster