MeepwnCTF2018-Still old school

翻譯自:http://blog.redrocket.club/2018/07/18/meepwn-quals-2018-StillOldSchool/
題目給了服務器的腳本,和一個服務。
腳本如下:

from secret import flag, mask1, mask2
import string
import random
import sys
import os
import signal
import hashlib
from Crypto.Cipher import AES

menu = """
CHOOSE 1 OPTION
1. Encrypt message
2. Decrypt message
3. Get encrypted flag
4. Exit\n
"""

sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0)
bs = 16

def to_string(num, max_len = 128):
    tmp = bin(num).lstrip('0b')[-max_len:].rjust(max_len, '0')
    return "".join(chr(int(tmp[i:i+8], 2)) for i in range(0, max_len, 8))

def pad(s):
    padnum = bs - len(s) % bs
    return s + padnum * chr(padnum)

def unpad(s):
    return s[:-ord(s[-1])]

def gen_key(mask):
    tmp1 = random.random()
    tmp2 = random.random()
    key = int(tmp1 * 2**128) | int(tmp2 * 2**75) | (mask & 0x3fffff)
    key = to_string(key)
    return key

def encrypt_msg(msg, key1, key2):
    iv = to_string(random.getrandbits(128))
    aes1 = AES.new(key1, AES.MODE_CBC, iv)
    aes2 = AES.new(key2, AES.MODE_CBC, iv)
    enc = aes1.encrypt(aes2.encrypt(pad(msg)))
    return (iv + enc).encode("hex")

def proof_of_work():
    """
    This function has very special purpose 
    :)) Simply to screw you up
    """
    prefix = to_string(random.getrandbits(64), 64)
    print 'prefix = {}'.format(prefix.encode('hex'))
    challenge = raw_input('> ')
    tmp = hashlib.sha256(prefix + challenge).hexdigest()
    if tmp.startswith('00000'):
        return True
    else:
        return False

key1 = gen_key(mask1)
key2 = gen_key(mask2)

signal.alarm(300)

if not proof_of_work():
    exit(0)

for _ in range(256):
    print menu
    try:
        choice = int(raw_input("> "))
    except:
        print "wrong option"
        exit(-1)
    if choice == 1:
        msg = raw_input("give me a string: ")
        print encrypt_msg(msg, key1, key2)
    elif choice == 2:
        print "Not implement yet..."
    elif choice == 3:
        print encrypt_msg(flag, key1, key2)
    elif choice == 4:
        exit(-1)
    else:
        print "wrong option"
        exit(-1)

通過這個服務我們可以得到加密過的flag,或者讓其加密我們輸入的內容,服務 會用不同的key做兩次AES加密:

def encrypt_msg(msg, key1, key2):
    iv = to_string(random.getrandbits(128))
    aes1 = AES.new(key1, AES.MODE_CBC, iv)
    aes2 = AES.new(key2, AES.MODE_CBC, iv)
    enc = aes1.encrypt(aes2.encrypt(pad(msg)))
    return (iv + enc).encode("hex")

加密用的key通過如下的隨機算法生成:

def gen_key(mask):
    tmp1 = random.random()
    tmp2 = random.random()
    key = int(tmp1 * 2**128) | int(tmp2 * 2**75) | (mask & 0x3fffff)
    key = to_string(key)
    return key

每個key都包含一個未知的mask,mask最長為22bit
python 的random.random函數并不是一個密碼學安全的隨機數生成器(RNG),一旦我們能夠獲得足夠的輸出,我們可以還原出tmp1,tmp2。
同時我們可以注意到AES CBC模式使用的iv唄提供給了我們,而且iv是使用相同的RNG生成的:

iv = to_string(random.getrandbits(128))

當我們還原出tmp1,tmp2之后,可以通過爆破的方式得到對應的mask。

梅森旋轉算法(Mersenne Twister)

通過print語句,我們可以知道該腳本是用python2編寫的。CPython2.7使用的是梅森旋轉算法偽隨機數發生器( Mersenne Twister Pseudo Random Number Generator)。https://en.wikipedia.org/wiki/Mersenne_Twister
該算法依賴于624個內部狀態(每個狀態為int32值),這些內部狀態遵循如下的關系:


即每個狀態會依賴于之前的三個數字。
當生成當前輸出的隨機數時,會進行一定的數學變換來滿足一些性質:

[...]
y = mt[self->index++];
y ^= (y >> 11);
y ^= (y << 7) & 0x9d2c5680UL;
y ^= (y << 15) & 0xefc60000UL;
y ^= (y >> 18);
return y;

