文章总结: 本文分析江苏省第四届数据安全技术应用职业技能竞赛初赛中的数据泄露题目,聚焦SQL布尔盲注日志分析。通过解析HTTP响应长度差异(24字节为True,47字节为False),利用二分法逐字符还原数据库内容,最终成功提取用户名admin和密码Adm1n@2026。文章提供了详细的日志分析步骤和自动化脚本,适用于CTF取证和应急响应场景。 综合评分: 83 文章分类: 漏洞分析,应急响应,实战经验
数据泄露——江苏省第四届数据安全技术应用职业技能竞赛初赛
原创
一只岸上的鱼 一只岸上的鱼
一只岸上的鱼
2026年7月13日 21:07 江苏
在小说阅读器读本章
去阅读
数据泄露——江苏省第四届数据安全技术应用职业技能竞赛初赛
缘起
其实这次赛事组织的真不咋地,一会儿没插座,一会儿附件无法下载,还有题目是奇奇怪怪的,比如这题,附件使用usb拷贝的,有三问,但是实际试卷却只有俩问……
总的来说,这是一道经典的题目,就是sql注入日志分析,这题还算简单————因为题目常规,脚本是备好的,
可惜,还是没得分,第一问做出来了,但是题目没有,第二问也做出来了,但是一直答案不对……,第三问就没做了
题干
先说,题目附件我放在cnb了:https://cnb.cool/netvvorm/items/datasecurity26
第一问:
但是附件下载后,第一问实际是:
分析
这种sql注入是经典题目,有时候还会套一层流量分析:就是先要从流量包里面导出这些日志,notepad打开是这样的:
有个小技巧,使用notepad++自带的工具进行url decode:
这样比较容易看得清楚:
布尔盲注
什么叫盲注:就是select的信息内容不会实际返回
什么叫布尔:即使返回结果根据对错有无,返回只有2中情况,所以借以判断注入结果
一般数据库、表、字段命名,大部遵循ascii码,而ascii码的值的范围是0-127,所以有一种简单的办法就是将想要查询的字段的每个字符与所有的ascii码进行比较,如果返回的结果与预期不符,则说明该字符不存在,否则存在,这样就可以逐个字符的查询了,当然实用的方法是二分法,而不是真的顺序查询。
以本题的第一个字符为例:
实际执行的sql是这样的:
sql
-- 布尔条件:第1个字符的ASCII码是否大于97?
-- 注入字段
ORD(MID((SELECT IFNULL(CAST(username AS NCHAR),0x20) FROM user_data.`user` ORDER BY id LIMIT 0,1),1,1))>63 AND 'CWkt'='CWkt
-- 后台执行的sql(例)
SELECT * FROM user WHERE id = 1 AND ORD(MID((SELECT username FROM user LIMIT 0,1),1,1))>63
然后来分析日志:
- 先确认true和false
返回的结果都是http code=200,但是长短只有2个,一个是24,一个47,看一眼(这一眼是指根据二分法查找的值的变化来判断的),24=true,47=false,
小知识:判断一般有这几种情况
| 信号类型 | 示例 | | — | — | | 响应体长度不同 | True→24字节, False→47字节 | | HTTP状态码不同 | True→200, False→302 | | 响应时间差异 | True→<100ms, False→>500ms(时间盲注) | | 页面特征文本 | True含”Welcome”, False含”Error” |
- 二分法轮询
code
第1轮:> 63 ? → True → 值在 (63, 127]
第2轮:> 95 ? → True → 值在 (95, 127]
第3轮:> 111 ? → False → 值在 (95, 111]
第4轮:> 103 ? → False → 值在 (95, 103]
第5轮:> 99 ? → False → 值在 (95, 99]
第6轮:> 97 ? → False → 值在 (95, 97]
第7轮:> 96 ? → True → 值 = 97 ✓
asscii码的值是97,所以第一个字符是a
后面就简单的了,最终结果:
+----------+------------+
| 字段 | 值 |
+----------+------------+
| username | admin |
| password | Adm1n@2026 |
+----------+------------+
脚本:
这个脚本是AI给的,因为他比我的脚本好看,就提供这个了:
python
#!/usr/bin/env python3
"""
SQL Boolean Blind Injection Log Analyzer
========================================
从Web访问日志中还原SQL布尔盲注(二分搜索模式)窃取的数据库内容。
适用场景:
- CTF取证题:给定access.log,还原被盲注窃取的数据
- 应急响应:分析攻击者通过盲注读取了哪些敏感字段
支持的Payload格式(sqlmap常见风格):
ORD(MID((SELECT IFNULL(CAST(field AS NCHAR),0x20)
FROM database.`table` ORDER BY col LIMIT row,1),pos,1))>threshold
用法:
python sqli_blind_log_analyzer.py -f access.log [--true-size 24] [--false-size 47] [--auto-detect]
作者:CTF Log Forensics Tool
"""
import re
import sys
import argparse
import urllib.parse
from collections import defaultdict
# ──────────────────────── 正则模式 ────────────────────────
# 匹配sqlmap风格的布尔盲注payload
PAYLOAD_PATTERN = re.compile(
r"ORD\(MID\(\(SELECT IFNULL\(CAST\((\w+) AS NCHAR\),0x20\)"
r" FROM (\w+)\.`(\w+)` ORDER BY \w+ LIMIT (\d+),1\)"
r",(\d+),1\)\)>(\d+)"
)
# 更宽松的匹配:不要求ORD/MID/IFNULL的具体格式,只要有关键结构
LOOSE_PATTERN = re.compile(
r"(?:ORD|ASCII)\(.*?MID\(.*?(?:SELECT|select).*?"
r"(\w+).*?LIMIT\s+(\d+),1.*?,(\d+),1\).*?>(\d+)",
re.IGNORECASE | re.DOTALL
)
# 日志行解析
LOG_PATTERN = re.compile(
r'(\S+).*?"(\w+)\s+(\S+)\s+HTTP[^"]*"\s+(\d+)\s+(\d+)'
)
# ──────────────────────── 核心逻辑 ────────────────────────
def parse_log(filepath, true_size=None, false_size=None, auto_detect=True):
"""
解析日志文件,提取所有盲注请求并还原数据。
参数:
filepath: 日志文件路径
true_size: 响应True时的body长度(已知时传入)
false_size: 响应False时的body长度(已知时传入)
auto_detect: 是否自动检测True/False对应的响应长度
返回:
dict: {(field, db, table, row): {position: char}}
"""
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
print(f"[*] 日志行数: {len(lines)}")
# 第一遍:收集所有请求信息
requests = []
size_counter = defaultdict(int)
for line in lines:
m = LOG_PATTERN.search(line)
if not m:
continue
ip, method, url, status_code, body_size = m.groups()
if method != 'GET' or int(status_code) != 200:
continue
body_size = int(body_size)
decoded_url = urllib.parse.unquote(url)
# 尝试匹配payload
pm = PAYLOAD_PATTERN.search(decoded_url)
if not pm:
continue
field = pm.group(1) # 字段名
db = pm.group(2) # 数据库名
table = pm.group(3) # 表名
row = int(pm.group(4)) # 行偏移
pos = int(pm.group(5)) # 字符位置
threshold = int(pm.group(6)) # 比较阈值
requests.append((field, db, table, row, pos, threshold, body_size))
size_counter[body_size] += 1
print(f"[*] 有效盲注请求数: {len(requests)}")
print(f"[*] 响应长度分布: {dict(sorted(size_counter.items(), key=lambda x: -x[1]))}")
# ── 自动检测 True/False 对应的响应长度 ──
if true_size is None or false_size is None:
if auto_detect and len(size_counter) == 2:
sizes = sorted(size_counter.keys())
# 启发式:较小的size通常对应True(页面返回简化内容)
# 但这不总是成立,需要验证
print(f"[!] 响应只有两种长度: {sizes[0]} 和 {sizes[1]}")
print(f"[!] 需要确定哪个是True,哪个是False")
# 尝试两种方向,用逻辑一致性验证
for ts, fs in [(sizes[0], sizes[1]), (sizes[1], sizes[0])]:
if verify_consistency(requests, ts, fs):
true_size, false_size = ts, fs
print(f"[+] 验证通过: True={true_size}, False={false_size}")
break
else:
# 两种方向都无法完美验证,默认取较小值为True
true_size, false_size = sizes[0], sizes[1]
print(f"[!] 无法自动验证,默认 True={true_size}, False={false_size}")
print(f"[!] 如结果不正确,请用 --true-size 和 --false-size 手动指定")
else:
print("[!] 无法自动检测,请用 --true-size 和 --false-size 指定")
return {}
# ── 按字符位置分组,执行二分搜索还原 ──
char_tests = defaultdict(list)
for field, db, table, row, pos, threshold, body_size in requests:
is_true = (body_size == true_size)
char_tests[(field, db, table, row, pos)].append((threshold, is_true))
reconstructed = defaultdict(dict)
for key, tests in char_tests.items():
field, db, table, row, pos = key
ascii_val = resolve_binary_search(tests)
if ascii_val > 0:
reconstructed[(field, db, table, row)][pos] = chr(ascii_val)
return reconstructed
def verify_consistency(requests, true_size, false_size, sample_limit=50):
"""
验证True/False方向是否逻辑一致。
原理:二分搜索的True/False序列应当形成合理的区间——
所有True的阈值应当 < 所有False的阈值。
"""
consistent = 0
total = 0
# 按字符分组
groups = defaultdict(list)
for field, db, table, row, pos, threshold, body_size in requests[:sample_limit * 7]:
is_true = (body_size == true_size)
groups[(field, db, table, row, pos)].append((threshold, is_true))
for key, tests in groups.items():
if len(tests) < 3:
continue
max_true = max((t for t, v in tests if v), default=-1)
min_false = min((t for t, v in tests if not v), default=256)
total += 1
if max_true < min_false: # 合理区间
consistent += 1
if total == 0:
return False
ratio = consistent / total
return ratio > 0.8 # 80%以上一致则通过
def resolve_binary_search(tests):
"""
从一组 (threshold, is_true) 对中还原字符的ASCII值。
算法:
max_true = 所有True阈值中的最大值
min_false = 所有False阈值中的最小值
若 max_true + 1 == min_false → 值 = max_true + 1(精确命中)
若 max_true == -1(全部False)→ 值 = 0(超出字符串长度)
若 min_false == 256(全部True)→ 值 = max_true + 1
"""
max_true = max((t for t, v in tests if v), default=-1)
min_false = min((t for t, v in tests if not v), default=256)
if max_true + 1 == min_false:
return max_true + 1
elif max_true == -1:
return 0 # 字符串结束
elif min_false == 256:
return max_true + 1
else:
# 存在噪声/不一致,取最佳估计
return max_true + 1
# ──────────────────────── 输出格式化 ────────────────────────
def print_results(reconstructed, verbose=False):
"""打印还原结果"""
print("\n" + "=" * 65)
print(" SQL Boolean Blind Injection - Data Recovery Results")
print("=" * 65)
# 按表和行分组输出
tables = defaultdict(list)
for key in reconstructed:
field, db, table, row = key
tables[(db, table)].append((row, field, key))
for (db, table), entries in sorted(tables.items()):
print(f"\n Database: {db} | Table: {table}")
print(f" {'-' * 50}")
rows = defaultdict(dict)
for row, field, key in entries:
chars = reconstructed[key]
value = ''.join(chars[p] for p in sorted(chars.keys()))
rows[row][field] = value
for row in sorted(rows.keys()):
fields_str = ' | '.join(f"{f}={v}" for f, v in sorted(rows[row].items()))
print(f" Row {row}: {fields_str}")
print("\n" + "=" * 65)
def print_detailed_trace(reconstructed, char_tests_cache, target_field=None, target_table=None, target_row=0):
"""打印详细的二分搜索轨迹"""
print(f"\n--- Detailed Binary Search Trace ---")
for key in sorted(char_tests_cache.keys()):
field, db, table, row, pos = key
if target_field and field != target_field:
continue
if target_table and table != target_table:
continue
if row != target_row:
continue
tests = sorted(char_tests_cache[key], key=lambda x: x[0])
steps = ' '.join(f'>{t}={"T" if v else "F"}' for t, v in tests)
ascii_val = resolve_binary_search(tests)
ch = chr(ascii_val) if ascii_val > 0 else '(end)'
print(f" {field} Pos{pos}: {steps} => ASCII {ascii_val} = \"{ch}\"")
# ──────────────────────── 主入口 ────────────────────────
def main():
parser = argparse.ArgumentParser(
description='SQL Boolean Blind Injection Log Analyzer',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 自动检测模式
python sqli_blind_log_analyzer.py -f access.log
# 手动指定True/False响应长度
python sqli_blind_log_analyzer.py -f access.log --true-size 24 --false-size 47
# 仅还原特定表
python sqli_blind_log_analyzer.py -f access.log --table user --field username,password
"""
)
parser.add_argument('-f', '--file', required=True, help='日志文件路径')
parser.add_argument('--true-size', type=int, default=None, help='True条件对应的响应体长度')
parser.add_argument('--false-size', type=int, default=None, help='False条件对应的响应体长度')
parser.add_argument('--no-auto-detect', action='store_true', help='禁用自动检测')
parser.add_argument('--table', type=str, default=None, help='只还原指定表')
parser.add_argument('--field', type=str, default=None, help='只还原指定字段(逗号分隔)')
parser.add_argument('-v', '--verbose', action='store_true', help='显示详细二分搜索轨迹')
args = parser.parse_args()
reconstructed = parse_log(
args.file,
true_size=args.true_size,
false_size=args.false_size,
auto_detect=not args.no_auto_detect
)
# 过滤
if args.table or args.field:
fields_set = set(args.field.split(',')) if args.field else None
to_remove = []
for key in reconstructed:
field, db, table, row = key
if args.table and table != args.table:
to_remove.append(key)
if fields_set and field not in fields_set:
to_remove.append(key)
for key in to_remove:
del reconstructed[key]
print_results(reconstructed, verbose=args.verbose)
if __name__ == '__main__':
main()
使用方法
bash
# 基础用法:自动检测True/False并还原所有字段
python sqli_blind_log_analyzer.py -f access.log
# 手动指定响应长度(当自动检测不确定时)
python sqli_blind_log_analyzer.py -f access.log --true-size 24 --false-size 47
# 只还原user表的username和password字段
python sqli_blind_log_analyzer.py -f access.log --table user --field username,password
试试效果
#
第二问:
实际上这才是试题的第一问,可惜做出来答案不对,这里记录一下做题的过程,也有回来用ai溯源的新的代码:
其实只要第一个做出来了,这个就很简单了,这里就不放代码,免得有水文的嫌疑
不过很可惜,这问也没得分,因为md5计算不对,不过因为数据太多,感觉哪儿有点小问题导致的,就不折腾了。
第三问:
这题没做,不过可惜了,这题真应该做的,因为回来复盘,这个答案个位数,计算md5应该不会错/(ㄒoㄒ)/~~
卢恩算法:
这个也就名字唬人,实际上就是模10,数据安全最基数的算法之一(还有一个是计算身份证校验码的)
这是ai优化后的,感觉还没有我写的简单:
python
def luhn_check_optimized(card_number):
"""
优化版卢恩算法校验,使用反向遍历和更高效的计算方式
"""
# 清洗输入
cleaned = ''.join(filter(str.isdigit, str(card_number)))
if not cleaned or len(cleaned) < 12: # 银行卡号通常至少12位
return False
total = 0
# 反转字符串,从右向左处理
reversed_num = cleaned[::-1]
for i, digit in enumerate(reversed_num):
n = int(digit)
# 偶数位(反向后的第2、4、6...位,即原卡号从右数偶数位)
if i % 2 == 1:
n *= 2
# 如果大于9,各位数字相加
if n > 9:
n = n // 10 + n % 10
total += n
return total % 10 == 0
小结
这次比赛,感觉运气也是背到家,好几个感觉做的是正确的,但是最后计算md5提交答案,都错了,郁闷。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:一只岸上的鱼 一只岸上的鱼 一只岸上的鱼《数据泄露——江苏省第四届数据安全技术应用职业技能竞赛初赛》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论