0%

[RWCTF2021]wp

[RWCTF2021]wp

好像也一个题不会来着,其实算是个复现吧。。。
以及寒假+过年摸了好久的鱼。。。

Hack into Skynet

最多解的web,是一个加了一个迷之AI的pgsql注入

#!/usr/bin/env python3

import flask
import psycopg2
import datetime
import hashlib
from skynet import Skynet

app = flask.Flask(__name__, static_url_path='')
skynet = Skynet()

def skynet_detect():
    req = {
        'method': flask.request.method,
        'path': flask.request.full_path,
        'host': flask.request.headers.get('host'),
        'content_type': flask.request.headers.get('content-type'),
        'useragent': flask.request.headers.get('user-agent'),
        'referer': flask.request.headers.get('referer'),
        'cookie': flask.request.headers.get('cookie'),
        'body': str(flask.request.get_data()),
    }
    _, result = skynet.classify(req)
    return result and result['attack']

@app.route('/static/<path:path>')
def static_files(path):
    return flask.send_from_directory('static', path)

@app.route('/', methods=['GET', 'POST'])
def do_query():
    if skynet_detect():
        return flask.abort(403)

    if not query_login_state():
        response = flask.make_response('No login, redirecting', 302)
        response.location = flask.escape('/login')
        return response

    if flask.request.method == 'GET':
        return flask.send_from_directory('', 'index.html')
    elif flask.request.method == 'POST':
        kt = query_kill_time()
        if kt:
            result = kt 
        else:
            result = ''
        return flask.render_template('index.html', result=result)
    else:
        return flask.abort(400)

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

def query_login_state():
    sid = flask.request.cookies.get('SessionId', '')
    if not sid:
        return False

    now = datetime.datetime.now()
    with psycopg2.connect(
            host="challenge-db",
            database="ctf",
            user="ctf",
            password="ctf") as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))
        data = [r for r in cursor.fetchall()]
        return bool(data)

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

def create_session():
    valid_since = datetime.datetime.now()
    valid_until = datetime.datetime.now() + datetime.timedelta(days=1)
    sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest()

    sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)"
           "  VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until))
    return sessionid

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def sql_exec_update(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            conn.commit()
    except Exception as e:
        print(e)
    return data

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

功能就是登录然后能进行一个查询,在查询那里有一个注入点

赛时以为query_login_state能注入的,这样子就能直接登录,日了半天本地也能跑通远程就是不通。。。当时还以为是不是根本不存在其他人登陆成功的token在里面

cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))

赛后才发现这里和其他地方不一样,execute直接传了第二个参数,然后去翻了一下官方文档,这样子操作就是类似预处理的操作了,无法注入。
https://www.psycopg.org/docs/usage.html#query-parameters

但其余的语句都是先进行字符串拼接然后再整个传入execute的。但是create_session处三个参数均不可控,query_login_attempt处也不可控,综合看下来只有登录进去之后的query_kill_time是一个完全可控的注入点

绕过登录

登录那里不能注入就只能看看有没有其他的逻辑漏洞了,然后发现登录校验处写了垃圾代码。

    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

这里写了一句if not username and not password,也就是用户名密码均为空才会退出,而查询则是先查密码然后对比用户名,并且查不出来的时候用户名还是空字符串,那么只要随便输入一个密码,必定查不出来,用户名留空,两个空字符串对比相等,就能绕过登录校验。

注入环节

说实话我最烦SQL注入了。。。猛男落泪.jpg
pgsql不是很熟,直接上本地环境进行测试。这个注入并没有什么传统的关键字过滤,只有一个写在一开始的迷之Skynet进行检测(这个数据的传输格式+返回值的类型,一看就知道是个人工智能)。简单的测了一下就发现确实是这样子,经常会出现一个payload没有被ban,加上两个其他字符就挂了,然后去掉其他语句之后这个其他字符存在也能通过。。。

