Elkeid v1.9.1(初步学习版)

security

简介 #

前段时间 Elkeid team 更新了 v1.9.1 版本,各个重要组件均有很多的更新,也解决了之前 issue 中很多的问题。我们还是继续学习一下 Elkeid 源码

1. Agent #

Agent 部分略读,可能有部分没有覆盖到

1.1. UUID 计算 #

改动部分主要为:增加了 pdid 的校验

if len(source) > 8 &&
    string(pdid) != "03000200-0400-0500-0006-000700080009" &&
    string(pdid) != "02000100-0300-0400-0005-000600070008" {
    pname, err := fromIDFile("/sys/class/dmi/id/product_name")
    if err == nil && len(pname) != 0 &&
        !bytes.Equal(pname, []byte("--")) &&
        !bytes.Equal(pname, []byte("unknown")) &&
        !bytes.Equal(pname, []byte("To be filled by O.E.M.")) &&
        !bytes.Equal(pname, []byte("OEM not specify")) &&
        !bytes.Equal(bytes.ToLower(pname), []byte("t.b.d")) {
        ID = uuid.NewSHA1(uuid.NameSpaceOID, source).String()
    }
    return
}

其中的 pdid 为 /sys/class/dmi/id/product_uuid 中读取。这个 ID 好像在哪里见过,我们看一下 osquery 的源码,就能发现如下:

const std::vector<std::string> kPlaceholderHardwareUUIDList{
    "00000000-0000-0000-0000-000000000000",
    "03000200-0400-0500-0006-000700080009",
    "03020100-0504-0706-0809-0a0b0c0d0e0f",
    "10000000-0000-8000-0040-000000000000",
};

都有一个类似的白名单,整理一下每个白名单都对应着啥

UUIDDescription
03000200-0400-0500-0006-000700080009一些主板厂商的默认设置,比如Gigabyte的,同时serial-number 显示为 To be filled by O.E.M.
02000100-0300-0400-0005-000600070008一些KVM启动的机器会有这个类似的UUID
00000000-0000-0000-0000-000000000000看起来是 BIOS 设置问题
03020100-0504-0706-0809-0a0b0c0d0e0f-
10000000-0000-8000-0040-000000000000-

还有 /sys/class/dmi/id/product_name 这部分的校验,这些我之前没想到过,可能因为我所在公司内部的机器数量较少,基本不会碰到 UUID 冲突的问题。BAD UUID 的case其实还有一些,一些工具会自己维护一份

1.2. Agent 自更新 #

更新的部分主要分为 DownloadDecompress 以及启动部分,在 Elkeid v1.9.1 中三个部分均有小改动

Download 变更 #

主要包含 client 变更,resp.Body 限制, io.TeeReader 方式优化

之前下载超时由一个 subctx 控制,目前是直接采取设置 Client Timeout 的方式,配置如下

client := &http.Client{
    Transport: &http.Transport{
        Dial: (&net.Dialer{
            Timeout:   15 * time.Second,
            KeepAlive: 30 * time.Second,
        }).Dial,
        ForceAttemptHTTP2:     true,
        MaxIdleConns:          100,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    },
    Timeout: time.Minute * 10,
}

其中 Transport 的配置和 http.DefaultTransport 相比,应该是只有 Timeout 的部分变短(从 30 -> 15)

增加了文件最大读取的 size,应该是防止出现意外下载过大文件,导致所有下发升级的机器磁盘堆满,client side 的安全措施(我猜的)

resp.Body = http.MaxBytesReader(nil, resp.Body, 512*1024*1024)

对比一下之前的方式,Elkeid v1.7.1:

buf, err = ioutil.ReadAll(resp.Body)
if err != nil {
    continue
}
hasher.Reset()
hasher.Write(buf)
if !bytes.Equal(hasher.Sum(nil), checksum) {
    err = errors.New("checksum doesn't match")
    continue
} else {
    br := bytes.NewBuffer(buf)
    switch config.Type {
    case "tar.gz":
        err = DecompressTarGz(dst, br)
    default:
        err = DecompressDefault(dst, br)
    }
    break
}

之前版本,将整个 resp.Body 通过 ioutil.RealAll 的方式读取全部至内存,下发的时候会有一个内存上升的问题,如果连续下发多个还有可能会触发 cgroup 被 kill (因为Hades Agent完全按照Elkeid来,之前有类似的情况)。新版本代码如下:

resp.Body = http.MaxBytesReader(nil, resp.Body, 512*1024*1024)
hasher.Reset()
r := io.TeeReader(resp.Body, hasher)
switch config.Type {
case "tar.gz":
    err = DecompressTarGz(r, filepath.Dir(dst))
default:
    f, err = os.OpenFile(dst, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o0700)
    if err == nil {
        _, err = io.Copy(f, r)
        f.Close()
    }
}
resp.Body.Close()
if err == nil {
    if checksum := hex.EncodeToString(hasher.Sum(nil)); checksum != config.Sha256 {
        err = fmt.Errorf("checksum doesn't match: %s vs %s", checksum, config.Sha256)
    } else {
        break
    }
}

io.TeeReader 可以看作是一次优化,不再全部 dump 到内存中,简单写了一个粗糙的 Benchmark 模拟,结果如下

