前言

今年给 D^3CTF 出了一道 Web 一道 Misc,非常可惜都TMD有非预期,出的最差的一回了。

Web 那道题暂且不提,那道题在出题角度来说其实没有任何难度,只是把原项目的Docker镜像稍微重打包了一下交给平台。

今给大伙讲讲我们这道 Misc 网络题,这道题前前后后打包容器和平台联调等等,非常有难度,可能耗费了我将近一周的时间。

早期构思

其实一早就定下了出一道网络相关的 Misc 题的基调,问题在于具体整点什么活上去比较有意思。

早先 D^3 CTF 出过一道网络相关的 Misc 赛题,原题叫 O!!!SPF!!!!!! 那道题非常简单,只需连上 OSPF 就可以拿到 flag 了,没有什么难度。

Soha 每年都会出一些网络相关的新年红包,他的题目也是遵循 CTF 的形式的,所以也在我的参考范围之内。

翻阅了很多,决定题目主体就出一个路由劫持,具体劫持的手法来源于一次真实路由劫持,不过那次环境比较复杂没通,但是我觉得这个思路还可以再利用,就只能出成题这样分享一下。

此外,Potat0 还给我提供了一个 rDNS 的小 trick,作为套娃的一部分,这个 trick 也成为了题目的一部分。为了防止选手写了一坨脚本打完卡在这个 trick 上,因此还设计将这个 trick 前置了一下。

开始出题吧

出一道这样的网络题和传统 CTF 题目不一样,他需要去模拟一个网络环境,首先在本地测试的时候,最容易想到的就是 docker compose,可以添加几个 network,然后将他们绑定到容器上构建一些内网出来。

network

我们大概需要一个这样子的内网环境,简单来说就是三个设备其中一个是路由器,负责两个内网的路由,openvpn 从这里接入并且桥接到 100.64.11.0/24 上。

好吧那就开工吧,把这个网络拓扑写进 docker compose

以下 docker compose 并非最终版本,有意留下了一些错误,后文会纠正

version: '3'
name: ospf-enhanced

services:
  router:
    image: router
    build: ./router
    privileged: true
    ports:
      - 1194:1194
    networks:
      nw0:
        ipv4_address: 100.64.11.1
      nw1:
        ipv4_address: 100.64.12.1
  
  client:
    build: ./client
    image: client
    privileged: true 
    environment:
      - authkey=Jy3aPmrX465PURdZ
      - gamekey=r5UCJsfs4LDeDFqz
    cap_add:
      - NET_ADMIN
    networks:  
      nw1:
        ipv4_address: 100.64.12.2

  target:
    build: ./target
    image: target
    privileged: true   
    environment:
      - authkey=Jy3aPmrX465PURdZ
      - gamekey=r5UCJsfs4LDeDFqz
      - flag=d3ctf{1_10ve_n4tvv0rk1n9_4nd_R0uting!!!}
    networks:
      nw0:
        ipv4_address: 100.64.11.2


networks:
  nw0:
    ipam:
      config:
        - subnet: 100.64.11.0/24
          gateway: 100.64.11.254

  nw1:
    ipam:
      config:
        - subnet: 100.64.12.0/24
          gateway: 100.64.12.254

由于容器里要跑 openvpn,要跑 bird 所以需要一点特权,所以早期全部开成特权容器了,这里给他们模拟设计了三个设备两个子网,其中一个设备是路由器,做两个子网之间的路由。

其中 router 容器是最麻烦的,回头要在上面跑 openvpn,跑 bird,开内核转发,做桥接等等操作,其实这都是需要特权的操作。

再看看 target 容器,这就是题目中的 flagserver 要跑的地方,还有一个 client,是内网环境里与 flagserver 不断交互的另一个设备,这两个比较简单,只需要去跑一下对应的程序就没有大问题了。

最后,我们还需要把这两个容器的网关指向 router,主要是而非他原本 docker 给的出网网关,这里也需要一点特权,总之网络的一切都需要特权或者说CAP_NET,三个容器没有一个能运行在完全没有特权的环境下。

最困难的容器

我决定从最困难的容器开始入手,对于 router 这个容器,我决定先配一个 openvpn 吧,具体证书的签名过程也是对着网上的教程来的,就不阐述了,当他们拿到对于的配置文件后,对容器环境还有一点特殊的要求需要我们去处理一下。