先就地注了一下当前表,没有有效数据(这个时候如果我直接改一个union select注入可能早就给他秒了。。。。)然后我究极不熟悉pgsql语法,居然愚蠢的想去进行盲注。。。明明这个题给了回显的来着。。。然后把上古套娃pgsql盲注语句翻出来之后,发现最后的引号闭合不起来。。。加上注释符就被waf,去掉注释符就没事。然后我开始乱测,希望通过其他的干扰字符来扰乱AI的判断。具体尝试有:添加大量的垃圾数据,在header各个接受输入的字段输入各种奇怪的字符,尝试是否存在类似于白名单之类的关键词,或者多塞几个引号和在内联注释里面塞一堆乱七八糟的字符。pgsql通过单引号来转义单引号,但如果在字符串的开头加一个大写E,就能用反斜杠来转义单引号,使用这种操作等等。均失败。。。太垃圾了

最后发现了一个绝对绕过方案,就是加括号?添加一定数量的括号对就能使任意复杂的payload通过,但这个括号在内联注释和字符串中都不行,就是得成为一个单独的语句。。。再次麻了,又试了一遍各种转义试图打断AI对字符串和注释的范围界定,失败。呜呜呜

最后是无敌的老国王试了下发现execute默认能执行多语句。并且pgsql能直接select;,这样子直接返回一个空的行,连续多个select;后接有效的查询语句之前的空行就都会被忽略。
以及老国王提到绕过AI用我之前那种引号注释乱塞的方法不一定能通过,他的思路就是我提到括号多起来之后就能bypass,所以尽可能的增多AI能够通过的内容,就可能被判别为安全

这样子就能任意注入自由翱翔了,并且支持多语句之后也可以令原本的查询语句结果为空,然后执行一个任意的查询
最后的payload为';(select);(select);(select);(select);(select);select 1,secret_key from target_credentials limit 1 offset 0;--
实际上根本不需要这么麻烦,好像这个AI对注释符–的检测比较严格,但只要能直接闭合引号,就能随便过检测,所以直接union就好了,或者堆叠然后select。。。最后写一个offset '0来闭合即可

这里在查表名的时候还踩了一些乱七八糟的坑。pgsql有一套自己的数据库结构体系,但同时又实现了information_schema这个SQL标准。导致我一时竟不知道查谁。information_schema就和常用的一样,但pgsql专用表函数得看看官方文档
https://www.postgresql.org/docs/9.3/monitoring-stats.html
pg_stat_all_tables查所有表名,pg_stat_user_tables可以省掉一些不必要的,只有当前用户的表,但是查列名好像还是得用information_schema

pgsql注入参考文章
说起来下次打的时候还是多翻翻文档好了。。。为什么我一开始会想到盲注而不是union或者堆叠呢。。。太奇怪了

其他解

最后的flag是说可以日flask也可以日skynet,我们这种绕过人工智能的打法应该是日的skynet,一时没想出来怎么日flask,赛后逛discord发现使用formdata进行数据的提交,会使得flask.get_data()无法获取到用户提交的数据,但在后续查询时还是能被form.get获取到,这样子输入数据直接不会被skynet处理,随意注入

RWDN

难度是baby,实际上也不是很baby。。。我太弱了,但解数确实也有十几个

nodejs写的一个文件上传,传上去之后那个目录下有一个Apache的服务,白名单方式限制了文件后缀

const express = require('express');
const fileUpload = require('express-fileupload');
const md5 = require('md5');
const { v4: uuidv4 } = require('uuid');
const check = require('./check');
const app = express();

const PORT = 8000;

app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');

app.use(fileUpload({
    useTempFiles : true,
    tempFileDir : '/tmp/',
    createParentPath : true
}));

app.use('/upload',check());

app.get('/source', function(req, res) {
    if (req.query.checkin){
        res.sendfile('/src/check.js');
    }
    res.sendfile('/src/server.js');
});

app.get('/', function(req, res) {
    var formid = "form-" + uuidv4();
    res.render('index', {formid : formid} );
});