goos: linux
goarch: amd64
pkg: agent/utils
cpu: Intel(R) Xeon(R) CPU E5-26xx v4
BenchmarkDownloadOld-2                 9         118909617 ns/op        81999175 B/op        138 allocs/op
BenchmarkDownloadNew-2                15          82094187 ns/op           47442 B/op         93 allocs/op
PASS
ok      agent/utils     3.441s

新增一个 Limiter, 解压的逻辑部分改动, 不展开说了

zr, err := gzip.NewReader(io.LimitReader(r, 512*1024*1024))

1.3. sync.Pool 优化 #

之前磊哥提到过这个问题 - Go issue-23199,简单描述为sync.Pool 碰到动态增长的大 buffer 会导致内存无法回收,从而导致无限增长的问题。解决方法就是对 []byte 做分批处理,或者是直接丢弃掉这个 []byte 等着被 GC 掉。下面是 Elkeid 的代码

pools = [...]sync.Pool{
    {New: func() any {
        return &proto.EncodedRecord{
            Data: make([]byte, 0, defaultCap),
        }
    }},
    {New: func() any {
        return &proto.EncodedRecord{
            Data: make([]byte, 0, defaultCap*2),
        }
    }}, {New: func() any {
        return &proto.EncodedRecord{
            Data: make([]byte, 0, defaultCap*3),
        }
    }},
    {New: func() any {
        return &proto.EncodedRecord{
            Data: make([]byte, 0, defaultCap*4),
        }
    }},
}

顺便看了一眼好像 zap 下有个类似的 issue

1.4. Heartbeat 更新 #

Cpu/Mem 逻辑问题修正,linux 下新增 host_serialdnsgateway

1.5. Plugin #

新增插件名称合法性校验,shutdown 标识位

1.6. Agent 状态 #

目前看来暂时只有 running 和 abnormal,应该方便集群主动查询

1.7. Main 函数 #

之前通过 DEBUG 来控制 pprof 的方式,现在变更监听 3 个信号

1.8. Deploy 部分更新 #

https://github.com/bytedance/Elkeid/issues/319,将这部分逻辑判断放置 elkeidctl 中了。

signal.Notify(sigs, syscall.SIGTERM, syscall.SIGUSR1, syscall.SIGUSR2)

相比之前更加灵活了,SIGUSR1 用于 pprof 的启停, SIGUSR2 用于强制触发内存回收

2. Collector 插件 #

Collector 插件本次更新除了增加了容器、fatjar 等,还重构改进了代码结构,逻辑非常清晰,推荐大家仔细阅读,在这里就先不赘述 engine 调度的逻辑了

2.1. 新增资产采集 #

app.go 支持了多种应用的采集,包括如下:

ruleMap = map[string]*AppRule{
    "apache2":         apacheRule,
    "httpd":           apacheRule,
    "nginx":           nginxRule,
    "redis-server":    redisRule,
    "rabbitmq-server": rabbitmqRule,
    "grafana-server":  grafanaRule,
    "mysqld":          mysqlRule,
    "postgres":        postgresqlRule,
    "mongod":          mongodbRule,
    "etcd":            etcdRule,
    "prometheus":      prometheusRule,
    "sqlservr":        sqlserverRule,
    "php-fpm":         phpfpmRule,
    "dockerd":         dockerRule,
    "containerd":      containerdRule,
    "kubelet":         kubeletRule,
}

大致的采集逻辑是循环 /proc/ 目录 pid,从进程的根文件系统 /proc/<pid>/root 去读取上述资产。是一个支持区分容器环境的资产采集

2.2. Integrity 采集 #

dpkg / rpm 信息采集,解析还是有一定代码量的,注意里面的 io 限流。跟小黑猪同学沟通,暂时不理解为啥不采集容器内的

2.3. Container 采集 #

一些默认的容器运行时套接字,也是 kubernetes 中的 constants

for _, path := range []string{
    "unix:///run/containerd/containerd.sock",
    "unix:///run/crio/crio.sock",
    "unix:///var/run/cri-dockerd.sock",
} {

dockershim 这种应该情况不用再覆盖

两种 client 的方式,分别为 criClient 以及 dockerClient

2.4. Process 采集 #

Process 采集和之前版本有较大变动,增加了很多如 namespace, status 详细信息,抽象到结构体函数下,代码比之前清晰了许多

2.5. Software 采集 #

Software 主要感兴趣的部分是 Jar 的部分。主要代码在 findJar 部分,根据 Jar 包名称获取 name 以及 sversion,通过在 META-INF/MANIFEST.MF 中读取 Implementation-Version: 来确定 version,这里也是区分容器的

2.6. Service 采集 #

Elkeid 中通过遍历解析获取

var SearchDir = []string{
	"/etc/systemd/system.control", "/run/systemd/system.control", "/run/systemd/transient",
	"/run/systemd/generator.early", "/etc/systemd/system", "/run/systemd/system",
	"/run/systemd/generator", "/usr/local/lib/systemd/system", "/usr/lib/systemd/system", "/run/systemd/generator.late"}

在 Osquery 中,好像是 dbus 相关的请求和解析,具体的后面再详细分析

3. Baseline #

还没看…

暂时还没写完,只看到 collector 的部分,中间应该也有不对的地方,待我慢慢咀嚼…