一个是 /dev/net/tun,虽然我们在docker里我们完全可以写一个--device把这个 device 给映射到容器内部给 OpenVPN 访问,但是题目平台上是并没有提供这个参数的,所以不能走这个方法。

所幸的是我在 OpenVPN 的镜像的 dockerfile 里发现了可以通过 mknod /dev/net/tun c 10 200 来创建一个这样的 /dev/net/tun

mknod 这个命令我上次听说的时候是我操作系统实验的时候,这次我们用他根据主次设备号来创建一个 tun 设备文件,关键在于,当我们给到特权容器的时候,其实 mknod 的权限是有了,tun 这个设备时内核提供的,这里只是相当于做了一个链接,所以是可以的。

第二个困难的点在于桥接,在不知道用平台的内网功能时平台上给的网卡名字会是什么,还得拿这个去搓一个桥接脚本,这个脚本就没那么好写了,不过多试试还是可以写出来的。

if [ ! -c /dev/net/tun ]; then
    mkdir -p /dev/net
    mknod /dev/net/tun c 10 200
    chmod 600 /dev/net/tun
    echo "Created tun device"
fi

sysctl -w net.ipv4.conf.all.forwarding=1
sysctl -w net.ipv4.conf.all.rp_filter=0
sysctl -w net.ipv4.conf.default.rp_filter=0

br="d3br0"
eth=$(ip addr show to 100.64.11.1 | grep -oP '(?<=: )[a-z0-9A-Z_-]*(?=@)')
tap="tap0"

openvpn --mktun --dev $tap
brctl addbr $br
brctl addif $br $eth
brctl addif $br $tap
bridge link set dev $eth learning off

ifconfig $tap 0.0.0.0 promisc up
ifconfig $eth 0.0.0.0 promisc up
ifconfig $br 100.64.11.1 netmask 255.255.255.0 broadcast 100.64.11.255
ip route add default via 100.64.11.254

OpenVPN 的文档确实很好用,上面这段脚本八九成都是抄的那个文档。

脚本跑在容器的 entrypoint.sh 里,先是检测并创建 tun 设备,然后开启内核转发,关闭源地址过滤,再创建 tap0 接口,全改混杂模式然后给他们做个桥接。

这部分做完发现能 ping 通 router 两个接口的 IP 了,非常对头

此外还需要部署一下 bird2 用来跑 OSPF,这是选手做路由劫持的基础

bird2 其实很好装,只需要 apt install 一下就可以了,后续用service bird start来起 bird2 给他挂在后台,前台我们起 OpenVPN

log "/var/log/bird.log" all;
debug protocols all;

protocol device {
}

protocol kernel {
        ipv4 {                # Connect protocol to IPv4 table by channel
            import all;       # Import to table, default is import all
            export all;       # Export to protocol. default is export none
        };
}

protocol ospf v2 {
      ipv4 {
              import all;
              export all;
      };
      area 0 {
              interface "*" {
                      type ptp;         # Detected by default
                      cost 10;                # Interface metric
              };
      };
}

写一下 bird 配置文件吧,由于不太清楚平台上的网卡名,写一套 shell 自动替换又有点麻烦,就写得比较敷衍了写了一个 interface "*"

BIRD_RUN_USER=root
BIRD_RUN_GROUP=root
#BIRD_ARGS=

此外呢还需要改动一下 /etc/bird/envvars ,这里指定了 bird 这里指定了 bird 运行使用的用户和组还有参数,方便起见就直接给到 root 权限了,后续题目出完了可以再看情况调整一下

剩下两个容器

来到了熟悉的 Golang 开发环节,愉快开发完 flagserver 和 client 的同时感谢 copliot 让我写的巨快

可惜只有“快”,copliot 自动补全还给我埋下了一个非预期的坑,后续在 WriteUp 里会提到这个非预期是怎么回事

#!/bin/bash
ip route delete default
ip route add default via 100.64.11.1
/server

这两个容器的 entrypoint.sh 都很简单,在跑程序之前把内网默认网关改一下就可以了,还有就是需要 CAP_NET.

网络调试

看出题部分的一顿操作,我觉得大多数人都会感觉,已经完全没有问题了,测试只是确保他没问题的一部分罢了。

但是这道题的折磨才刚刚开始。

当你在本地起这个 compose 的时候,然后 OpenVPN 连上去调试的时候,你会发现,你 ping 不通 100.64.11.2, 这是我遇见的第一件诡异的事情。

ICMP 报错报的是 Host Unreachable,是本机爆的这个错

