## Math.random() in Nodejs https://github.com/m1dm4n/CTF-WriteUp/blob/main/2023/plaidCTF/README.md Hàm này được xây dựng dựa trên [XorShift128+ algorithm](https://github.com/v8/v8/blob/master/src/base/utils/random-number-generator.h#L119) ### XorShift128+ algorithm Là một PRNG đơn giản với 128-bit `state` Thuật toán này được thực hiện như sau: ```cpp! static inline void XorShift128(uint64_t* state0, uint64_t* state1) { uint64_t s1 = *state0; uint64_t s0 = *state1; *state0 = s0; s1 ^= s1 << 23; s1 ^= s1 >> 17; s1 ^= s0; s1 ^= s0 >> 26; *state1 = s1; } ``` Ta có 2 giá trị `state0` và `state1`. Ở mỗi lần gọi hàm: `state1` được cập nhật giá trị là kết quả của các phép tính `XOR` và `shift` từ `state1` và `state0`. `state0` nhận giá trị `state1` trước đó. ### Cách hoạt động Ta sẽ thử in ra một số giá trị random ```nodejs! nums = []; for(var i=0; i<5; ++i) { nums.push(Math.random()) }; console.log(nums); ``` output: ``` [ 0.30135435755863194, 0.3726454850458716, 0.4198182193668114, 0.7236693563736067, 0.00626728852787628 ] ``` ![73d66c44-c29f-4b95-83af-84cf49353fc6](https://emoji.discadia.com/emojis/4aa11ab3-4726-4ca7-ad10-f93b49c76743.png) Các giá trị được sinh ra là các số thập phân nằm trong khoảng $(0, 1)$. Các số nguyên 64bits đã được đưa về dạng `doubles` theo fomat của [IEEE 754 double-precision](https://en.wikipedia.org/wiki/Double-precision_floating-point_format). Bao gồm: - Sign bit: 1 bit - Exponent: 11 bits - Significand precision: 53 bits (52 explicitly stored) ![image](https://hackmd.io/_uploads/BJjxliK-yl.png) với 0x3fff được áp dụng cho exponent và sign, $e = 1023$ và $sign = 0$ giá trị thực của một số dấu phẩy động 64-bit theo chuẩn double-precision (độ chính xác kép) được tính như sau: $$ -1^{sign}(1+\sum_{i=1}^{52}{b_{52-i}2^{-i}}) \times 2^{e-1023} = \ (1+\sum_{i=1}^{52}{2^{-i}}) $$ Sau khi trừ cho 1 thì luôn nhận được giá trị trong khoảng $(0,1)$. ```cpp! // Static and exposed for external use. static inline double ToDouble(uint64_t state0) { // Exponent for double values for [1.0 .. 2.0) static const uint64_t kExponentBits = uint64_t{0x3FF0000000000000}; uint64_t random = (state0 >> 12) | kExponentBits; return base::bit_cast<double>(random) - 1; } ``` Và đây là cách convert lại `state0` ```cpp! static inline uint64_t FromDouble(double value) { // Exponent for double values for [1.0 .. 2.0) static const uint64_t kSignBitMask = uint64_t{0x7FFFFFFFFFFFFFFF}; // Add 1.0 to shift [0.0 .. 1.0) to [1.0 .. 2.0) value += 1.0; // Convert double to uint64_t by bit-casting, then remove the sign bit uint64_t bits = std::bit_cast<uint64_t>(value) & kSignBitMask; return bits; } ``` Như vậy ta thấy cách hoạt động của nó khá đơn giản và chỉ dựa trên phép `XOR` và `SHIFT`. Nên ta hoàn toàn có thể tìm lại được các `state` bằng Z3.Mình ở sẽ không trình bày cách giải ở đây. Hàm **v8** không trả về giá trị random ngay lập tức mà lưu trữ 64 giá trị cùng lúc để có hiệu suất nhanh hơn và tạo ra đầu ra theo thứ tự ngược lại nên Math.random() sẽ giống thế này trong python. ```python! def xs128p(state0, state1): s1 = state0 s0 = state1 s1 ^= (s1 << 23) & MASK s1 ^= (s1 >> 17) & MASK s1 ^= s0 s1 ^= (s0 >> 26) & MASK return state1, s1 def to_double(value): double_bits = (value >> 12) | 0x3FF0000000000000 return unpack('d', pack('<Q', double_bits))[0] - 1 def next_random(state0, state1): while True: cache = [] for i in range(64): state0, state1 = xs128p(state0, state1) cache.append(to_double(state0)) for i in reversed(cache): yield i rng = next_random(..., ...) print(next(rng)) ``` ### Implementing symbolic PRNG state Mỗi bit của `state` có thể được biểu diễn tuyến tính với các bit xác định trước của trạng thái ban đầu. Vì dùng các phép biến đổi XOR(là phép cộng trên trường GF(2)). Gọi $V$ là ma trận đơn vị chứa 128 vectors, $S$ là vector chứa 128 bits của `state` bao gồm cả `state0` và `state1`. Ta có: $V*S = S$. Ví dụ `state0 = state0^state1` ta sẽ có state mới được cập nhật như sau: $\begin{pmatrix} 1_{1 - 1} & \dots & \dots & 1_{1 - 65} & \dots & 0_{1 - 128} \\ \vdots & \ddots & & \vdots & \ddots & \vdots \\ \vdots & & 1_{64 - 64} & \vdots & & 1_{64 - 128} \\ 0_{65 - 1} & \dotsb & \dots & 1_{65 - 65} & \dots & 0_{65 - 128} \\ \vdots & & & \vdots & \ddots & \vdots \\ 0_{128 - 1} & \dotsb & \dotsb & \dotsb & \dotsb & 1_{128 - 128} \end{pmatrix} * \ \begin{pmatrix} s_1 \\ s_2 \\ \vdots \\ s_{64} \\ s_{65} \\ \vdots \\ s_{128} \end{pmatrix} = \ \begin{pmatrix} s_1 + s_{65} \\ s_2 + s_{66} \\ \vdots \\ s_{64} + s_{128} \\ s_{65} \\ \vdots \\ s_{128} \end{pmatrix}$ Python code: - Khởi tạo `Add`, `ShiftRight` và `Shiftleft` ```python= def Add(a, b): c = [] for i, j in zip(a, b): c.append(i + j) #like i said, adding 2 vectors in GF(2) work same as xoring 2 numbers return c def SL(a, n): return a[n:] + [vector(F, 128)]*n def SR(a, n): return [vector(F, 128)]*n + a[:64 - n] ``` Ta sẽ xây dựng các state tiếp theo dựa trên state đầu như sau. Symbolic the Xorshift128 function: ```python= def sym_xs128p(sym_state0, sym_state1): # Symbolically represent xs128p s1 = sym_state0 s0 = sym_state1 s1 = Add(s1, SL(s1, 23)) s1 = Add(s1, SR(s1, 17)) s1 = Add(s1, s0) s1 = Add(s1, SR(s0, 26)) return sym_state1, s1 ``` Cần đủ ma trận để thiết lập 128 phương trình độc lập - Mục tiêu là khôi phục state0 và state1, mỗi cái 64 bit - Tổng cộng cần tìm 128 bit chưa biết - Do đó cần ít nhất 128 phương trình độc lập để giải hệ phương trình Mỗi giá trị từ Math.random() cho ta 52 bit từ state0. Nhưng không phải lúc nào ta cũng nhận được đầy đủ 52 bit này nên ta cần biến tấu với yêu cầu của đề bài. Do server có giới hạn thời gian, lưu trữ 820 ma trận đầu tiên của 820 đầu ra Math.random() để tra cứu nhanh hơn: ```python= F = GF(2) v = [vector(F, [0]*i + [1] + [0]*(127-i)) for i in range(128)] state0 = v[:64] state1 = v[64:] N = 64 + (250+128)*2 Bigg = [] for i in range(N): state0, state1 = sym_xs128p(state0, state1) Bigg.append(state0[:-12]) # Output only using 52 bits for fraction save(Bigg, f'equals') ``` Vì các trạng thái tiếp theo sẽ là tổng của 64 tập con từ 128 vector ban đầu của chúng ta, chúng ta có thể xây dựng một ma trận hệ số và giải hệ phương trình với 128 ẩn để khôi phục trạng thái ban đầu sau khi thu thập đủ các đầu ra. Mỗi đầu ra từ Math.random() sẽ cho chúng ta 52 bit từ state0 hiện tại. ### Predict Trước tiên, cần tải các ma trận symbolic đã lưu trước đó: ```python= equals = load(f'equals.sobj') ``` Nhớ rằng Node.js sử dụng bộ nhớ đệm cho mỗi 64 số, tạo một hàm hoạt động như một pivot trong việc tra cứu phương trình: ```python= def next_idx(): k = 63 while True: for l in range(k, k-64, -1): # Trả về index ngược từ k đến k-63 yield l k += 64 # Tăng k thêm 64 cho lần lặp tiếp theo ``` Như đã đề cập, cần xây dựng một hệ phương trình và giải nó. Ma trận có rank đầy đủ sẽ luôn có nghiệm nên ta sẽ nạp ma trận cho đến khi có một ma trận với rank 128: ```python= def solve_random(payloads, pre_run, idx_rng, leaks, step=0): Ms = matrix(F, 128, 128) # Ma trận hệ số 128x128 ans = vector(F, 128) # Vector kết quả 128 phần tử c = 0 # Đếm số hàng đã thêm vào ma trận # Bỏ qua pre_run số đầu ra đầu tiên for i in range(pre_run): next(idx_rng) # Xử lý từng giá trị trong payload for value in payloads: if c == 128: # Đã đủ 128 hàng độc lập break i = next(idx_rng) # Bỏ qua step lần gọi Math.random() for _ in range(step): next(idx_rng) # Xử lý các bit rò rỉ từ mỗi giá trị for j in range(len(leaks[value])): if leaks[value][j] is None: # Bỏ qua nếu bit không rò rỉ continue Ms.set_row(c, equals[i][j]) # Thêm hàng mới vào ma trận if Ms.rank() > c: # Kiểm tra nếu hàng mới độc lập ans[c] = leaks[value][j] # Thêm giá trị bit rò rỉ vào vector kết quả c += 1 if c == 128: break # Giải hệ phương trình Ms * state = ans state = Ms.solve_right(ans) # Chuyển đổi vector nhị phân thành số nguyên state0 = int(''.join(map(str, state[:64])), 2) state1 = int(''.join(map(str, state[64:])), 2) return state0, state1 ``` Trong đó: - Tham số `pre_run`: Math.random() có thể đã được chạy trước khi rò rỉ một số đầu ra cho chúng ta. - Tham số `leaks`: Đầu ra không phải lúc nào cũng rò rỉ toàn bộ giá trị ngẫu nhiên. Ví dụ nếu nhân với 4 rồi làm tròn: - leaks = [[0,0], [0,1], [1,0], [1,1]] ```python= from new_xs128p import * import os import math b4 = [ [0, 0], [0, 1], [1, 0], [1, 1], ] seed = os.urandom(16) original_state0, orginal_state1 = int.from_bytes( seed[:8], 'big'), int.from_bytes(seed[8:], 'big') print("original seed: (%s, %s)" % (original_state0, orginal_state1)) rng = next_random(original_state0, orginal_state1) randoms = [next(rng) for i in range(15 + 192 + 128)] mult = 4 payloads = [math.floor(random * mult) for random in randoms] print("Last 128 value of payloads:\n\t", payloads[-128:]) state0, state1 = solve_random(payloads[15:-128], 15, next_idx(), b4) print("recovered seed: (%s, %s)" % (state0, state1)) new_rng = next_random(state0, state1) for i in range(15 + 192): next(new_rng) predict_random = [math.floor(next(new_rng)*mult) for i in range(128)] print("Predicted value:\n\t", predict_random) ``` - 15 là số lần đã chạy Math.random() - Ta cần thêm 192 lần random (64*3) giúp đồng bộ cache lại đúng với trạng thái ban đầu mà nó đã có khi tạo ra chuỗi số ngẫu nhiên mà ta đang cố gắng dự đoán. - 128 là các giá trị tiếp theo. Tham số step: Dùng khi mỗi lần output gọi nhiều lần Math.random() Vậy là ta đã thành công!! ![image](https://hackmd.io/_uploads/ryHML40Wkx.png)