app.post('/upload', function(req, res) {
    let sampleFile;
    let uploadPath;
    let userdir;
    let userfile;
    sampleFile = req.files[req.query.formid];
    userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5);
    userfile = sampleFile.name.toString();
    if(userfile.includes('/')||userfile.includes('..')){
        return res.status(500).send("Invalid file name");
    }
    uploadPath = '/uploads/' + userdir + '/' + userfile;
    sampleFile.mv(uploadPath, function(err) {
        if (err) {
            return res.status(500).send(err);
        }
        res.send('File uploaded to http://47.243.75.225:31338/' + userdir + '/' + userfile);
    });
});

app.listen(PORT, function() {
    console.log('Express server listening on port ', PORT);
});

check.js

module.exports = () => {
    return (req, res, next) => {
        if ( !req.query.formid || !req.files || Object.keys(req.files).length === 0) {
            res.status(400).send('Something error.');
            return;
        }
        Object.keys(req.files).forEach(function(key){
            var filename = req.files[key].name.toLowerCase();
            var position = filename.lastIndexOf('.');
            if (position == -1) {
                return next();
            }
            var ext = filename.substr(position);
            var allowexts = ['.jpg','.png','.jpeg','.html','.js','.xhtml','.txt','.realworld'];
            if ( !allowexts.includes(ext) ){
                res.status(400).send('Something error.');
                return;
            }
            return next();
        });
    };
};

后缀绕过

有两个解法,非预期更让人看得懂,预期解非常的高妙,就是这里的代码写的有点魔幻,导致高妙的预期解完全没有体现出来。

非预期

首先是一开始的检测逻辑,测了一下formid之类的变量存不存在,不存在直接return终止,这里是没有问题的

然后接下来这个foreach,出题人写了非常多的垃圾代码,不止一个点有问题,把我绕晕了。这里注意,foreach是一个回调函数,这里面的return只是简单的终止了该回调函数而已,和前面的if判断的return完全不一样。。。如果这里的foreach不是回调函数而是for循环的话,这个return就和continue没什么区别。。。检测出来了也是无事发生,所以这个黑名单检测就已经变成笑话了

然后还有更雷的,只要上传的文件没有后缀或后缀在白名单中,直接return next();而实际的文件上传是通过用户提交的formid来确定文件的,只要我先连传两个文件,第一个是白名单文件,第二个是恶意文件,提交的formid是恶意文件的id,就能直接在第一个文件合格直接next到upload路由完成上传。。。

然而我看到了更奇葩的打法,随便传文件,只要有一个是合法的文件就行了,都不用考虑顺序,因为白名单检测是在foreach这个回调函数里面,先被check到了然后return也不会终止会话处理,虽然会被发一个res.send结束了tcp通信,但是程序逻辑还在跑啊?只要能跑到有一个文件过了检测直接进next,又能通过formid上传任意文件了。。。

仔细思考一下,这里的问题主要出现在那个回调函数上,我估计出题人也忘了那里是个回调函数,return;写了和没写一样。正确的写法大概是把return改成能直接结束当前会话处理的东西?
当然那个return next()也写的没救了,改成return;差不多,然后在函数的最后写一个return next(),应该逻辑就对了。。。

something else

我以前一直以为调用next就是直接把程序的控制流给交出去了,当前函数调用next就会直接跳出该函数去执行下一个函数。今天稍微debug了一下才发现,next只是简单的帮你调用下一个中间件而已,在那个中间件调用结束之后又会回到当前函数代码中,就是进行了一次简单的函数调用,如果要做到控制流的切换,就一定要使用return next()
举一个简单的例子,如果在一个for循环中调用next,会导致下一个中间件被循环执行,而要做到调用next就直接切换到下一个中间件中,需要使用return next(),Stack Overflow上有一个不错的回答
When to use next() and return next() in Node.js

这也就解释了为什么非预期解中无论怎么上传都会回显something error,虽然写的是return next(),但是由于是在回调函数中调用。。。只是将回调函数的控制权移交了出去,当upload路由执行完之后还是会回到foreach循环中继续处理,而sampleFile.mv的上传成功应答也是一个回调函数,由于nodejs的event loop的原因被插在了任务队列中,直到forEach循环结束才轮到其执行,而forEach总会有一次循环到上传的恶意文件,然后return一个400和something error,等到sampleFile.mv应答成功信息时,会话早就结束了