盲调没有头绪,一点都没有,于是不得不给所有容器里装了一个 tcpdump 来观察一下流量,这一看会发现,事情变得更诡异了。

graph LR
  laptop -- 1 --> router
  router -- 2 --> flagServer
  flagServer -- 3 --> router
  router --4--x laptop

大概画了一下,发 ICMP 包之前需要找到对面的 MAC 地址的,所以这是我的 ARP 包的路径,就是流量一路过去是正常的,但是 ARP Reply 在 router 往 laptop 这里往回转发的时候,他没转发过去。

router的laptop和flagServer都在一个桥上,能出问题的话就是这个桥出问题。

但是一个bridge,简简单单的bridge又能出什么问题呢?

关键流量已经定位出来,来看这段流量吧,不妨可以先尝试自己思考一下到底发生了什么。

tcpdump-network

看出问题了吗?

让我来揭晓答案,首先我需要指出的是,上图的流量并不是按照时间顺序排列的。

我们来逐帧分析吧

  1. 09:46:20.968080 OpenVPN 收到了流量,没有问题
  2. 09:46:20.968191 tap0 接口收到了 ARP 流量,并且也出现在了 br0 这个桥上,没有问题
  3. 09:46:20.968200 br0 把 ARP 广播转发给了 eth0,没有问题
  4. 09:46:20.968208 这个流量有点诡异,eth0 居然收到了刚刚发出去的 ARP 广播?
  5. 09:46:20.968213 br0 把刚刚收到的 ARP 广播包转发给了 tap0 正常操作
  6. 09:46:20.968219 eth0 收到 ARP 回包了,但是这是最后一个包了 router 并没有转发

全流程梳理下来,唯一可疑的是莫名奇妙出现的 ARP 广播,这个时候应该怀疑网络内有环了。

但是从我们的配置上看 docker network 就一个桥,如果这个桥是真的纯粹的桥,桥了这两个设备外再无其他,我觉得不应该出现环至少。

但是很可惜,docker 的 bridge 并不是这样的一个东西,他还有一个网关,用于暴露端口和外部通信。

对于 bridge driver 的网关,我只能说是所知甚少,网上也几乎没有相关的文章,毕竟哪里有人会尝试挂一个 L2 隧道进 docker network,我搜到的全是一些 Linux 内核和 OpenVPN 配置的问题,这显然与我遇到的问题无关。

翻看 docker network 的文档后,他说

internal

By default, Compose provides external connectivity to networks. internal, when set to true, allows you to create an externally isolated network.

是不是打开 internal 后这个网关就会消失呢?

很遗憾,我不知道他消失了没,但是他引入了一个新的问题,在这个模式下,来在外部设备的 mac 地址转发进 docker network 的时候会被直接 DROP 掉,另一边完全没收到这个包。

docker 的 bridge driver 的实际网络拓扑可能比我想象中的更诡异。

经过反复翻看文档,Docker 还说了一句

macvlan: Macvlan networks allow you to assign a MAC address to a container, making it appear as a physical device on your network. The Docker daemon routes traffic to containers by their MAC addresses. Using the macvlan driver is sometimes the best choice when dealing with legacy applications that expect to be directly connected to the physical network, rather than routed through the Docker host’s network stack. See Macvlan network driver.

其中 appear as a physical device on your network 让我很是心动,我确实需要一个表现得和物理设备物理网络一样的 network driver。

networks:
  nw0:
    driver: macvlan
    ipam:
      config:
        - subnet: 100.64.11.0/24
          gateway: 100.64.11.254

  nw1:
    driver: macvlan
    internal: true
    ipam:
      config:
        - subnet: 100.64.12.0/24
          gateway: 100.64.12.254

很幸运,虽然他依然要求指定一个 gateway,或许依然存在一个 gateway,但这个 driver 下没有之前那个诡异的 broadcast 现象了。

把 network 部分修改为上面的内容后,我就 ping 通 flagserver 了。

以后有空可以看看 docker 的源码,看看到底发生了什么,至少现在是暂时没问题了,我现在就是一个非常赶时间的状态。

平台联调

我们的平台 Kardinal-Pro,是早年开源的 Cardinal 的商业版本, 商业版本最大的变化从我一个出题人的角度看就是从 docker 在线靶机变成了基于 k8s 实现在线靶机。

好在是 Vidar 学长写的不用担心平台商业版本授权问题。

