JSON注入攻击:现代Web应用中的SQL注入新形态与防御实践

发布时间:2026/8/16 7:45:23
JSON注入攻击:现代Web应用中的SQL注入新形态与防御实践 1. 从传统SQL注入到JSON注入的演变最近在复盘一些安全审计和CTF比赛的案例时我发现一个趋势越来越明显传统的、基于表单或URL参数的SQL注入漏洞正在减少但另一种形态的注入攻击却悄然兴起那就是JSON注入。很多开发者尤其是习惯了传统Web开发模式的朋友在转向前后端分离、API接口化的架构时安全意识没有同步跟上以为用了JSON传输数据就万事大吉殊不知攻击面只是换了个马甲。今天我就结合自己踩过的坑和实战中遇到的案例来深入聊聊“JSON注入”这个老问题的新变种。简单来说JSON注入是SQL注入的一种特殊形式。它的核心攻击原理与传统SQL注入并无二致都是利用应用程序对用户输入数据的不充分验证和不安全拼接将恶意构造的SQL指令“注入”到后端数据库查询语句中。区别在于攻击载荷Payload的载体从application/x-www-form-urlencoded或multipart/form-data格式的表单数据变成了application/json格式的HTTP请求体。很多后端框架或开发者编写的数据库操作层在解析JSON请求体后如果直接、未经严格处理地将其中的字段值拼接到SQL语句中就会重蹈传统SQL注入的覆辙。为什么这个问题值得单独拿出来说因为在现代Web开发中RESTful API和JSON几乎成了标配。前端Vue/React后端Spring Boot/Express/Django REST framework通信全靠JSON。开发者很容易产生一种错觉“我用了JSON框架自动反序列化成对象应该很安全吧”或者“我用了参数化查询Prepared Statement应该没问题吧”但实际情况往往更复杂。参数化查询能有效防止大多数注入前提是你真的把所有用户输入都通过参数传递。如果开发者在某些场景下比如动态表名、动态排序字段图省事采用了字符串拼接的方式那么即使请求体是JSON格式危险依然存在。接下来我们就拆解几个典型的场景和攻击手法。2. JSON注入的常见攻击场景与原理剖析要理解JSON注入我们必须先抛开“JSON”这个外壳直击本质任何将用户可控数据以不安全的方式拼接到SQL语句中的行为都是注入漏洞。JSON只是数据在传输过程中的一种序列化格式。当后端代码如PHP的json_decode Python的json.loads Java的RequestBody Jackson将JSON字符串解析成内存对象字典、数组、POJO后后续的数据处理逻辑才是安全的关键。2.1 场景一直接拼接解析后的JSON值这是最原始、也最危险的场景。假设一个用户登录的API接口接收JSON格式的请求{ username: admin, password: 123456 }后端以PHP为例的脆弱代码可能长这样?php $input json_decode(file_get_contents(php://input), true); $username $input[username]; $password $input[password]; // 致命错误直接拼接用户输入 $sql SELECT * FROM users WHERE username $username AND password $password; $result mysqli_query($conn, $sql); ?攻击者可以发送这样一个畸形的JSON请求体{ username: admin -- , password: anything }经过json_decode后$username的值变成了字符串admin --。拼接进SQL语句后就变成了SELECT * FROM users WHERE username admin -- AND password anything--在大多数数据库中是单行注释符这意味着后面的AND password...条件被注释掉了。攻击者就能在不知道密码的情况下以admin身份登录。这与传统注入一模一样只是Payload放在了JSON里。注意这里的关键不是JSON本身而是后端代码在拿到$username这个字符串后未经任何过滤就直接将其包围在单引号内拼接到SQL字符串中。即使前端通过JSON发送后端如果用参数化查询如mysqli_prepare和bind_param这个攻击也会失效。所以漏洞的根源在于不安全的字符串拼接而非JSON格式。2.2 场景二JSON数组元素被拼接用于IN子句这是一种进阶场景更容易被忽略。很多应用会有根据ID列表查询详情的功能前端传一个ID数组{ ids: [1, 2, 3, 5] }后端代码可能为了方便将数组join成字符串后直接放入SQL的IN子句# Python Flask 脆弱示例 import json from flask import request, jsonify app.route(/api/users/batch, methods[POST]) def get_users_batch(): data request.get_json() id_list data.get(ids, []) # 危险操作直接将列表转换为字符串未对每个元素进行验证或转义 id_str ,.join(str(i) for i in id_list) sql fSELECT * FROM users WHERE id IN ({id_str}) # ...执行查询看起来似乎没问题因为ID通常是数字。但如果攻击者传入的JSON是{ids: [1, 2); DROP TABLE users; --]}呢经过join后id_str变成了1,2); DROP TABLE users; --。拼接出的SQL语句将是SELECT * FROM users WHERE id IN (1,2); DROP TABLE users; --这将导致灾难性的后果。这里的问题在于开发者假设了ids数组里的元素都是“安全”的数字但JSON解析器会将JSON数字如2转换为编程语言的数字类型如int而将JSON字符串如2转换为字符串类型。如果后端没有对每个元素的类型进行严格校验例如确保所有元素都是int类型攻击者就可以注入字符串形式的Payload。2.3 场景三利用JSON嵌套结构绕过初步过滤有些应用会在网关、WAFWeb应用防火墙或控制器层对输入进行一些简单的过滤比如检查请求参数中是否包含单引号、分号;、OR、AND等关键字。当数据以x-www-form-urlencoded格式传输时这种过滤可能有效。但JSON格式为绕过这些过滤提供了新的可能性。Unicode编码绕过JSON规范完全支持Unicode转义序列。攻击者可以将Payload中的关键字符进行Unicode编码。原始Payloadadmin OR 11JSON编码后{username: admin\u0027 OR \u00271\u0027\u00271}\u0027是单引号的Unicode码点。一些简单的字符串匹配过滤可能无法识别这种形式但后端json_decode函数会将其正确解码为单引号字符从而成功注入。JSON数字类型注入如果后端代码在拼接时对字符串类型的字段加了引号但对数字类型的字段没加那么攻击数字字段可能更简单。假设查询用户信息的接口{id: 1}脆弱SQLSELECT * FROM users WHERE id $id注意这里用的是变量替换而非参数化且未加引号攻击Payload{id: 1 OR 11}如果后端弱类型语言如PHP或未做类型强校验字符串1 OR 11可能会被转换成数字1注入失败。但更精妙的攻击是{id: 1 AND (SELECT COUNT(*) FROM admin) 0}。如果这个JSON被解析后id字段的值是字符串且拼接时没加引号注入就可能成功。这高度依赖于后端语言和代码的具体实现。2.4 场景四库表名、排序字段等动态拼接这是参数化查询的“盲区”。参数化查询只能对值Value进行参数化而不能对标识符Identifier如库名、表名、列名或SQL关键字如ORDER BY后面的字段名进行参数化。例如一个动态排序的API{ sort_by: create_time, order: DESC }后端代码可能会这样写以Node.js为例const { sort_by, order } req.body; // 危险直接将用户输入的列名和排序方向拼接到SQL中 const sql SELECT * FROM products ORDER BY ${sort_by} ${order};攻击者可以传入{sort_by: id; SELECT SLEEP(10) -- , order: ASC}从而执行任意SQL语句。防御这种情况不能依赖参数化查询而必须采用白名单机制。即预先定义好允许排序的字段列表如[id, create_time, price]在拼接前检查用户输入的sort_by是否在该白名单内。3. 实战演练手动挖掘与利用JSON注入漏洞理解了原理我们通过一个模拟的实战场景来巩固。假设我们面对一个黑盒系统只知道它提供了JSON格式的API。我们的目标是探测并利用可能存在的JSON注入点。3.1 信息收集与接口探测首先使用浏览器开发者工具F12的“网络”Network选项卡观察前端与后端的交互。寻找那些发送POST、PUT或PATCH请求且Content-Type为application/json的接口。常见的注入点可能出现在登录/认证接口(/api/login,/auth/token)数据查询接口(/api/user/search,/api/products/filter)数据提交接口(/api/comment/new,/api/order/create)记录下接口URL、请求方法以及大致的JSON结构。例如发现一个搜索用户接口POST /api/user/search请求体类似{keyword: alice, page: 1}。3.2 使用工具进行模糊测试手动构造Payload效率低我们可以借助工具。这里推荐两个组合Burp Suite Turbo Intruder用Burp Suite拦截到目标JSON请求。发送到Intruder模块选择攻击类型为“Sniper”或“Pitchfork”。在JSON数据中需要测试的位置如keyword的值设置Payload标记。Payload Sets选择从文件加载文件内容包含各种SQL注入测试Payload如,, OR 11, AND SLEEP(5)--等。注意因为是在JSON字符串中Payload本身需要是合法的JSON字符串值所以里面的双引号需要转义或者整体用单引号。Burp Suite通常能自动处理。通过观察响应时间、响应状态码、响应内容长度和内容的变化来判断是否存在注入。例如注入SLEEP(5)导致响应时间显著增加就是时间盲注的迹象。sqlmap这款神器同样支持JSON注入。你需要将请求保存到一个文件比如req.txt中。POST /api/user/search HTTP/1.1 Host: target.com Content-Type: application/json Content-Length: 27 {keyword:test,page:1}然后使用sqlmap命令sqlmap -r req.txt --level3 --risk2 --batch-r从文件加载HTTP请求。--level和--risk提高检测级别和风险等级以测试更多参数和更危险的Payload。--batch非交互模式自动选择默认选项。 sqlmap会自动识别JSON参数并进行测试非常强大。3.3 手动构造Payload与漏洞确认如果工具测试有迹象或者你想更深入地手动验证可以遵循以下步骤语法错误探测注入一个单引号或双引号看SQL语句中用的是哪种观察是否返回数据库错误信息如MySQL error, PostgreSQL error。这能快速确认是否存在注入点以及数据库类型。Payload:{keyword: }或{keyword: \}(JSON中双引号需要转义)预期如果后端错误处理不当可能会返回包含SQL syntax error near 之类的信息。布尔逻辑测试如果页面返回内容会因查询结果真假而变化如搜索到结果 vs 未搜索到可以使用布尔盲注逻辑。Payload 1 (恒真):{keyword: test OR 11}Payload 2 (恒假):{keyword: test AND 12}观察Payload1可能返回所有结果或正常页面Payload2可能返回空结果或错误页面。如果两者响应有明显差异则存在布尔盲注。时间盲注测试当页面返回内容不随查询结果变化时使用时间延迟函数。MySQL Payload:{keyword: test AND SLEEP(5)-- }PostgreSQL Payload:{keyword: test AND pg_sleep(5)-- }观察如果响应时间大约为5秒则存在时间盲注。联合查询注入如果页面会直接显示查询结果的一部分可以尝试联合查询。首先确定字段数{keyword: test ORDER BY 5-- }递增数字直到报错。假设字段数为3尝试联合查询{keyword: test UNION SELECT 1,2,3-- }看页面是否回显了2或3的位置。然后就可以在回显位置查询数据库信息了{keyword: test UNION SELECT 1, database(), user()-- }实操心得在实际测试中JSON注入点往往隐藏在复杂的嵌套对象或数组中。不要只测试第一层字段。例如遇到{filter: {name: xxx, tags: [a, b]}}这种结构filter.name、filter.tags[0]都可能存在注入点。用Burp Suite的Intruder时可以设置多个Payload位置进行并发测试。4. 防御之道从开发框架到代码习惯知道了怎么攻击更重要的是知道如何防御。防御JSON注入需要建立从架构到代码细节的多层防线。4.1 第一原则坚持使用参数化查询预编译语句这是防止SQL注入最有效、最根本的方法。无论输入来自哪里JSON、表单、URL、Cookie在构造SQL语句时永远不要将用户输入直接拼接到SQL字符串中。使用占位符?或命名参数然后将输入值作为参数传递给数据库驱动。Java (JDBC):String sql SELECT * FROM users WHERE username ? AND password ?; PreparedStatement stmt connection.prepareStatement(sql); stmt.setString(1, username); // 来自JSON解析的username stmt.setString(2, password); // 来自JSON解析的password ResultSet rs stmt.executeQuery();Python (sqlite3/MySQLdb):cursor.execute(SELECT * FROM users WHERE username %s AND password %s, (username, password))PHP (PDO):$stmt $pdo-prepare(SELECT * FROM users WHERE username :username AND password :password); $stmt-execute([username $username, password $password]);Node.js (mysql2):connection.execute(SELECT * FROM users WHERE username ? AND password ?, [username, password]);关键点确保你的ORM如Hibernate, Sequelize, Eloquent或查询构建器如MyBatis, Knex.js底层也是使用参数化查询。大部分现代ORM的.where({username: inputUsername})这类用法是安全的但如果你使用了它们提供的“原始查询”Raw Query功能危险就又回来了。4.2 第二原则对无法参数化的部分进行严格的白名单校验对于必须动态拼接的SQL部分如排序字段ORDER BY、表名、列名参数化查询无能为力。此时必须采用白名单机制。# 正确的做法白名单校验 ALLOWED_SORT_FIELDS {id, create_time, price, name} ALLOWED_ORDERS {ASC, DESC} sort_by data.get(sort_by, id) order data.get(order, ASC).upper() if sort_by not in ALLOWED_SORT_FIELDS: sort_by id # 或返回错误 if order not in ALLOWED_ORDERS: order ASC sql fSELECT * FROM products ORDER BY {sort_by} {order} # 注意这里的 sort_by 和 order 来自受控的白名单不是用户直接输入因此拼接是安全的。4.3 第三原则在数据反序列化层进行输入验证和类型强制在将JSON解析为对象后立即对数据进行验证。这不仅是安全需要也是保证业务逻辑正确性的需要。使用强类型和验证框架Java: 结合Spring Boot的RequestBody和JSR-303验证注解如NotNull,Size,Pattern。public class LoginRequest { NotBlank Size(min3, max20) Pattern(regexp ^[a-zA-Z0-9_]$) // 只允许字母数字下划线 private String username; NotBlank Size(min6) private String password; // getters and setters }Python: 使用Pydantic库。它能自动进行类型转换和验证不符合定义的请求会被直接拒绝。from pydantic import BaseModel, constr class UserSearch(BaseModel): keyword: constr(strictTrue, max_length50) # 严格字符串最大50字符 page: int Field(ge1, le100) # 整数1到100之间Node.js: 使用Joi或class-validator。const Joi require(joi); const schema Joi.object({ username: Joi.string().alphanum().min(3).max(30).required(), ids: Joi.array().items(Joi.number().integer().positive()).max(100) });进行类型强制转换对于预期是数字的字段确保解析后是数字类型而不是数字字符串。在弱类型语言中尤其重要。$id (int) $input[id]; // 强制转换为整数非数字会变成0 // 更好的做法是校验 if (!is_numeric($input[id]) || $input[id] 0) { throw new InvalidArgumentException(Invalid ID); } $id (int) $input[id];4.4 第四原则最小权限原则与纵深防御数据库账户权限应用程序连接数据库的账户不应具有DROP,CREATE,ALTER等高级权限。通常只赋予SELECT,INSERT,UPDATE,DELETE等必要权限。这样即使发生注入攻击者能造成的破坏也有限。Web应用防火墙WAF部署WAF可以在网络层拦截一些常见的攻击模式。但WAF不是银弹复杂的编码绕过或0day攻击可能无法被识别。它应作为纵深防御的一环而非唯一防线。错误处理配置应用程序不要将详细的数据库错误信息如堆栈跟踪、SQL语句返回给客户端。应返回通用的错误提示并将详细日志记录在服务器端。这可以增加攻击者利用盲注的难度。定期安全审计与代码扫描将SQL注入检查纳入代码审查Code Review流程并使用SAST静态应用安全测试工具对代码库进行扫描寻找不安全的代码模式。5. 框架与ORM的安全实践与常见误区很多开发团队依赖框架或ORM来保证安全但错误的使用方式会引入风险。5.1 MyBatis / MyBatis-Plus 中的$与#陷阱这是Java生态中一个经典的高危误区。在MyBatis的XML映射文件中#{}是安全的它会被解析为JDBC的预编译语句占位符?。${}是危险的它会被直接替换为字符串相当于拼接。错误示例导致注入select idselectUser parameterTypeString resultTypeUser SELECT * FROM users ORDER BY ${sortField} /select如果sortField来自用户输入的JSON攻击者传入sortField: id; SELECT SLEEP(5)就会导致注入。正确做法对于排序字段必须在Java代码层做白名单校验而不是在XML中直接使用${}。或者使用MyBatis-Plus提供的条件构造器它内部会对排序字段进行安全处理。5.2 Sequelize / TypeORM 中的 Raw Query 风险Node.js的ORM如Sequelize提供了sequelize.query方法执行原始SQL。如果其中包含用户输入必须非常小心。危险示例const { order } req.body; // 绝对不要这样写 const sql SELECT * FROM products ORDER BY create_time ${order}; const results await sequelize.query(sql, { type: QueryTypes.SELECT });安全做法使用ORM提供的安全方法进行排序。// 使用Sequelize的order参数它是安全的 const products await Product.findAll({ order: [[create_time, order DESC ? DESC : ASC]] }); // 或者如果必须用raw query对order进行白名单校验 const allowedOrders [ASC, DESC]; const safeOrder allowedOrders.includes(order.toUpperCase()) ? order : ASC; // 但即使这样在raw query中拼接标识符也不安全应尽量避免。5.3 ThinkPHP5/6 查询构造器的“表达式”漏洞ThinkPHP的查询构造器非常方便但它的“表达式查询”如果使用不当会导致注入。危险示例$map json_decode({id: 1 OR 11}, true); $user Db::name(user)-where($map)-find(); // 生成的SQL: SELECT * FROM user WHERE id 1 OR 11这是因为where方法传入数组时默认使用等值查询。但TP的查询构造器也支持更复杂的表达式如果用户能控制查询逻辑就可能产生危险。更安全的做法是使用参数绑定即使接收JSON数据$id input(post.id); // 假设从JSON中获取 $user Db::name(user)-where(id, , $id)-find(); // 或者使用参数绑定 $user Db::name(user)-where(id :id)-bind([id [$id, \PDO::PARAM_INT]])-find();核心建议无论使用什么框架或ORM请仔细阅读其安全文档理解其查询构建的安全边界。默认情况下使用框架提供的、最高抽象级别的查询方法如Model.findByPk(id)通常是最安全的。当需要更灵活的操作时务必检查用户输入是否用于SQL字符串拼接。6. 自动化检测与渗透测试中的技巧在安全审计或渗透测试中如何高效地检测JSON接口的SQL注入接口识别与资产梳理使用Burp Suite的Content Discovery或OWASP ZAP的爬虫功能结合自定义的字典寻找API端点。特别关注/api/,/graphql,/rest/等路径下的POST,PUT,PATCH请求。Payload的构造与编码由于JSON字符串中双引号需要转义在Burp Suite的Intruder中可以使用§ OR 11§这样的PayloadBurp会自动处理。对于更复杂的绕过可以尝试将Payload放在JSON数字、布尔值甚至null中测试后端类型处理的边界情况。例如{id: 1 AND SLEEP(5)}和{id: 1 AND SLEEP(5)}注意后者在JSON语法上是非法的数字但某些蹩脚的解析器可能处理不当。利用JSON特性尝试注入Unicode转义字符、多余的空格、换行符(\n)、制表符(\t)看是否能绕过一些简单的过滤。关注非常规注入点GraphQLGraphQL查询本身是一个字符串但通常以JSON格式发送如{query: query { user(id: \1\) { name } }}。如果后端直接将id参数的值拼接到GraphQL查询字符串中然后再由GraphQL引擎生成SQL就可能存在二次注入。测试时需要将SQL注入Payload放在GraphQL的查询变量或参数值里。JSON数组如前所述数组的每个元素都可能是一个注入点。嵌套对象{filter: {or: [{name: a}, {name: b OR 11}]}}测试嵌套结构中的每一个叶子节点的值。时间盲注的自动化使用sqlmap的--time-sec参数设置延迟时间结合--techniqueT指定时间盲注技术。对于JSON接口务必使用-r参数加载原始请求文件并确保Content-Type正确。如果接口有Token或CSRF保护可能需要使用--csrf-token和--csrf-url参数。结果判断自动化工具的成功率依赖于响应差异的识别。对于布尔盲注和时间盲注需要设置合理的阈值。在Burp Suite的Intruder中可以添加Grep - Match规则来提取页面中的特征字符串如“未找到结果”、“登录成功”或直接比较响应长度、状态码和时间。最后记住一点安全是一个持续的过程。JSON注入只是SQL注入在新时代的“皮肤”其内核从未改变。作为开发者在享受JSON带来的便捷时务必在数据流经的每一个环节反序列化、验证、业务处理、持久化保持警惕将“不信任任何用户输入”这一原则刻在脑子里。而作为安全测试人员则需要不断更新自己的测试用例库理解目标应用的技术栈才能更有效地发现这些“穿着新衣”的老漏洞。

相关新闻