预期

也是上传多个文件,其中一个文件的key为__proto__,Object.keys这个函数会主动忽略proto这类键,从而让这个文件直接不接受检测,但仍需要第二个合法文件,不然的话没法进next,但这个由于不会被白名单检测给发现,所以能正常返回文件上传的路径(因为路径是通过remote addr和文件md5生成的,所以非预期解中需要通过上传一个文件内容一模一样的合法文件来获取目录)

不过这里还是有一丝丝微妙,因为预期解中的formid并不是__proto__,而是数字1,也就是是这里并不是简单地用proto来隐藏恶意文件,req.file对象遭到了修改。
无法理解ing,需要进行简单的调试
但是我完全不会调试node。。。我甚至不知道当他启动之后我应该从哪进当前会话的各项属性的初始化。。。。
所以我大致猜测app的运行应该还是一个个中间件走过来的,那么文件上传的处理应该就在这里

app.use(fileUpload({
    useTempFiles : true,
    tempFileDir : 'tmp/',
    createParentPath : true
}));

不过这里应该是指定了回调函数,直接在这下断点只会在启动时触发一次,直接进这个fileUpload的定义里面去下断点。一路跟进到processMultipart.js,这里面虽然看不太懂,但是应该还是以回调的方式注册了几个处理函数,在L122找到了req.files的赋值

      req.files = buildFields(req.files, field, fileFactory({
      ........
      },options));

然后在buildFields中再下一个断点,开始调试
先看看这个函数长什么样

const buildFields = (instance, field, value) => {
  // Do nothing if value is not set.
  if (value === null || value === undefined) return instance;
  instance = instance || {};
  // Non-array fields
  if (!instance[field]) {
    instance[field] = value;
    return instance;
  }
  // Array fields  
  if (instance[field] instanceof Array) {
    instance[field].push(value);
  } else {
    instance[field] = [instance[field], value];
  }
  return instance;
};

这里的instance就是req.files,field是文件上传是的key,value是经过fileFactory函数处理过的文件对象。
逻辑很简单,如果instance的filed不存在,就直接进行赋值,如果存在,如果那个filed已经是一个数组,就把当前值push上去,如果不是数组,就把已存在的值和当前值拼成一个数组再赋值上去。
对于正常文件当然没有问题,但当field是__proto__时,场面就会有所变化,首先这个值一定是存在的,因此会进入第二个if,而req.files的proto是一个Object,不是Array,所以会将req.files的proto进行重新赋值,变为一个其原proto和上传的恶意文件的Obj的数组,如图所示

image-20220124215158599

image-20220124214906087

dda是我随便上传的另一个为触发next的有效文件

因此在进行上传formid访问时,Object.keys还是能获取到之前上传的dda,能成功进入到next(),但恶意文件被塞进proto,无法被检测,访问时使用下标,虽然req.file没有下标1的对象,但其proto已被修改,其对应1下标的对象即为恶意对象,完成上传并获取到路径

.htaccess

文件上传到的目录上运行了一个Apache服务,那唯一有可能的攻击方式就是传htaccess了

显然,这里应该不会简单到还给Apache配了PHP,一键打通未免太简单了。所以在Apache没有装PHP的情况下需要找到其他的可利用点,这里我直接在赛后discord里看到师傅们提到的ErrorDocument任意文件读
多翻Apache文档
ErrorDocument

然后我直接写了个ErrorDocument 404 /etc/passwd,发现还是404,但报错是ErrorDocument时再次遭遇404,认真一看才发现这里的路径是直接接着网站根路径进行访问的,并不能读到整个文件系统

但是就在ErrorDocument的介绍里面有提到expression syntax可以在这里用于动态产生应答

点开看一眼,看到了几个比较关键的东西

variable ::= “%{“ varname “}”
| “%{“ funcname “:” funcargs “}”
file Read contents from a file (including line endings, when present)