上了 k8s 意味着我前面写的一坨 docker compose 其实是没法在上面跑的,我必须把这一切翻译成平台的配置文件格式。不过非常幸运的是,平台那边内网题目的网络组件确实就是 macvlan,我之前在本地出的问题他并不会出现在远程环境上。

我写了一份配置文件,然后把题目镜像传到平台上面去,顺利的话题就出好了但是顺利不了一点。

测了一边题目发现我劫持劫不到东西,我问问魔皇(平台运维),这其实他也只能给我发发 log,看看网卡一类的,他甚至还在休假,这个信息完全不足,我没法看上面的题目到底什么情况。

RUN apt-get update \
    && apt-get install -y openssh-server \
    && mkdir /var/run/sshd \
    && echo 'root:root' | chpasswd \
    && sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

想了点比较偏门的办法,给 pod 全装上 ssh 这样我就可以上去调试了

net2

上去之后又看到一个诡异的情况,这个平台两个不同的内网 ne1 和 net2,但是为什么我在 net1 发的广播包,我能在 net2 收到?

这个时候其实就开始怀疑平台的内网是不是没有做二层隔离,按道理讲 macvlan 的二层是隔离的,我打不通是不是因为平台这个问题扰乱了 ARP 表。

随后催平台那边加二层隔离,这一催就是三天,实话实说这是有点技术难度的,又正赶上他们人事调整,最后感觉,不太指望得不上。

QEMU 启动

这时候是 4 月 24 号,比赛前三天,时间已经很紧迫了,我不敢再等平台了,那我唯一拯救这道题的办法就是开个 QEMU 然后再在 QEMU 里装 docker 再把我在本机测通了的题目丢到 QEMU 的 Docker 里去跑。

最早的想法是现成的 pve 上开个机器,上去配好后把磁盘镜像给拉下来,qemu-system 启动一下。

我其实不会用 QEMU 只能找个 PWN 手问问,他们 PWN 环境内核题目不得起手一个 QEMU。

但是拿到的回复我并不太满意,他们把 内核, initrt, rootfs 什么的都分开跑,我只能说非常有PWN的感觉,但是他们并不知道直接给你一个镜像该怎么办,再加上当时安装的时候选了 LVM 分区,这下子就更雪上加霜了。

好吧,那还是相信一次我们的 PWN 手吧。

qemu-system-x86_64 -nographic -m 1024 -kernel linux -initrd initrd.gz -drive file=d3ctf-amd64.img -append "console=ttyS0"

找了个debain,这次还是比较顺利的,安装完成后就可以抛开 kernel 和 initrd 直接起这个镜像了,再把 docker 什么的都装进去,镜像拷进去,然后就拿着我本地在用的 compose 去跑就可以了。

qemu-system-x86_64 -nographic -m 512 \
    -drive file=d3ctf-amd64.img \
    -netdev user,id=net1,restrict=yes,hostfwd=tcp::1194-:1194 \
    -device virtio-net-pci,netdev=net1

本地这个 QEMU 测通后呢就得把这个 QEMU 打包进 docker 了,这边除了镜像打包出来巨大(4G)外,前方已经只有坦途了。

FROM ubuntu:latest

RUN apt-get update \
    && apt-get install -y qemu-system-x86 \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY ./d3ctf-amd64.img /app/d3ctf-amd64.img

WORKDIR /app

CMD qemu-system-x86_64 -nographic -m 512 \
    -drive file=d3ctf-amd64.img \
    -netdev user,id=net1,restrict=yes,hostfwd=tcp::1194-:1194 \
    -device virtio-net-pci,netdev=net1

有一个小插曲,qemu 比较占资源,爆了默认的单靶机资源上限,不过这个魔皇给了足够的反馈了,调整一下资源限制即可。

放眼前方,尽是坦途!

后记

下次不出了

WriteUp

预期解

看看题目介绍,这明摆着就是让你去 traceroute 2a13:b487:11aa::d3:c7f:2f 不过也没那么容易,你需要把 TTL 改大一点,不然是 trace 不到的。

