某 PHP 混淆器 V3.1.0 及变种加密强度分析
声明
本文目的不是破解,而是分析某 PHP 混淆器 V3.1.0 及其变种的加密强度与防逆向能力。文中相关商业名称均已消去,仅讨论技术原理。本文不提供任何形式的成品工具或一键解码脚本,仅分享分析思路与伪代码。
样本
一份由该工具 V3.1.0 加密的 PHP 文件,原始代码仅为一行 echo 1;,用于测试加密强度。加密后的文件结构如下:
- 开头:版权注释,包含工具名、版本号、创建时间、联系方式
- 主体:
if(!defined(...))define(...); $GLOBALS[...]=explode('|,|2|2','...');
- 后续:大量
goto 语句构成的跳转网络
- 结尾:
echo $________;
第一层:字符串表
加密代码的核心入口是一句 explode:
$GLOBALS[A__AA_AAA]=explode('|,|2|2','H*|,|2|262324A...|,|2|...');
用一条正则即可提取完整字符串表:
/explode\s*\(\s*['"]\|,\|2\|2['"]\s*,\s*['"]([\s\S]*?)['"]\s*\)/
提取后按 |,|2|2 分割,得到 14 段数据。每段数据对应一个字符串常量或一段 payload。
第二层:逐段解码
对每一段数据,解码流程是固定的:
hex 解码 → 判断是否为 base64 → 若是则 base64 解码 → 尝试 gzip/deflate 解压
伪代码:
FUNCTION decode_piece(piece):
FOR skip FROM 0 TO 3:
candidate = piece[skip:]
# 跳过前几个字节,尝试匹配纯十六进制串
IF candidate IS NOT hex string:
CONTINUE
# 第一步:hex 解码
bytes = hex_decode(candidate)
# 第二步:如果结果是短可读文本,直接返回
IF bytes IS printable AND length(bytes) < 300:
RETURN bytes
# 第三步:如果是 base64,先 base64 解码
IF bytes IS valid base64:
decoded = base64_decode(bytes)
# 第四步:尝试解压
decompressed = try_decompress(decoded)
IF decompressed EXISTS:
RETURN decompressed
ELSE:
RETURN decoded
# 第五步:不是 base64,直接尝试解压
decompressed = try_decompress(bytes)
IF decompressed EXISTS:
RETURN decompressed
RETURN bytes
RETURN decode_failed
其中 try_decompress 的逻辑:
FUNCTION try_decompress(bytes):
# 依次尝试三种压缩格式,哪个成功用哪个
FOR format IN [deflate-raw, deflate, gzip]:
TRY:
stream = create_decompression_stream(format)
write(stream, bytes)
close(stream)
result = read_all(stream)
IF result IS NOT empty:
RETURN result
CATCH exception:
CONTINUE
RETURN null
逐段处理结果:
| 段索引 |
解码后内容 |
长度 |
| 0 |
(失败) |
— |
| 1 |
ob_end_clean |
12 字节 |
| 2 |
return eval($_); |
18 字节 |
| 3 |
ob_start |
8 字节 |
| 4 |
gzuncompress |
12 字节 |
| 5 |
$_ |
2 字节 |
| 6 |
base64_decode |
13 字节 |
| 7 |
__BFeVWX |
8 字节 |
| 8 |
主 payload |
1140 字节 |
| 9 |
printf |
6 字节 |
| 10 |
ob_get_contents |
15 字节 |
| 11 |
Loading DeckPHP Encode |
22 字节 |
| 12 |
function_exists |
15 字节 |
| 13 |
__BFeVWX |
8 字节 |
第 8 段是主 payload,解码过程为:
hex 解码 → 952 字节
base64 解码 → 714 字节
deflate 解压 → 1140 字节
第三层:payload 分析
第 8 段解码后的内容是一段 PHP 代码,核心结构如下:
$____ = 'printf';
$___________ = 'DeckPHP Encode Class...';
/* [DECKPHP] */
$__... = '...'; // 一个超长 base64 字符串
在这个 payload 中,存在一个长度超过 400 字符的 base64 串。提取并解码:
伪代码:
FUNCTION extract_final_layer(source):
# 在源码中找所有 $变量 = '超长base64字符串';
regex = /\$\w+\s*=\s*'([A-Za-z0-9+/=]{200,})'/g
longest = null
FOR EACH match IN regex.execute(source):
current = match[1]
# 保留最长的那一个
IF longest IS null OR length(current) > length(longest):
longest = current
IF longest IS null:
RETURN source # 没找到,返回原样
# base64 解码
decoded = base64_decode(longest)
# 尝试解压
decompressed = try_decompress(decoded)
IF decompressed EXISTS:
RETURN decompressed
# 没有压缩,直接当文本返回
RETURN decoded
对该 base64 串解码:
base64 解码 → 331 字节
解码结果直接就是可读文本,即最终源码。
最终源码
/*
本代码由 - Version V3 创建
创建时间 2026-10-09 12:02:44
技术支持 去个敏<>
严禁反编译、逆向等任何形式的侵权行为,违者将追究法律责任
*/
if(!defined('_AAA____A'))define('_AAA____A', 'AA__AA___');
$GLOBALS[_AAA____A]=explode('|W|S|I','H*');
unset($�);$�;
echo 1;
原始代码 echo 1; 被完整还原。加密代码中大量 goto 跳转、变量混淆、字符串表索引,在静态解码面前全部失效——因为解码过程根本没有执行加密代码,而是直接从数据层把源码捞了出来。
完整流程伪代码
把上面各层串起来,整体分析流程如下:
FUNCTION analyze_encrypted_file(file_content):
# ===== 第一步:提取字符串表 =====
match = regex_extract(file_content, /explode\(...\)/)
IF match FAILED:
OUTPUT "未找到字符串表"
RETURN
raw_table = match.group[1]
pieces = split(raw_table, '|,|2|2')
# ===== 第二步:逐段解码 =====
decoded_table = []
FOR EACH piece IN pieces:
result = decode_piece(piece)
decoded_table.append(result)
# ===== 第三步:找主 payload =====
# 解码后最长的那段就是主 payload
candidates = filter(decoded_table, length > 100)
IF candidates IS empty:
OUTPUT "未找到主 payload"
RETURN
main_payload = candidates.sort_by_length_desc().first()
# ===== 第四步:提取最后一层 =====
final_source = extract_final_layer(main_payload)
# ===== 第五步:输出 =====
OUTPUT final_source
整个流程不需要执行加密代码,不需要调试器,不需要理解 goto 跳转逻辑。
变种分析
闲来无事到这个混淆器官网生成了一个变种玩。该变种与原版共用相同的顶层结构(版权注释 + explode 字符串表 + goto 网络),但在分隔符、段索引、payload 位置上有所差异。以下分析该变种。
变种 A:分隔符变化 + 超长 payload 段
特征:
$GLOBALS[__A____A_]=explode('|3|B|,','H*|3|B|,7072696E7466|3|B|,...|3|B|,654E7139...');
差异:
- 分隔符从
|,|2|2 变为 |3|B|,
- 常量名从
A__AA_AAA 变为 __A____A_
- 主 payload 段从第 8 段变为第 3 段
- 主 payload 解码过程从
hex + base64 + deflate 变为 hex + base64 + gzinflate
goto 网络规模从 184 个变为 500 个
分析:
变种 A 的差异体现在参数层面,而非结构层面。分隔符、常量名、段索引都是可变参数,不影响解码流程。
主 payload 的定位方式需要从“固定取第 8 段”改为“取解码后最长的段”。这是因为变种 A 把主 payload 放在了第 3 段,而原版在第 8 段。通过“取最长”这一策略,可以自动适配不同变种的段索引。
伪代码:
FUNCTION find_main_payload(decoded_table):
# 找出解码后长度超过 100 的所有段
candidates = []
FOR EACH item IN decoded_table:
IF item.decoded EXISTS AND length(item.decoded) > 100:
candidates.append(item)
IF candidates IS empty:
RETURN null
# 按长度降序排列,取最长
candidates.sort_by_length_desc()
RETURN candidates.first()
goto 网络规模的变化不改变防护本质。无论 184 个还是 500 个 goto,它们都只影响动态执行时的控制流。在静态解码路径下,我们不执行这些 goto。当然goto最后可以解:参考 https://www.52pojie.cn/thread-1760466-1-1.html
变种 A 完整解码链
第 1 层:explode('|3|B|,', ...) → 14 段
第 2 层:逐段 hex 解码
第 3 层:第 3 段 hex 解码 → 2784 字节
第 4 层:base64 解码 → 2088 字节
第 5 层:gzinflate 解压 → 3776 字节(中间代码)
第 6 层:从中间代码里提取 $___________ 的 base64 → 3080 字符
第 7 层:base64 解码 → 2206 字节(最终 payload)
第 8 层:最终 payload 里 explode('|#|X|9', ...) → 7 段
第 9 层:逐段 hex 解码 → 业务字符串
变种对比总表
| 特性 |
原版 V3 |
变种 A |
| 分隔符 |
\|,\|2\|2 |
\|3\|B\|, |
| 常量名 |
A__AA_AAA |
__A____A_ |
| 段数 |
14 |
14 |
| 主 payload 段 |
第 8 段 |
第 3 段 |
| 主 payload 解码 |
hex + base64 + deflate |
hex + base64 + gzinflate |
| 中间代码 |
无 |
有(3776 字节) |
| 最终 payload 内嵌 explode |
无 |
有(\|#\|X\|9) |
| goto 数 |
184 |
500 |
| 静态解码耗时 |
约 20 分钟 |
约 35 分钟 |
通用解码流程伪代码
针对原版和变种 A,通用解码流程如下:
FUNCTION analyze_encrypted_file_v3(file_content):
# ===== 第一步:提取字符串表(通用)=====
match = regex_extract(file_content, /explode\s*\(\s*'([^']+)'\s*,\s*'([^']+)'\s*\)/)
IF match FAILED:
OUTPUT "未找到字符串表"
RETURN
separator = match.group[1] # 自动读取分隔符,适配 |,|2|2 和 |3|B|,
raw_table = match.group[2]
pieces = split(raw_table, separator)
# ===== 第二步:逐段解码(通用)=====
decoded_table = []
FOR EACH piece IN pieces:
result = decode_piece(piece)
decoded_table.append(result)
# ===== 第三步:找主 payload(通用)=====
# 不依赖固定索引,而是取解码后最长的段
main_payload = find_main_payload(decoded_table)
IF main_payload IS null:
OUTPUT "未找到主 payload"
RETURN
# ===== 第四步:提取最终源码(通用)=====
# 主 payload 可能直接是源码,也可能内部还有一层
final_source = extract_final_layer(main_payload)
# ===== 第五步:如果最终源码里还有 explode,递归解一层(变种 A)=====
IF final_source CONTAINS "explode(":
inner_match = regex_extract(final_source, /explode\s*\(\s*'([^']+)'\s*,\s*'([^']+)'\s*\)/)
IF inner_match FOUND:
inner_sep = inner_match.group[1]
inner_table = inner_match.group[2]
inner_pieces = split(inner_table, inner_sep)
inner_decoded = []
FOR EACH p IN inner_pieces:
inner_decoded.append(decode_piece(p))
final_source = replace_in_source(final_source, inner_decoded)
# ===== 第六步:输出 =====
OUTPUT final_source
其中 extract_final_layer 的逻辑如下:
FUNCTION extract_final_layer(source):
# 在源码中找所有 $变量 = '超长base64字符串';
# 取最长的那一个
regex = /\$\w+\s*=\s*'([A-Za-z0-9+/=]{200,})'/g
matches = regex_findall(source, regex)
IF matches IS empty:
RETURN source
longest = matches.sort_by_length_desc().first()
# base64 解码
decoded = base64_decode(longest)
# 尝试解压
decompressed = try_decompress(decoded)
IF decompressed EXISTS:
RETURN decompressed
RETURN decoded
该流程的核心设计原则是:
- 不依赖固定分隔符:动态读取
explode 的第一个参数
- 不依赖固定段索引:取解码后最长的段作为主 payload
- 不依赖固定解码链:每段都尝试 hex → base64 → 解压
- 不执行加密代码:纯数据层解码,
goto 网络完全绕过
因此,原版和变种 A 共用同一套分析逻辑,只需在参数层面自动适配即可。
技术分析
该混淆器 V3.1.0 及变种 A 的防护层次如下:
1. 字符串表分割
用 explode 将多个字符串常量存为一张表,按索引访问。防护意图是让分析者难以直接定位关键字符串。
实际效果:一条正则即可提取完整表,逐段解码即可还原所有字符串。分隔符变化不构成防护,动态读取即可适配。
2. hex + base64 + gzip 多层编码
数据经过 hex 编码、base64 编码、gzip/deflate 压缩三层处理。
实际效果:解码流程是标准算法,按固定顺序逐层剥离即可。压缩格式变化(deflate vs gzinflate)不构成防护,try_decompress 依次尝试即可适配。
3. 超长 base64 隐藏源码
主 payload 中嵌入超长 base64 串,解码后得到最终源码。
实际效果:正则匹配长 base64 串,一次解码即得源码。变种 A 在中间代码里再次嵌入超长 base64,但仍然是同一策略。
4. goto 跳转网络
大量 goto 语句构成控制流混淆,变量名用不可读字符替换。
实际效果:静态解码完全绕过这一层。不执行代码,goto 网络就毫无意义。无论 184 个还是 500 个 goto,静态解码路径完全不关心。
5. 嵌套动态函数
使用 __BFeVWX 或 __QApZFI 动态创建函数,增加动态分析难度。
实际效果:静态解码完全绕过这一层。不需要执行 __BFeVWX 或 __QApZFI,直接从数据层提取最终 payload。
评测报告
| 防护层 |
设计意图 |
实际破解成本 |
变种适配成本 |
| 字符串表分割 |
隐藏关键字符串 |
一条正则,约 5 分钟 |
+2 分钟 |
| hex + base64 + gzip |
多层编码增加解码难度 |
标准解码流程,约 10 分钟 |
0 分钟 |
| 超长 base64 隐藏源码 |
隐藏最终源码 |
正则匹配长串,约 5 分钟 |
0 分钟 |
| goto 跳转网络 |
控制流混淆 |
静态解码完全绕过,0 分钟 |
0 分钟 |
| 变量名混淆 |
增加阅读难度 |
静态解码完全绕过,0 分钟 |
0 分钟 |
| 嵌套动态函数 |
增加动态分析难度 |
静态解码完全绕过,0 分钟 |
0 分钟 |
| 版权声明 |
法律威慑 |
无技术防护效果 |
0 分钟 |
原版静态解码耗时:约 20 分钟。
变种 A 静态解码耗时:约 35 分钟。
结论
该混淆器 V3.1.0 及变种 A 的核心防护思路是“多层编码 + 控制流混淆”,但存在一个根本性问题:所有编码都是可逆的,且解码逻辑完全暴露在文件自身中。
explode 字符串表是入口,正则即可提取。hex、base64、gzip 三层编码是标准算法,按固定顺序逐层剥离即可。超长 base64 串是最后一层,正则匹配即可定位。
变种 A 增加了嵌套层,但嵌套的每一层仍然是标准算法的组合。分隔符、常量名、段索引、压缩格式的变化,都只是参数层面的差异,不改变解码流程的本质。
整个解码过程不需要执行加密代码,不需要调试,不需要理解 goto 跳转逻辑,纯粹是数据层的逆向。因此,goto 混淆、变量名混淆、嵌套动态函数这些针对“动态分析”的防护手段,在“静态解码”面前完全失效。
变种 A 的差异只体现在参数和层数上,不体现在解码难度上。每增加一层标准编码,解码时间线性增加,但解码方法不变。这意味着变种的防护效果与层数成正比,而与“新颖性”无关。
这不是工程上的胜利,更不是混淆和技术上的胜利,而是营销上的胜利,毕竟官网吹的有多牛,实际摔地上就有多惨。当然这玩意还是有点难度(提交的样例echo 1因为本身简单分析也easy)
免责声明
本文仅用于技术研究与学习,分析对象为自行加密的测试样本。文中不提供任何成品解码工具,不针对任何具体商业产品进行破解。文中所涉商业名称均已消去,如无意中侵犯了您的权益,请联系删除。