[HCTF2018] WEB Writeup

WarmUp

查看源代码,发现source.php

<?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;
}

$_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\" />";
}
?>

可以看出存在文件包含,也就是我们的payload大概是要index.php?file=这样。

在hint.php中可以得到flag文件的名字ffffllllaaaagggg

源码核心部分:

if (! empty($_REQUEST['file'])
&& is_string($_REQUEST['file'])
&& emmm::checkFile($_REQUEST['file'])
) {
include $_REQUEST['file'];
exit;

file有3个条件,前两个都很好满足,第三个是要通过emmm类的checkFile函数,才能include。

看看checkFile函数,函数一共做了2次问号前截取,3次白名单检测,1次URL解码。

以下是checkFile函数重要部分:

$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;

因此要绕过checkFile检测,我们可以利用?截取hint.php使其在白名单中,再利用跳转目录读到flag所在文件夹的内容。

payload:

/index.php?file=hint.php?../../../../../ffffllllaaaagggg

本题利用了当时phpmyadmin爆出的文件包含CVE,此处关于此CVE的解析

admin

这道题有三种解法,虽然不太难但能学到hin多

有登陆和注册页面

随便注册了一个号,登上后源代码中提示<!-- you are not admin -->

注册admin又会提示账号已存在,用sql注入的方法构造了一下就Internal Server Error了,所以还是用刚才注册的号登上去康康。

登上去之后在post页面可以发文章,在change页面可以改密码。并且在change页面给出了一段注释<!-- https://github.com/woadsl1234/hctf_flask/ -->

给了源码,那就能进一步观望了。以下是本题的三种解法。

session伪造

源码routes.py中的登陆一段中:

@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)

就是session[‘name’] = ‘admin’才会在页面上显示flag,因此我们需要伪造session。

客户端 session 导致的安全问题

看过这篇文章基本就明白了

关键的几点:

主要看最后两行代码,新建了URLSafeTimedSerializer类 ,用它的dumps方法将类型为字典的session对象序列化成字符串,然后用response.set_cookie将最后的内容保存在cookie中。

那么我们可以看一下URLSafeTimedSerializer是做什么的:

主要关注dump_payloaddumps,这是序列化session的主要过程。

可见,序列化的操作分如下几步:

  1. json.dumps 将对象转换成json字符串,作为数据
  2. 如果数据压缩后长度更短,则用zlib库进行压缩
  3. 将数据用base64编码
  4. 通过hmac算法计算数据的签名,将签名附在数据后,用“.”分割

第4步就解决了用户篡改session的问题,因为在不知道secret_key的情况下,是无法伪造签名的。

由于flask的session在本地,可以通过这个脚本将登上去的账号的session取出来进行解码。

解码后可以看到原来的name为当前的用户名11,因此改成admin后再重新签名即可构造出新的session。

在源码中写出了

SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'

将ckj123用作key试试

把原session换成这个,flag就显示出来了。

Unicode欺骗

在源码中可以看到这样一段代码:

if request.method == 'POST':
name = strlower(form.username.data)

将名字转成小写了,并且在登陆和注册地方都转成了大小写,所以不能使用注册ADMIN的方法来绕过啦。

但这里用到的是strlower进行小写转换,看看自定义的strlower函数:

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

用到了nodeprep.prepare,它对应的库是twisted,现在已经更新到20.3.0了

再看看本题用的

Unicode同形字引起的安全问题这篇文章的例子蛮好的,但我看了其他好多篇文章,总有一种”Unicode同形字引起的安全问题是怎么回事呢?Unicode同形字引起的安全问题相信大家都很熟悉,但是Unicode同形字引起的安全问题是怎么回事呢,下面就让小编带大家一起了解吧。Unicode同形字引起的安全问题,其实就是Unicode同形字引起的安全问题,大家可能会很惊讶Unicode同形字引起的安全问题怎么会是Unicode同形字引起的安全问题呢?但事实就是这样,小编也感到非常惊讶。这就是关于Unicode同形字引起的安全问题的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!”

好8,回归正题。

大致意思就是在对字符串的标准化操作时导致unicode字符转换成了与它同形的ascii字符。

比如A就与同形。

并且这个老版本的库的nodeprep.prepare会将内容转换成小写,将其他类的编码转成ASCII码,因此就造成了这个漏洞。所以我们可以注册时将admin写成ᴬdmin,因此在strlower转换时就把转换成了A,然后在改密码时,change处也有strlower把A改成a,这样就实现更改admin密码了。

(并且此处由于在login、register处都有strlower处理,因此在registerᴬdmin之后,认为是注册的用户名是Admin,但在login时也会进行strlower处理,因此仍然要输入ᴬdmin登陆。

更改后再登上去就能成功拿到flag了。

条件竞争

这种办法需要天时地利人和?我觉得思路是正确的并且出题人也说彳亍,但我没跑出来。

原理就是在进行登陆改密码时没有进行验证,就直接把session[‘name’]赋值给name了

@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)

因此就可以写个双线程脚本,线程1更改密码,线程2登出并利用用户名为admin与更改的密码登陆。如果运气好的话可以当线程1进行到改密码操作时,进程2恰好注销且要进行登录,此时进程1改密码需要一个session,而进程2刚好将session[‘name’]赋值为admin,然后进程1调用此session修改密码,即修改了admin的密码。

条件竞争方法参考

Author: Neorah
Link: https://neorah.me/ctf/Hctf2018/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.