45. bd23ff4fb2b7f8e49200c3801151663d                                                       0.0%   113  135.8 138.0 116.0 155.6  12.0
46. 0dcc848e1b075bd4dcb4fd32712559de                                                       0.0%   113  116.8 117.0 116.7 124.9   0.9
47. 207bb8777d7fbedcc7e83c48c31b2bda                                                       0.0%   113  132.4 137.9 116.0 155.2  11.8
48. 172602638c6a7c8fe61b4ff086c47690                                                       0.0%   113  116.1 116.1 116.0 117.8   0.3
49. 1c9ec648853b2bc316b58923505cbe6b                                                       0.0%   113  116.2 116.2 116.0 121.3   0.7
50. 902c1f0809152f0a868c4cda66df19ad                                                       0.0%   113  150.3 138.0 116.1 158.0  11.6
51. d0b1da1c5e7fa0af81843735cefcf132                                                       0.0%   113  116.1 116.3 115.9 121.1   0.8
52. 4aea04f4b1076a844fbf5f69e2a7c420                                                       0.0%   113  150.8 138.7 116.7 163.4  11.6
53. 8d2dc7d91e3f6fe5ccd0fccd280aadc2                                                       0.0%   113  116.0 116.2 116.0 119.5   0.5
54. 5cd04243410e2e3372cf91a8395b4d1a                                                       0.0%   113  150.9 137.8 116.0 158.8  11.3
55. b70828d9f6a7a2aff81b0127af493d23                                                       0.0%   113  116.0 116.1 115.9 118.2   0.3
56. 7305c7d7c018bbb1a557fee33b7372d5                                                       0.0%   113  152.1 137.6 116.0 155.7  11.3
57. aca7bfae5d337bdcc196e37dc363789d                                                       0.9%   112  152.4 137.4 116.0 156.3  11.4
58. 73c0791483a0b208f538892cf61fcf11                                                       0.0%   112  116.8 116.9 116.7 119.1   0.3
59. 7d1ee65385eef03d533a94b03324bd01                                                       0.0%   112  152.7 138.2 116.7 158.4  11.4
60. aaf26d2a066ce6356487ead9551fda4c                                                       0.0%   112  116.8 117.0 116.7 129.1   1.3

上面就是你的 OpenVPN TLS Static Key 了,它位于 2a13:b487:11aa::d3:c7f:20 和 2a13:b487:11aa::d3:c7f:2f 之间

把他写到附件的配置文件里预留的地方就可以了

连上题目环境,就如题目所说,你可以发现一个 OSPF 并且连上他,拿下路由信息,然后你可以扫扫网段

  • 100.64.12.2
  • 100.64.12.1
  • 100.64.11.1
  • 100.64.11.2

100.64.11.2 开了 18080 端口鉴定为 flagServer

Route Hijack

protocol static {
        ipv4 {
            import all;
            export all;
        };
        route 100.64.11.2/32 via 100.64.11.16;
}

protocol ospf v2 {
      ipv4 {
              import all;
              export all;
      };
      area 0 {
              interface "tap0" {
                      type ptp;         # Detected by default
                      cost 10;          # Interface metric
              };
      };
}

我们有 OSPF 是可以做路由劫持的,安装一个 bird2 ,配置一下打一发路由劫持,劫个 100.64.11.2 看看,这个不知道是什么东西。

你会发现他在给 flagServer 发消息,看过 flagServer 代码的这个时候都会意识到可以打个中间人,拿下所有的签名。

MITM Attack

sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv4.conf.tap0.forwarding=1
sysctl -w net.ipv4.conf.tap0.rp_filter=0

开一下 Linux 内核转发,把劫持过来的流量转发回去,这个中间人劫持就起来了,虽然只是单向的劫持

打开你的 wireshark 开始抓包

一个小 trick,这个时候你 wireshark 会有一样的流量进来了又出去,可以根据 mac 地址过滤

拿下流量但是你不知道这么多的签名具体对应的是哪个明文,不过题目在这里是设计过的。

我们每一段明文的长度都不一样,你可以从 TCP ACK number 推算上一个发送的明文具体是哪一段,这样你就拿下了明文和签名的所有对应关系了。

打开你的 pwntool,和 flagServer 交互,拿下。

非预期

        if slices.Compare(rec[:], Sign([]byte(msg))) != 0 {
            _, err = c.Write([]byte("Wrong Hash\n"))
            if err != nil {
                log.Println(err)
                return
            }
        }

return 写错地方了,比较失败并不总是 return

平台小彩蛋

  1. 如果你去 traceroute 附件服务器,你会发现路上有两个公网路由的 rDNS 分别是 welcome.to 和 d3c.tf
  2. 如果你去看附件服务器的域名解析,你会发现他解析出来的 IP 最后两位分别是 66 77 88 99
  3. 如果你去查看这道题 traceroute 的 IP 的 ASN 你会发现这个 IP 属于 Potat0 Network。