任意文件读取有了
ErrorDocument 404 %{file:/etc/passwd}

在/etc/apache2/apache2.conf下读取到Apache配置文件,在最后看到了这么一句
ExtFilterDefine 7f39f8317fgzip mode=output cmd=/bin/gzip
从来没见过的操作哦?这里应该就是利用点了

简单搜一下会直接搜到这个
Apache Module mod_ext_filter

大抵就是说这个ExtFilterDefine会定义一个外部的程序对内容进行处理,这里用的是/bin/gzip,这个操作是新开进程的,所以使用强有力的RD_PRELOAD进行劫持RCE,SetOutputFilter项可以为输出定义对应的filter,配合SetEnv目录制定PD_PRELOAD,绕过上传恶意.so文件完成利用

setenv文档
Apache Module mod_env

至于.so怎么写,就太经典了,不谈

something else

说起来怎么看Apache是否启用了PHP?似乎有的会在配置文件里面直接load对应的module,不过我感觉大部分自动安装的应该都是在/etc/apache2/mods-enabled下面会有单独的PHP配置文件?我这里的就是一个php7.0.conf

Secured Java

这个题说起来不是web来着,标签是misc和pwn。。。但是我没事做下了一下附件并发现是一个python+java的题,就决定试一下
当然没做出来

#!/usr/bin/env python
import os
import base64
import tempfile
import subprocess

SOURCE_FILE = "Main.java"
DEP_FILE = "dep.jar"


def get_file(filename: str):
    print(f"Please send me the file {filename}.")
    content = input("Content: (base64 encoded)")
    data = base64.b64decode(content)
    if len(data) > 1024 * 1024:
        raise ValueError("Too long")
    with open(filename, "wb") as fp:
        fp.write(data)


def main():
    print("Welcome to the secured Java sandbox.")
    with tempfile.TemporaryDirectory() as dir:
        os.chdir(dir)
        get_file("Main.java")
        get_file("dep.jar")
        print("Compiling...")
        try:
            subprocess.run(
                ["javac", "-cp", DEP_FILE, SOURCE_FILE],
                input=b"",
                check=True,
            )
        except subprocess.CalledProcessError:
            print("Failed to compile!")
            exit(1)

        print("Running...")
        try:
            subprocess.run(["java", "--version"])
            subprocess.run(
                [
                    "java",
                    "-cp",
                    f".:{DEP_FILE}",
                    "-Djava.security.manager",
                    "-Djava.security.policy==/dev/null",
                    "Main",
                ],
                check=True,
            )
        except subprocess.CalledProcessError:
            print("Failed to run!")
            exit(2)


if __name__ == "__main__":
    main()

就是能输入两个内容,一个java文件和一个依赖jar包,python用subprocess来运行,但在运行时指定了java.security.policy==/dev/null,/dev/null读出来的结果永远是EOF,就等于是空文件,在policy被指定为空文件时等于关掉了所有权限。。。。

经过简单的搜索发现了两个在关闭所有权限下仍然能进行的操作。分别是创建子进程和读取和class同目录及子目录下的文件
但是创建的子进程也同样受到security manager的超级限制,能下手的地方似乎只有文件读取?简单测试之后发现是可以读取软链接的,即使软链接指向的文件不在当前目录下。这让我想起了那个经典zip压缩软链接的操作。而jar包其实就是一个zip,往里面多塞一个软链接也不会影响解析,编译也能通过。似乎是一个很好的主意

然后编译出来之后发现java读zip中的软链接会只能读出来文件名,无法读取软链接的内容,搜索了一下之后发现好像的确如此,躺平了。。。不会

现在想起来这个是pwn题,而输入就是给python的两个文件,不会是这里能pwn一下吧。。。

看wp

看完之后说这个security manager无敌防御,打不进去的。有问题的点在于那个编译环节,使用dep.jar进行编译时如果对编译时操作进行额外设置,可以在编译过程中进行rce。完全不知道有这种事啊。我是菜狗

直接看r3的wp好了

r3kapig wp