1. 新生赛SQL注入到底在考什么
1.1 为什么SQL注入是新生赛的"必考题"
去年带过几个新生赛的队伍,发现一个很有意思的现象:不管比赛是校内的、省内的还是某云平台举办的线上赛,SQL注入几乎是雷打不动的一类题目。说它是电子取证之外最常出现的Web入门题都不过分。原因很简单,SQL注入技术本身不复杂,但考察点非常丰富,从最简单的字符串拼接、闭合语句,到报错注入、布尔盲注、时间盲注、堆叠注入,再到各种花式绕过,难度梯度可以拉得非常开。新生赛需要给刚接触CTF的同学一个"跳一跳够得着"的难度,SQL注入天然具备这个特点。
另一个原因是,SQL注入的真实危害足够大,理解它对于后续学Web安全、做渗透测试都有直接的迁移价值。你学会了在靶场里怎么通过id参数把数据库里的flag掏出来,再看真实环境里某个查询参数为什么可能导致拖库,思路是完全贯通的。所以新生赛用它当考题,不只是为了出一个flag,而是希望你通过这一道题,把"输入点-处理逻辑-数据流"这条安全分析链路建立起来。
这个实战系列文章,我会以一个标准的新生赛SQL注入题目为蓝本,从头到尾把判断注入点、确定闭合方式、查库查表查字段、最终拿到flag的完整流程写清楚。同时把我在带赛过程中遇到的典型问题、容易卡住的细节、以及怎么避免被过滤规则坑到,一并讲透。适合刚接触CTF、Web方向为零基础、想系统入门SQL注入的同学来读。
1.2 flag是什么,题目的"通关目标"长什么样
先解决一个新人最容易困惑的问题:flag到底是个什么东西。简单说,flag就是CTF竞赛里用来证明你"解出了这道题"的唯一凭证,通常是一串固定格式的字符串,比如flag{...}或者ctf{...}这种。这串字符一般会藏在数据库里、文件里、某个隐藏页面的响应头里,你的任务就是通过各种手段把它找出来,然后提交到比赛平台换取分数。
在SQL注入题里,flag最常见的位置有两个:一是数据库的某个表里,比如一张叫flag的表、或者名为secret的表,里面存着一行或几行数据;二是文件系统里,比如利用SQL注入的LOAD_FILE函数去读取 /flag 文件。新生赛绝大多数是第一种情况,也就是flag直接以数据行的形式存在数据库里。所以你的整个SQL注入操作,本质上就是在做一件事:在不知道数据库具体结构的情况下,通过注入点一点一点地把数据库里的表名、字段名、数据内容套出来,最终定位到存有flag的那一行。
这里必须强调一个底线,后面也会反复提到:所有SQL注入练习必须在CTF题目环境、授权的靶场环境或自己搭的实验环境中进行。利用注入技术去未经授权访问真实系统,在任何场景下都是违法行为。这篇文章讲的所有技巧,都是用来说明竞赛解题和防御思路的,不是拿来对真实目标使用的。
2. 先搞懂注入的底层原理
2.1 从一条正常查询到注入点的出现
SQL注入之所以产生,根子上只有一个原因:开发者把用户输入当成SQL代码的一部分来执行了。正常情况下,一个查询应该是这样的:
$id = $_GET['id']; $sql = "SELECT * FROM news WHERE id = " . $id;当你在URL里传入?id=1时,SQL语句变成SELECT * FROM news WHERE id = 1,这没问题。但如果传入?id=1 OR 1=1,语句就变成了:
SELECT * FROM news WHERE id = 1 OR 1=1因为OR后面的1=1恒为真,这条语句会把news表里的全部数据行都查出来返回给页面。这就是最原始、最朴素的注入。
很多新手会问:那为什么开发者会写出这么明显的漏洞代码?答案是在真实业务中,开发者经常为了省事,直接把前端参数拼进SQL字符串里,尤其是早期代码或者急赶出来的业务接口。而在CTF题目里,出题人就是故意保留了这个漏洞,让你去利用。理解这层逻辑很重要——注入的本质不是某个特殊函数,而是"输入被带入到了SQL语法层"。
判断一个点能否注入,核心也在于这里:你提交的内容能不能影响SQL语句的结构。如果能通过闭合、注释、拼接等手法改变查询逻辑,这个点就是注入点;如果无论怎么输入,数据都只是作为一个普通值被处理,那就没法注入。新生赛的第一道坎就是会判断这种"结构上的漏洞"。
2.2 参数类型判断:数字型、字符型、搜索型
拿到一个注入点之后,不要急着上工具,先把参数类型搞清楚。类型决定后面所有payload的写法。常见的有三种:
数字型,对应SQL里直接用一个数值,比如WHERE id = 1,那么你注入时可以直接id = 1 and 1=1这种,不需要额外的引号去闭合什么。判断方法是:在参数后面加一个单引号,比如?id=1',如果页面报错,说明数据库把我们输入的单引号当作SQL语句的一部分了,系统直接把SELECT * FROM news WHERE id = 1'拿去执行,语法错误暴露了你正在跟SQL解释器打交道。
字符型,对应WHERE name = 'admin'这种,参数被一对单引号包裹。你要做的事是先用自己的单引号把查询的左侧闭合掉,再通过注释符把后面的内容注释掉。最经典的payload就是?name=admin' and 1=1-- -,单引号闭合左边的前引号,-- -注释掉右边的'以及后面的内容。
搜索型,常见于搜索框,对应WHERE title LIKE '%搜索词%',你的输入会被%'包裹,需要先输入%去闭合左侧,典型payload是?key=%' and 1=1-- -。这种类型在新生赛里出现频率适中,出题人会用搜索功能做注入点,很多只会打字符型的新手一上来就蒙了。
这里分享一个我自己做题时的习惯:不要靠猜,直接在参数后面分别输入1、1'、1"、1')、1"))等一串测试值,看页面返回差异就能快速判断类型。尤其是加上单引号之后如果报错,几乎可以确定是字符型,再根据报错信息里的位置去判断有没有括号包裹。
3. 实战主线:手工Union注入拿flag的全流程
3.1 判断注入点与闭合方式
下面我们拿一道典型的新生赛模拟题走一遍全流程。题目场景是一个新闻列表页,URL格式是http://your-target/index.php?id=1,页面会显示对应id的新闻标题和内容。目标就是从库里把flag找出来。
第一步,先做基础验证。在浏览器里依次访问下面这些URL,观察页面差别:
?id=1 -- 正常显示新闻 ?id=1' -- 报错 ?id=1 and 1=1 -- 正常显示(如果闭合是数字型) ?id=1 and 1=2 -- 页面异常(可能空白或找不到数据)如果and 1=1正常、and 1=2异常,说明这是一个数字型注入点。因为 SQL 执行WHERE id = 1 and 1=1等价于WHERE id = 1,结果正常;WHERE id = 1 and 1=2恒为假,查不到数据。如果?id=1'报错,而?id=1' and '1'='1正常,那说明是字符型,需要用'去闭合。
为什么and 1=1有时候要写成and 1=1有时要前面加-- -注释?因为不同数据库对字符串的处理不一致。MySQL里--后面必须跟一个空格或控制字符才能形成注释,所以CTF题目里习惯写-- -(两个短横后一个空格再一个短横),或者用#注释。Oracle里注释是--后面必须跟空格,SQL Server则是--后跟空格。新生赛绝大多数是MySQL,记住-- -和#就够用了。
经验之谈:判断闭合方式的时候,把页面返回内容的差异记下来,尤其是报错信息里给出的SQL片段,直接看它前端引号位置就能知道是哪一种闭合。别嫌麻烦,这一步出错后面全废。
3.2 用order by确认字段数
确认了注入点和闭合方式之后,下一步要干的事是确认SELECT语句查询了多少列。为什么必须知道列数?因为后面的UNION查询要求前后两个查询返回的列数必须一致,否则数据库会报The used SELECT statements have a different number of columns这样的错误。
方式是用ORDER BY加数字不断试,直到报错为止。比如:
?id=1 ORDER BY 1 ?id=1 ORDER BY 2 ?id=1 ORDER BY 3 ...如果ORDER BY 10报错,说明SELECT语句的列数小于10,然后逐步缩小范围,最终确定一个N,让ORDER BY N正常而ORDER BY N+1报错,那么N就是列数。原理是ORDER BY后面跟的数字是列序号,超过实际列数就会报错。
新手最容易在这里卡住的一个点是:ORDER BY的位置必须放在你注入点所在语句的末尾,所以不需要闭合后面的引号,而是要把原来式子闭合掉,再用注释把后面的引号给吞了。例如字符型,标准payload是:
?id=1' ORDER BY 3-- -假如正常查询是SELECT * FROM news WHERE id = '1' ORDER BY 3-- -',那么-- -把最后的'注释掉了,查询正常执行。如果列数不是3,而是只有2,那么ORDER BY 3会报错,就可以判断列数是2。
这里还有一个很实用的细节:某些题目返回的数据是固定的,比如只显示第一行新闻的标题,那么ORDER BY 1和ORDER BY 2看着都一样,因为排序后的第一行还是它。但一旦ORDER BY N+1报错,就可以用二分法来确定边界。从1试到10,最多试10次,不用写脚本,手工也能很快。
3.3 联合查询定位回显位
知道了有3列,接下来用UNION SELECT构造查询。核心思路是:让原来的查询返回一个空结果,然后让UNION后面的select结果覆盖到页面上来。为什么要让原查询返回空?因为UNION默认会去重,还会把两个结果都列出来,但页面通常只显示第一行数据,所以我们希望真正显示的是我们自己的select结果。方法就是把id参数设成一个不存在的值,比如 -1。因为id一般为正数,传-1大概率查不到新闻。
于是payload变成:
?id=-1' UNION SELECT 1,2,3-- -页面如果正常显示,就会在你新闻标题、内容的位置出现数字1、2、3中的某一个或几个。出现在哪个位置,哪个位置就是"回显点"。假设页面把第2列显示在标题处,第3列显示在内容处,那么接下来我们只需要把第2、3列位置替换成我们要查询的内容就行了。
这里有个常见的坑:有的题目用了LIMIT或者固定显示第一条,你UNION SELECT的数据会被排到后面不显示。解法是构造原查询无结果(如id=-1),然后再用UNION,这样无结果和无结果合并,等于只有我们自己的数据。如果id=-1仍然有数据,说明表里存在id为负数的记录,你就换一个大一点的负数比如-999,或者用id=1 AND 0这种方式让原查询条件恒为假。
定位回显位之后,就要正式开始查库了。MySQL在information_schema库里存着所有数据库、表、字段的元数据信息,这几乎是我们打MySQL题目的"字典"。具体查询逻辑是这样的:
先查数据库名:
UNION SELECT 1, schema_name, 3 FROM information_schema.schemata这一步可能返回多个数据库名,页面如果只显示第一行,那就一条条看,或者用LIMIT 0,1一条条翻。新生赛题目一般就两三个库,翻几下就能看到类似ctf、web、test这种名字。假设查到数据库名是ctf_db。
接着查这个库的表名:
UNION SELECT 1, table_name, 3 FROM information_schema.tables WHERE table_schema='ctf_db'同样可能需要用LIMIT翻记录,直到看到flag或者secret这种令人兴奋的表名。注意表名是动态的,出题人有时会起一个特别迷惑的名字比如users、logs,但总之多查几遍,把所有表名都看一遍。
然后查目标表的字段名:
UNION SELECT 1, column_name, 3 FROM information_schema.columns WHERE table_schema='ctf_db' AND table_name='flag'假设查出来字段名就是flag,那么最后一步就是:
UNION SELECT 1, flag, 3 FROM ctf_db.flag页面标题或内容处就会返回flag字符串。
3.4 查询库名、表名、列名与flag数据
上面这个流程看似简单,但实际操作中每一步都有检查点。我建议你每执行一步payload,都对照页面变化排查一下:
- 查询
information_schema时报错,检查是不是列数写错了,union后面select的内容数量要和前面一致。 - 查询结果只显示一行,别着急,用
LIMIT翻页。举个例子,查询所有表名时:
UNION SELECT 1, table_name, 3 FROM information_schema.tables WHERE table_schema='ctf_db' LIMIT 0,1然后依次把LIMIT的偏移量改成1、2、3,直到找到目标表。页面只会显示一条记录时这个技巧是必备的。
- 有些题目会把
information_schema相关的关键字过滤掉,这种情况放到后面绕过节再讲。
另外要注意:如果回显位置是第2列,你也可以把1换成group_concat(table_name)这种聚合函数,一次性把多个表名拼在一起返回。对于MySQL来说,GROUP_CONCAT是非常好用的工具,它可以把查询结果的多行合并成一个字段,像这样:
UNION SELECT 1, group_concat(table_name), 3 FROM information_schema.tables WHERE table_schema='ctf_db'这样就不用频繁翻页了。但前提是回显位置的字段长度足够,如果数据库字段长度有限或者页面做了截断,那还是用LIMIT一条条翻比较稳妥。
4. 注入点被"过滤"了怎么办:绕过思路速查
4.1 常见过滤规则与对应绕过写法
新生赛的SQL注入如果只是让你union一把梭,就太单调了。出题人通常会加一些过滤条件,最常见的是拦截关键字和特殊符号,比如过滤select、union、from、information_schema、空格、=等。
先说最经典的空格绕过,题目如果过滤了空格,可以用注释符/**/替代,比如:
UNION/**/SELECT/**/1,2,3也可以使用1%09(水平制表符)、%0a(换行)等URL编码代替空格。在手工测试时,直接把空格替换成%0a往往超级好用,因为开发者在写过滤时通常只处理显式空格字符,对空白类的URL编码考虑不全。
如果过滤了select关键字,可以使用大小写混写SeLeCt,前提是过滤规则没有先统一转小写再比对;如果做了不区分大小写的过滤,就尝试双写关键字,比如seselectlect,因为很多过滤逻辑是"把select替换成空字符串",你输入seselectlect经过替换后反而变成了select,这就是经典的"二次绕过"。
内联注释绕过也很常见,MySQL在特定条件下支持/*!select*/这种注释内的代码执行。比如:
UNION /*!50000SELECT*/ 1,2,3有些题目过滤了information_schema,那就不走元数据库,改用sys.schema_auto_increment_columns、mysql.innodb_table_stats等表,或者用无列名注入的方式,直接通过子查询比较来逐位猜解。这个问题稍微深一点,新生赛一般用第一种方式就能过:把information_schema字符串拆成inf+ormation_schema这种拼接形式,有时候能绕过简单的黑名单。
新手容易踩的坑是:过滤规则用正则匹配,把or、and也都过滤了。这时候逻辑运算可以用xor、not,或者干脆在数字型注入中用||、&&这种运算符代替,只要数据库支持。我见过不少人在这一步卡很久,其实换一种逻辑表达就绕过去了。
4.2 盲注场景:布尔盲注与时间盲注实战对比
有时候题目不会把查询结果显示在页面上,你输入的and 1=1和and 1=2页面响应也没有明显差别,只能看到一个"查询成功"或者"查询失败"的状态码,这就是布尔盲注。更极端的情况下,连页面状态都不变,只能根据数据库执行时间判断条件真假,那就是时间盲注。
布尔盲注的思想是用substr、ascii、length等函数逐字符判断。比如判断当前数据库第一个字符的ASCII值是否大于100:
?id=1 AND ascii(substr(database(),1,1)) > 100如果页面正常,说明条件为真,继续缩小范围。也可以用if配合sleep做时间盲注:
?id=1 AND IF(ascii(substr(database(),1,1))>100, sleep(1), 0)如果页面响应延迟了1秒,说明条件为真。时间盲注最适合那些完全没有回显、任何报错都被吞掉的场景。
这两种盲注的最大痛点是效率。手工逐字符猜虽然能理解原理,但真要拿flag可能要猜几百个字符,很难受。我建议在理解了手工原理之后,直接使用自定义脚本或者工具来跑。很多新生赛选手只会手工验证一个字符,不会写自动化脚本,结果答题时间消耗在重复劳动上。你可以用Python的requests库写一个简单的布尔盲注脚本,逐个字符遍历,几百行代码量,二十分钟就能跑完。这个能力比单纯会打union注入更重要,因为CTF后期的题几乎全是盲注。
4.3 堆叠注入:一次执行多条语句的妙用
堆叠注入是另一种常见考点,指通过分号分隔,在一条SQL语句后面再接一条或多条语句。比如:
?id=1; DROP TABLE IF EXISTS test;-- -堆叠注入在PHP的mysqli_multi_query场景下才能生效,如果用的是mysqli_query这种单查询函数,即使你提交分号也没用。所以新生赛里堆叠注入只要出现,说明出题人明确开了多语句执行的接口。
堆叠注入的典型价值在于:它不受union查询列数的限制,可以直接执行SELECT之外的操作,比如改数据、删表、甚至调用函数。一台堆叠注入的题目常见套路是先用SHOW DATABASES、SHOW TABLES这种方法看结构,之后再SELECT拿flag。注意SHOW语句在union注入用不了,必须靠堆叠。
写payload时要注意分号和注释的配合。比如:
?id=1';SHOW TABLES;-- -如果用户的输入被拼到WHERE id = '1'里,那么你的分号前必须先用单引号闭合,然后执行SHOW TABLES;,最后用-- -注释掉原始SQL后面的部分。这条链路的原理和字符型union注入是一样的,先闭合再注释。
5. 工具辅助:Sqlmap的正确打开方式
5.1 什么时候该用工具,什么时候必须手注
我在带新生赛的时候,一直跟学员强调一句话:工具是加速器,不是替身。很多同学上来就一把sqlmap -u "http://xxx?id=1" --dbs,跑出库名表名,然后拿flag,整个过程看似很爽,但一旦题目加了waf、过滤或者特殊闭合,sqlmap默认的payload全部失效,这时候手工基础就决定了你能不能把这题做出来。
我的建议是分两步走:第一步必须手工完成注入点判断、闭合方式识别、字段数确认、以及至少一次完整的union查询拿数据演示。这个过程能帮你建立SQL注入的"肌肉记忆"。第二步才是使用sqlmap去验证和加快后续动作。
当然在一些数据量比较大的盲注题目里,完全靠手工一个一个字符去猜确实太折磨人了。这时sqlmap的效率优势非常明显:
sqlmap -u "http://your-target/index.php?id=1" --dbs sqlmap -u "http://your-target/index.php?id=1" -D ctf_db --tables sqlmap -u "http://your-target/index.php?id=1" -D ctf_db -T flag --dump跑完这几条,数据库结构就全出来了。如果发现sqlmap跑不动,检查是不是该加--random-agent或--level 2、--risk 2,有些开关是这几个参数控制测试深度和风险的。
5.2 常用命令与注意点
sqlmap的灵活之处在于它可以精确指定注入技术。比如题目是布尔盲注,但sqlmap默认先去测报错、时间盲注,可能会浪费很多时间。可以用--technique=B限定只用布尔盲注,--technique=T只测时间盲注。还有--flush-session和--fresh-queries强制更新缓存,保证使用的是最新的测试结果。
另一个高频问题:目标URL有多个参数时,sqlmap默认只测一个,需要指定-p "id"来确定测试哪个参数。token、cookie若是登录后才能访问的,则要带上--cookie="xxx",这些细节在新生赛的注入题里不常见,但在后期打有鉴权的环境时极重要。
但我要特别提醒一点:sqlmap的输出会很多,不要拿到一个库名就急着提数据,先要弄明白目标表里存的到底是不是flag。有的题目会把真实flag藏在一个名字非常不起眼的表里,比如user表里的某个字段。直接--dump全库也没有问题,但如果表数据量巨大,dump会非常慢,反而不如手工先精准定位。
总结来说,sqlmap是CTF和渗透测试的日常助手,但依赖它就会失去对SQL注入本身的理解。新生赛阶段,我强烈建议只在完全掌握手注流程之后,才把sqlmap当"高级计算器"来用。
6. 做完题之后的复盘:如何防注入 & 合规边界
6.1 从攻击视角反推防御:参数化查询与输入校验
拿到flag之后,这道题就结束了吗?没有。一场合格的CTF实战必须包含防御视角的复盘,不然你的技术永远停留在"会用payload"而不是"理解安全"。
SQL注入的核心原因是没有把用户输入和数据操作分离开。防御的第一对策是使用参数化查询(Prepared Statement)。无论PHP的PDO、Java的预编译SQL、Python的ORM,在使用参数绑定之后,用户输入只会被当成数据值,永远不会进入SQL语法层。代码层面大概是这样的:
$stmt = $pdo->prepare('SELECT * FROM news WHERE id = ?'); $stmt->execute([$id]);这样即使$id传入1 OR 1=1,也只会被当作一个字符串值去匹配,跟语法层隔离。这是最根本的修法。输入校验是第二层防御,包括类型限制(intval)、长度限制、白名单校验等;Web层面的WAF则属于第三层防护,负责拦截明显恶意的payload,但WAF不该是唯一防线,它总是可以被绕过的。
在复盘题目时,你可以尝试从出题人视角想一想:如果我要修掉这个漏洞,该怎么改?如果我是开发者,这个查询应该怎么写才是安全的?这种换位思考能让你对SQL注入的理解提升一个台阶,而不是只停留在"会打"。
6.2 学习边界:只在授权的靶场和CTF里动手
最后必须认真说一句:本文所有技术内容,只适用于CTF竞赛题目、自己搭建的靶场环境,或者获得明确授权的测试环境。任何对真实网站、未授权系统进行扫描注入的行为,都违反了法律和道德规范,后果非常严重。这是底线问题,没有任何借口。
CTF新生赛的魅力在于,它提供了一个合法、安全、有意思的练习场,让你可以在不影响他人的情况下研究攻防技术。拿flag的快感应该来自技术本身的突破,而不是非法攻击的越界。希望大家都能守住这个边界,做一个技术能力过硬、行为合规的安全爱好者。
6.3 排查速查表
按照惯例,我把实操过程中经常遇到的坑整理成一个速查表,方便你以后做题时快速对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
?id=1'报错,但不知道闭合方式 | 字符型或带括号闭合 | 尝试1'、1')、1'))、1",看哪条不报错 |
| ORDER BY 一直不报错 | 可能被order by过滤或者列数非常多 | 尝试大小写、/**/绕过,或直接用UNION SELECT 1,2,3...试探 |
| UNION SELECT返回不了数据 | 原查询可能返回了数据,或列数不匹配 | 确保id=-1无数据,确认列数一致 |
| information_schema被拦截 | 黑名单过滤了关键字 | 尝试inf/**/ormation_schema、大小写、编码、其他系统表 |
| 页面不显示任何数据 | 可能是盲注题目 | 分别测试布尔盲注和时间盲注 |
| sqlmap跑不出结果 | 闭合方式特殊或有关键字过滤 | 手工确认闭合,加--string、--technique |
这张表是我在实际带题过程中攒出来的,覆盖了新生赛SQL注入90%以上的卡点。当然,题目是死的,思路是活的。每次拿到一个新题,不要急着套模板,而是先观察、再验证、最后利用,这比背下所有payload重要得多。
最后的一些心得
做了这么多年Web题,带过好几届新生赛的队伍,我自己最大的体会是:SQL注入是那种"看起来很简单、实际上很深"的领域。一道新生赛的union注入,你可能十分钟就打通了,但真正把盲注、绕过、堆叠这些技巧都吃透,至少需要刷几十道题。别怕慢,别怕报错,报错信息往往是题目给你的最大提示。
另外想给还在起步阶段的同学一个建议:既然在练SQL注入,就顺手把SQL语言本身查漏补缺一下。很多人打到后面卡住,不是因为注入技巧不够,而是看不懂数据库报错信息里的SQL语法。把SELECT、JOIN、GROUP BY、information_schema这些基础概念搞扎实,你会发现所有注入技法都变得顺理成章。
最后再分享一个小技巧:在实际的CTF比赛中,如果时间紧张,不要在一道SQL注入题上死磕超过40分钟。先把有把握的题拿下,再回头慢慢磨。大多数SQL注入题的解法是相通的,你在一道题上卡住,往往是因为某一个小细节没注意,回头看一眼报错,可能马上就想通了。祝大家都能在新生赛里轻轻松松拿到属于自己的那行flag。