如果我們想要恢復tmp1,需要:

  • 請求156個隨機的iv(生成一個iv需要4個int32)
  • 根據輸出的隨機數恢復內部狀態 Yi
  • 計算在生成key時用到的內部狀態Yi-N+1
  • 重現gen_key函數中生成的隨機數

但還存在一些問題,在更新內部狀態的過程中,之前狀態的最低bit位丟失了。因此在根據后面的內部狀態倒推之前的狀態的過程中,我們會得到兩個可能的結果。
再仔細看random.random函數的實現,需要兩個32bit的內部狀態:

static PyObject * random_random(RandomObject *self)
{
    unsigned long a=genrand_int32(self)>>5, b=genrand_int32(self)>>6;
    return PyFloat_FromDouble((a*67108864.0+b)*(1.0/9007199254740992.0));
}

又由于我們需要得到兩個key,因此一共有 (22)2=16種可能。
通過如下的代碼計算可能的內部狀態:

def inv(x):
    x ^= (x >> 18)
    # Lowest 16 bit stay how they are, so we can just repeat...
    x ^= (x << 15) & 0xEFC60000
    # Do it step by step
    x ^= (x << 7) & 0x1680
    x ^= (x << 7) & 0xC4000
    x ^= (x << 7) & 0xD200000
    x ^= (x << 7) & 0x90000000
    # Only highest 11 bits are untouched
    x ^= (x >> 11) & 0xFFC00000
    # Do step by step again
    x ^= (x >> 11) & 0x3FF800
    x ^= (x >> 11) & 0x7FF
    return x
    
def recover_state(i, outputs32):
    """
    return all possible candidates for state how it was (i-624) iterations ago!
    """


    Y = inv(outputs32[i - 1])
    h_1 = Y ^ inv(outputs32[i - 227 - 1])
    Y_old = inv(outputs32[i])
    h_1_msb = ((Y_old ^ inv(outputs32[i - 227]))>>30) & 1

    h_2 = h_1 
    h_2_alt = h_1 ^ 0x9908B0DF

    # even case
    h_2 = (h_2 << 1) & 0x7fffffff
    # odd case
    h_2_alt = ((h_2_alt << 1)|1) & 0x7fffffff
    
    # Add the missing highest bit (recovered from successive output)
    h_2 = (h_1_msb<<31)|h_2
    h_2_alt = (h_1_msb<<31)|h_2_alt

    candidates = [h_2, h_2_alt]
    return candidates

再窮舉16種可能的情況,推導random.random計算出的16個可能的浮點數:

def float_magic(a, b):
    """
    Rebuild of random_rancom from randommodule.c
    uses two outsputs!
    """
    a = a >> 5
    b = b >> 6
    return (a*67108864.0+b)*(1.0/9007199254740992.0)

def floats_for_cands(a_cs, b_cs):
    """
    Applies float_magic to all candidate combinations
    """
    floats = []
    for a_c in a_cs:
        for b_c in b_cs:
            floats.append(float_magic(a_c, b_c))
    return floats

還需要注意,在key_gen和得到第一個iv見的proof of work也用到了2個內在狀態。
當我們逆向推導出所有的16種可能后,我們還需要找出key對應的mask。
如果我們采用純爆破的方法,一個mask最多有22bit長,計算量為

222 * 222=244

顯然計算量過大。
一個替代做法是:

  • 發送一個任意的明文M給服務器,存下對應的密文C
  • 用所有可能的 16 * 222 個key2 解密密文C得到 D
  • 用哈希表存儲所有 的key2i 和對應的 Di
  • 用所有可能的16 * 222個key1 加密明文M得到E
  • 在哈希表中查找和Ei相同的Di,即能確定最終的key1,key2以及mask1,mask2

通過這種方法,總的計算量為 2*16 * 222=227 在可行范圍內??梢允褂枚嗑€程在多核機器上運行,加快窮舉速度。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 曾經去一家公司參觀考察,前臺的招待人員是一個年輕漂亮的小姑娘,明眸皓齒、唇色紅艷,站在那里笑盈盈地迎著我們一行人,...
    Amay梅閱讀 348評論 0 3
  • 重陽節就這樣悄無聲息過去了,我原來并不覺得它可以跟春節,中秋節,端午節這樣大眾的節日相媲美,對它的印象只有小時候學...
    劉取丹昕趙汗情閱讀 559評論 0 1
  • 一個女人脫著一副沉重的棺材走在漫天的星空中,每一步都很小心,他對著身邊的兒子說:“跟進媽媽的腳步,不要被主神那個...
    小想子閱讀 1,117評論 0 0