1. 靶场环境与核心思路解析
拿到一个名为“[suctf 2019]easysql”的靶场,从名字就能看出,这大概率是一道SQL注入的题目,而且“easy”往往意味着它考察的是最基础、最核心的绕过技巧,而非复杂的盲注或二次注入。这类题目在CTF(Capture The Flag)中非常经典,是检验选手对SQL注入本质理解是否扎实的试金石。我个人的经验是,越是名字里带“easy”的题,越要警惕出题人设置的“小陷阱”,它可能不是技术上的复杂,而是思维上的一个巧妙限制。
这道题的核心场景是一个典型的Web应用登录或查询接口,我们需要通过构造特殊的输入,让后端数据库执行我们预期的SQL语句,从而绕过身份验证、获取敏感数据(即“flag”)。整个解题过程,实际上是一场与后端代码逻辑的“对话”。我们需要从有限的用户输入点(比如一个搜索框、一个登录框)出发,去猜测后端拼接SQL语句的方式,然后尝试用各种Payload去“试探”和“突破”它的防御逻辑。
解题的第一步永远是信息收集。我们需要知道这个输入点对应什么类型的SQL语句。是SELECT * FROM table WHERE id='$input'?还是SELECT * FROM table WHERE username='$user' AND password='$pass'?不同的语句结构,决定了我们注入Payload的构造方式。对于这道题,从网络上的讨论和“easysql”这个提示来看,它很可能是一个单输入点的查询,比如一个搜索功能,后端直接拼接用户输入进行查询。我们的目标就是从这个看似无害的输入框,拿到数据库里的flag。
2. 初探与基础注入尝试
面对一个未知的注入点,标准流程是从最基础的探测开始。我会首先尝试输入一些常规的测试字符,观察页面的回显变化。
- 单引号测试:输入一个单引号
‘。这是最经典的测试。如果页面返回了SQL语法错误(比如“You have an error in your SQL syntax”),那几乎可以100%确认存在SQL注入漏洞,并且很可能没有对单引号进行转义。如果页面正常显示但内容为空,或者显示“无结果”,那可能是数字型注入或者过滤了单引号。如果直接报500内部服务器错误,也可能是触发了异常,需要进一步分析。 - 布尔测试:输入
1‘ and ‘1’=‘1和1‘ and ‘1’=‘2。如果第一个输入返回正常结果(比如搜索到了ID为1的内容),而第二个输入返回无结果或错误,那么这就是一个典型的基于布尔的注入点。我们可以通过构造真/假条件来逐位推断数据。 - 联合查询探测:输入
1‘ order by 1--,然后递增数字,如order by 2,order by 3... 直到页面报错。这可以帮我们判断当前查询语句最终返回的列数。这是使用UNION SELECT进行注入的前提。
在“[suctf 2019]easysql”这道题的实际操作中,很多选手反馈,进行上述基础测试时,页面可能会返回一个非预期的结果,或者直接过滤了某些关键词。这提示我们,题目可能设置了简单的过滤机制。例如,它可能过滤了or,and,select,union,from,where等常见SQL关键词,或者过滤了空格、注释符--和#。
注意:在实战和CTF中,遇到过滤是常态。我们的思路不应该是“用不了A方法就放弃”,而是“A被过滤了,有没有功能等效的B方法?或者有没有办法绕过对A的过滤?”。
当基础关键词被过滤时,我们需要思考替代方案:
and被过滤?可以尝试&&(在某些数据库如MySQL中,&&是逻辑与)。or被过滤?可以尝试||(逻辑或)。空格被过滤?可以尝试用括号()、注释/**/、换行符%0a、制表符%09来替代。select被过滤?这可能比较麻烦,但有时可以用大小写混合SeLeCt、双写selselectect(如果过滤是简单的字符串替换)、或者用编码(如URL编码、十六进制)来绕过。
对于这道题,一个关键的突破口在于理解它的过滤逻辑可能不是“黑名单式”的简单替换,而是对输入流进行了更严格的检查,或者设置了一个非常“白名单”的预期输入。
3. 关键绕过技巧与堆叠注入的应用
在多次尝试基础Payload无果后,我们需要转换思路。这道题名为“easysql”,暗示解法可能很简洁。一个在CTF中常见的考点是“堆叠查询”(Stacked Queries)。
堆叠查询的原理是,利用SQL语句的分隔符(通常是分号;),在一次数据库连接中执行多条SQL语句。例如,如果后端代码是直接使用mysqli_multi_query()这类函数执行用户输入,那么输入1; SELECT DATABASE();就有可能先执行原查询,再执行我们注入的查询。
为什么堆叠注入在这里可能是关键?因为题目可能只对第一条(或预期的那条)查询语句的输入进行了严格的过滤和检查,但当我们用分号;开启一个新的查询时,后续的语句可能就跳出了那个过滤逻辑的上下文。这就好比安检只检查你手里的第一个包,而你用绳子在后面又偷偷拖了一个包。
那么,如何利用堆叠注入呢?假设我们探测出输入点对应的查询是:
SELECT * FROM articles WHERE title='$input'如果我们输入:
'; SHOW TABLES; --拼接后的SQL变为:
SELECT * FROM articles WHERE title=''; SHOW TABLES; -- '这条语句会先执行一个空的查询(可能无结果),然后执行SHOW TABLES列出所有表名,最后--注释掉后面的单引号。如果页面回显了表名信息,我们就成功了。
在MySQL中,SHOW TABLES是查看当前数据库所有表。但我们的终极目标是拿到flag。flag通常存储在一张特定的表里,表名可能叫flag,f1ag,secret等。我们需要先知道表名,然后查询其中的内容。
一个更直接、在CTF中常用的Payload是:
'; SET @sql=CONCAT('S','ELECT * FROM `flag`'); PREPARE stmt FROM @sql; EXECUTE stmt; --这个Payload利用了预处理语句(PREPARE/EXECUTE)来动态执行SQL。它先将字符串'SELECT * FROM flag'拼接并赋值给变量@sql(这里把SELECT拆开写是为了绕过可能的select关键词过滤),然后准备并执行这个语句。但这依然需要我们知道表名。
如果不知道表名怎么办?我们可以用information_schema数据库。这是MySQL的系统数据库,存储了所有数据库、表、列的信息。一个经典的查询所有表名的语句是:
SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE()所以,一个完整的利用链可能是:
- 输入
'; SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE(); --,获取当前数据库的所有表名。 - 从回显中找到疑似存储flag的表名(比如
flag)。 - 输入
'; SELECT * FROM flag; --,直接查询flag内容。
然而,在“[suctf 2019]easysql”这道题中,经典的解法往往更加“简单粗暴”,它巧妙地利用了后端代码的逻辑缺陷。
4. 非预期解与逻辑漏洞挖掘
经过社区众多选手的实践,这道题有一个非常著名的“非预期解”或说“捷径”。这个解法不需要堆叠注入,甚至不需要知道表名和列名。它源于对后端代码逻辑的深度猜测。
我们假设后端代码是这样的(这是一种非常不安全但可能存在于“easy”题目中的写法):
$input = $_GET['id']; $sql = "SELECT * FROM table WHERE id='" . $input . "'"; $result = mysqli_query($conn, $sql); if ($row = mysqli_fetch_assoc($result)) { echo $row['data']; } else { echo "Not Found."; }看起来平平无奇。但如果我们输入的不是一个id值,而是一个能改变整个查询逻辑的Payload呢?
考虑输入:' or 1=1 --拼接后:SELECT * FROM table WHERE id='' or 1=1 -- '这条语句会查询所有id为空或者1=1(恒真)的记录。由于1=1永远为真,所以它会返回表中的第一条记录。
如果flag就存储在数据表的某一行里,并且我们不知道它的id,那么用or 1=1返回所有记录,再从中寻找flag,是一个思路。但题目可能只回显第一条数据。
更进一步的技巧来了:利用SQL_MODE中的ONLY_FULL_GROUP_BY?不,这里的关键可能是GROUP BY或ORDER BY的副作用。
但在这道题最经典的解法中,Payload简单到令人惊讶:1或者0。
这怎么可能?这需要我们做一个大胆的假设:后端代码可能不是简单的SELECT * FROM table WHERE id='$input',而是将用户输入直接放在了SELECT语句的字段部分!
例如,后端代码可能是这样的:
$input = $_POST['query']; $sql = "SELECT " . $input . " FROM flag"; // 或者 $sql = "SELECT " . $input . " FROM some_table";如果真是这样,那么用户输入*,查询就是SELECT * FROM flag,直接查出所有内容。用户输入1,查询就是SELECT 1 FROM flag,会返回一个所有行都是1的结果集。如果页面直接回显查询结果,那么输入*就能直接拿到flag。
然而,题目叫“easysql”,出题人不会这么轻易放过我们。很可能*被过滤了。那么,我们输入什么呢?
一个巧妙的Payload是:1;show tables;吗?不,在SELECT字段位置,这不合语法。
但如果过滤了*,我们可以尝试输入1,2,3,看看是否回显多个字段。或者,我们可以尝试输入database()来查看数据库名,输入version()查看版本。
这道题最精妙的绕过在于:利用数字1作为查询字段,并结合后端代码对查询结果的处理逻辑。
假设后端逻辑是:
- 执行
SELECT $input FROM flag。 - 获取结果集的第一行第一列。
- 直接将其输出到页面上。
如果我们输入1,查询变成SELECT 1 FROM flag。假设flag表里只有一行数据,那么这个查询会返回一行,该行的值就是数字1。页面就会显示1。
但这没有用。我们需要让查询返回flag本身。怎么办?
这时,我们可以利用字符串拼接函数。在MySQL中,CONCAT()函数可以将多个字符串连接起来。如果我们能构造一个查询,让它返回CONCAT(flag的列名)的结果呢?
但问题又回到了原点:我们不知道列名。在不知道列名的情况下,如何查询一列的数据?
这里有一个MySQL的“特性”或说技巧:如果使用GROUP_CONCAT()函数配合SELECT *,并在不知道列名的情况下,可以通过列的位置来引用数据吗?更直接的一个方法是:使用SELECT语句直接查询一个不存在的列名,但将其别名(alias)设置为一个我们想要的字符串。
实际上,最简洁的Payload被证实是:1或者2等数字,但需要结合页面回显的差异来判断。更进一步的探索发现,输入\(反斜杠)可能会引发报错,报错信息中有时会泄露数据库结构,这是一种“报错注入”的思路。
但综合这道题的各种Writeup,最被广泛接受的“正解”Payload是:
1或者
2然后通过观察回显的不同,结合SQL注入中的布尔逻辑,推断出flag。具体来说,可能是这样的逻辑:
- 输入
1,页面返回Hello, 1(或其他内容A)。 - 输入
2,页面返回Hello, 2(或其他内容B)。 - 输入
1 and (select ascii(substr((select flag from flag),1,1))>100),如果页面返回内容A,说明条件为真(第一个字符的ASCII码大于100);如果返回内容B或无内容,说明为假。通过这种二分法,可以逐位猜解出flag。
然而,题目名是“easysql”,可能连布尔盲注都不需要。一个更简单的Payload被最终确认:直接输入*。
是的,绕了一圈,最简单的就是答案。但这建立在*没有被过滤的假设上。如果*被过滤了怎么办?我们可以尝试它的URL编码%2a,或者十六进制表示0x2a。
在实际解题中,选手们发现,输入*后,页面直接输出了flag。这是因为后端查询很可能就是SELECT $_GET[‘query’] FROM flag。当我们传入*时,它直接查询了flag表的所有列(假设只有一列,就是flag本身),并将结果输出。
实操心得:这道题给我最大的启示是,面对SQL注入,不要被复杂的绕过技巧局限住。首先要穷举最简单的可能性:直接输入
‘、”、\、*、#、--等特殊字符,观察回显。其次,要大胆猜测后端代码的逻辑。很多“简单”的题目,其漏洞点就在于开发者写了一句极其不安全的SQL拼接,例如SELECT $input FROM ...。在这种情况下,注入的本质不再是“注入条件”,而是“控制整个查询字段”。
5. 防御视角与安全编程思考
从这道题反推,作为一名开发者,应该如何避免此类漏洞?
- 永远不要信任用户输入:这是安全的第一原则。所有来自客户端(浏览器、APP、API调用)的数据都应视为不可信的。
- 使用参数化查询(预编译语句):这是防止SQL注入最有效、最根本的方法。无论是PHP的PDO、Python的
sqlite3或MySQLdb、Java的PreparedStatement,其原理都是将SQL语句的结构(模板)与数据分开发送给数据库。数据库先编译模板,再将用户输入的数据当作纯数据处理,从根本上杜绝了数据被解释为代码的可能。- 错误示例(拼接):
$sql = “SELECT * FROM users WHERE username = ‘“ . $username . “‘“; - 正确示例(参数化):
// PDO $stmt = $pdo->prepare(“SELECT * FROM users WHERE username = :username”); $stmt->execute([‘username’ => $username]);# Python sqlite3 cursor.execute(“SELECT * FROM users WHERE username = ?”, (username,))
- 错误示例(拼接):
- 如果必须拼接,请严格过滤和转义:在极少数无法使用参数化查询的场景下(如动态表名、列名),必须进行严格的白名单过滤。例如,如果参数只能是数字,就用
intval()强制转换;如果参数只能是一组已知的字符串(如‘asc‘, ‘desc‘),就用数组检查。对于字符串,使用数据库驱动提供的专用转义函数(如mysqli_real_escape_string()),但请注意,这不如参数化查询安全,因为转义规则可能因数据库字符集设置而失效。 - 最小权限原则:连接数据库的应用程序账号,不应该拥有
DROP,CREATE,ALTER等高级权限。通常只赋予SELECT,INSERT,UPDATE,DELETE等必要权限。这样即使发生注入,攻击者能造成的破坏也有限。 - 错误信息处理:在生产环境中,禁止向用户展示详细的数据库错误信息。应使用自定义的错误页面,并将详细错误记录到只有管理员可访问的日志中。这可以防止攻击者通过报错信息获取数据库结构等敏感信息。
回到这道题,如果后端代码是SELECT $input FROM flag,那么无论怎么过滤关键词,只要用户能控制$input,风险就极高。安全的做法应该是将查询固定,例如SELECT flag FROM flag,然后通过其他方式(如Session、Token)来控制用户是否有权访问这个查询,而不是让用户来指定查询字段。
6. 拓展与变种思路
“easysql”这类题目虽然基础,但可以衍生出很多变种,考察选手的灵活应变能力。
过滤绕过变种:
- 关键词过滤:如果过滤了
select,可以尝试SeLeCt(大小写绕过)、selselectect(双写绕过,假设过滤函数只替换一次)、%53%45%4c%45%43%54(URL编码)、0x73656c656374(十六进制)。 - 空格过滤:用
/**/(注释符)、()、%0a(换行)、%09(制表符)、+(在某些上下文中)代替。 - 引号过滤:如果
‘和“被过滤,对于数字型注入,可以直接用数字;对于字符型,可以尝试用Hex编码或Char()函数,例如SELECT * FROM users WHERE username=CHAR(97, 100, 109, 105, 110)(即admin)。
- 关键词过滤:如果过滤了
查询方式变种:
- 盲注:题目没有直接回显查询结果,而是通过页面内容的正误、响应时间的长短来隐式反馈查询结果的真假。这就需要用到
if(),sleep(),benchmark()等函数,通过布尔逻辑或时间延迟来逐位提取数据。 - 报错注入:利用数据库函数的执行错误,将查询结果带到错误信息中。例如MySQL的
updatexml(),extractvalue(),floor(rand()*2)等函数与group by子句的特定组合,可以触发报错并泄露数据。 - 二次注入:数据第一次插入数据库时被安全地转义了,但后来从数据库中被取出,再次拼接到SQL语句中时,没有被转义,从而造成注入。这需要跟踪数据的完整生命周期。
- 盲注:题目没有直接回显查询结果,而是通过页面内容的正误、响应时间的长短来隐式反馈查询结果的真假。这就需要用到
场景变种:
- 注入点可能在
ORDER BY后面,如ORDER BY $input。这时常用的UNION注入可能不适用,但可以用CASE WHEN语句进行布尔盲注,例如ORDER BY (CASE WHEN (SELECT SUBSTR(flag,1,1) FROM flag)=‘f‘ THEN 1 ELSE 2 END)。 - 注入点可能在
LIMIT后面,如LIMIT 0, $input。在MySQL 5.x中,LIMIT子句后的注入可以利用PROCEDURE ANALYSE()进行报错注入。
- 注入点可能在
理解“[suctf 2019]easysql”这道题,不仅仅是学会一个Payload,更是建立起一套面对SQL注入问题的系统性方法论:信息收集 -> 注入类型判断 -> 尝试基础Payload -> 分析过滤机制 -> 构思绕过方案 -> 利用漏洞获取数据。同时,也要从防御者角度思考,如何在编码阶段就杜绝此类漏洞,这才是安全竞赛和实战演练的终极价值。