diff --git a/content/posts/five-dollars-instead-of-an-open-port.md b/content/posts/five-dollars-instead-of-an-open-port.md
index bcf2b59..e1807fa 100644
--- a/content/posts/five-dollars-instead-of-an-open-port.md
+++ b/content/posts/five-dollars-instead-of-an-open-port.md
@@ -2,16 +2,16 @@
title: "Five dollars a month instead of an open port"
date: 2021-03-16T21:50:00+07:00
draft: false
-description: "Every home service you add tempts you to forward one more port on a modem you do not own. Renting a small server as a VPN hub does not remove the internet-facing listener; it replaces several you cannot patch with one you can."
-summary: "The honest reason the homelab exists. Port forwarding on the ISP modem accumulates: one listener per service, on a box you do not administer. A 5 USD virtual server does not make the listener disappear - it moves it somewhere you control."
+description: "Every home service you add tempts you to forward one more port. Renting a small server as a VPN hub does not remove the internet-facing listener; it replaces a growing set of them, some on appliance firmware nobody patches, with one you administer."
+summary: "The honest reason the homelab exists. Port forwarding accumulates: one internet-reachable service per forward, several of them on hardware nobody will patch. A 5 USD virtual server does not make the listener disappear - it moves it somewhere you control."
tags: ["homelab", "networking", "vpn", "security"]
---
> *Archive note. Written in March 2021 in Hanoi, for a blog I have since retired, and the earliest post in that blog's homelab sequence - everything else grew out of it. Prices and products are of their time, and the arrangement itself did not last long: I moved to the Netherlands within about a year, and a VPN hub hosted by a Vietnamese carrier stops being the obvious choice once you live on the other side of the planet from it. What follows chronologically is [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/).*
-**TL;DR.** You want to reach a machine at home from outside. You can forward a port on the ISP modem, or you can rent a VPS - a virtual private server, a small Linux machine rented by the month - and run a VPN on it that everything dials out to. Both designs put a process on the internet where strangers can reach it. The difference is how many, and whose. Forwarding grows one internet-facing listener per service, on firmware you did not choose and cannot patch. The VPN hub has exactly one listener, on a machine you administer, and that count does not move when the service list grows. The reason this matters is not the first service. It is the fourth.
+**TL;DR.** You want to reach a machine at home from outside. The textbook answer is to forward a port on the ISP modem. On my line that was not available at all - it sat behind carrier-grade NAT, with no public address to forward from - but the argument against it stands even where it is. What does work is renting a VPS - a virtual private server, a small Linux machine rented by the month - and running a VPN on it that everything dials out to. Both designs put a process on the internet where strangers can reach it. The difference is how many, and whose. Forwarding grows one internet-reachable service per forward, some of them on appliance firmware nobody will ever patch. The VPN hub has two, both on a machine you administer: WireGuard, and the SSH you administer it with. That count does not move when the service list grows. The reason this matters is not the first service. It is the fourth.
-Concretely: the VPN was WireGuard, deployed with [Algo](https://github.com/trailofbits/algo) - Ansible scripts from Trail of Bits that stand up a personal WireGuard or IPsec server with hardened defaults - on a virtual machine rented from Viettel IDC - a Vietnamese cloud provider whose small instances sit in much the same bracket as a DigitalOcean droplet, and the hosting arm of the same carrier that supplied the line at home. Both of those choices do more work in the argument than they look, so they get their own sections.
+Concretely: the VPN was WireGuard, deployed with [Algo](https://github.com/trailofbits/algo) - Ansible scripts from Trail of Bits that stand up a personal WireGuard or IPsec server with hardened defaults - on a virtual machine rented from Viettel IDC - a Vietnamese cloud provider, and the hosting arm of the same carrier that supplied the line at home. Both of those choices do more work in the argument than they look, so they get their own sections.
## The network everyone starts with
@@ -19,15 +19,15 @@ Home networks are not standardised. Two flats with identical floor plans end up
That is enough for what most people need. Web, streaming, games with low latency. Nothing about it is wrong.
-Then one day you want to reach the PC at home while you are not at home, and to do it safely.
+Then one day you want to reach the PC at home while you are not at home, and to do it safely. And you discover that the textbook answer is not on the menu.
## The ready-made answers
The hosted remote-desktop tools do this job and do it well:
-- **TeamViewer** is free only while your use counts as personal, and the moment it decides otherwise you are paying. The figure I had in mind at the time was 24.90 USD. I did not write down whether that was per month or per year, and I am not going to reconstruct it now - which means I cannot honestly claim the VPS was cheaper, only that it was not obviously more expensive and did considerably more.
+- **TeamViewer** is free only while your use counts as personal, and the moment it decides otherwise you are paying. The figure I had in mind at the time was 24.90 USD. I did not write down whether that was per month or per year, and without that the comparison cannot be made at all: against a monthly licence the VPS at 60 USD a year is about a fifth of the price, and against an annual one it is more than double. I am not going to pick the branch that flatters the decision.
- **AnyDesk** is the same shape with fewer features.
-- **Chrome Remote Desktop** is genuinely free, and it is what I would still point a non-technical person at. It also renegotiates to a direct route instead of hauling traffic through Google's servers. Its catch is small: it runs in Chrome, so some key combinations never reach the far machine - press Ctrl-W expecting to close a window over there and you close a tab over here.
+- **Chrome Remote Desktop** is genuinely free, and it is what I would still point a non-technical person at. It also tries to negotiate a direct peer-to-peer route rather than hauling traffic through Google's relays, though behind carrier-grade NAT - which my line was - it falls back to relaying, and the signalling goes through Google either way. Its catch is small: it runs in Chrome, so some key combinations never reach the far machine - press Ctrl-W expecting to close a window over there and you close a tab over here.
So I should be straight about the decision, because the cost framing flatters it. Chrome Remote Desktop was free and solved the stated problem. Paying 5 USD a month to escape a hotkey collision would be absurd. What I actually wanted was a general-purpose machine with a public address, which happens to solve remote access as a side effect and then keeps being useful. The remote desktop was the excuse, not the reason.
@@ -35,25 +35,35 @@ Buying domestically mattered more than the price did, though the price was compe
Point everything at a VPN running on that VPS and the devices stop being on separate networks. They address each other as though they shared a LAN, which means the built-in remote-desktop protocols - RDP on Windows, VNC elsewhere - work with no product tier involved. Laptop, phone, tablet, all reaching the machines at home.
-Worth naming what that does not buy: the VPS is a third party in the path, and unlike Chrome Remote Desktop's direct route, it is in the path for every packet. I traded a company I do not control for a rented box I do, which is a real improvement in authority and a real regression in hop count.
+Worth naming what that does not buy: the VPS is in the path for every packet, always, where Chrome Remote Desktop is in the path only when it cannot get out of it. I traded a company I do not control for a rented box I do, which is an improvement in authority and a regression in hop count.
## What is actually running on it
WireGuard, put there by Algo.
-Algo is worth naming rather than leaving as "a VPN", because half of what makes this design defensible is which server you expose. It is Ansible, run from your own laptop against a fresh cloud instance, and its opinions are the point. It declines to install OpenVPN or Tor. It drops legacy cipher suites and protocols - no L2TP, no IKEv1, no RSA. It does not put its security on TLS. Users are declared in a config file before deployment and each one gets a generated WireGuard config and a QR code, so adding a phone is scanning a square rather than editing anything on the server. It builds on an Ubuntu LTS with unattended security upgrades switched on.
+Algo is worth naming rather than leaving as "a VPN", because half of what makes this design defensible is which server you expose. It is Ansible, run from your own laptop against a fresh cloud instance, and its opinions are the point. It declines to install OpenVPN or Tor. It drops legacy cipher suites and protocols - no L2TP, no IKEv1, no RSA. It does not put its security on TLS. Users are declared in a config file before deployment and each one gets a generated WireGuard config and a QR code, so adding a phone is scanning a square rather than editing anything on the server. It builds on an Ubuntu LTS - long-term support, the flavour that receives five years of security patches - with unattended security upgrades switched on.
-The WireGuard part matters more than the automation. WireGuard does not reply to packets that fail authentication. A port scan of the VPS finds nothing on that port, because the server says nothing to anyone who cannot already prove they belong. That is a materially different exposure profile from a forwarded port on a modem, where whatever is behind it answers and identifies itself to anybody who knocks. The listener exists, and it is silent.
+The WireGuard part matters more than the automation. WireGuard does not reply to packets that fail authentication, so it offers no banner, no version, and no handshake to anyone who cannot already prove they belong.
+
+That is worth stating carefully rather than as a magic trick. A scanner still learns something: a UDP port that returns silence while the rest of the host returns port-unreachable is a fingerprint, and anyone looking for WireGuard will recognise it. What it does not get is an identified service to look up in a vulnerability database. Compare that with a forwarded port, where the thing behind it answers, names itself, and frequently gives its version. The listener exists and is findable; it just refuses to introduce itself.
It is also small. WireGuard's kernel implementation is a few thousand lines against the hundreds of thousands in the IPsec and OpenVPN stacks, and that ratio is the whole reason it is possible to feel relaxed about running one on a public address.
## The port forward, and why I did not
-The obvious objection: run the VPN server on the ISP modem, forward one port, save the 5 USD. If the contract has no static IP, dynamic DNS will give you a free name that tracks the address as it moves. People do this and it works.
+The obvious objection: run the VPN server at home behind the ISP modem, forward one port to it, save the 5 USD. If the address changes, dynamic DNS gives you a free name that tracks it. People do this and it works.
+
+Be precise about what a forward actually does, because I was sloppy about this for years. It is a translation rule on the modem, not a service running there. The modem listens for nothing; it rewrites the destination and hands the packet to a machine on the inside. The exposed process is on that machine.
+
+Which is the part that matters. Some of those machines you patch: the VMs, the PC. Some you cannot. A camera appliance runs whatever firmware its vendor last felt like shipping, and no update you can apply will change that. The forward makes it reachable from the entire internet, and the modem in the middle is a device you did not choose and do not administer, so you cannot even put a useful rule in front of it.
+
+So the exposure is not "a listener on the ISP's box". It is one internet-reachable service per forward, spread across machines of mixed provenance, some of which will never receive another update. That is the argument, and it is the reason I would still do this somewhere that hands out a routable address without being asked.
-The catch is that it works only where the ISP lets you configure the modem at all. Plenty of contracts ship locked firmware, and where that is true this alternative does not lose on security, it simply is not available. Where it is available, here is what it costs.
+There was a second reason, and in my flat it was decisive: there was no address to forward from. The line sat behind carrier-grade NAT, CGNAT for short. The ISP hands out a private address on the WAN side of your modem and shares one real public address among a great many subscribers. Nothing outside can address your line, because your line has no address of its own.
-Forwarding a port puts a process in listening state, reachable from the entire internet, on a device you did not choose, do not administer, and cannot patch. Your ISP picked it and ships its firmware on its own schedule. When a vulnerability lands in that firmware, your options are to wait.
+That is worth separating from the usual advice, because dynamic DNS is normally offered as the answer to "my IP changes" and people reach for it here too. It does not help. Dynamic DNS keeps a name pointed at an address that is yours but moves. Under CGNAT the address is not yours at any moment, and pointing a name at the carrier's shared address sends strangers to the carrier, not to you. The modem will still show you a port-forwarding page. It will still accept the rule. The rule will do nothing.
+
+Outbound connections work fine under CGNAT - that is the whole point of it - so a tunnel dialled outward to something with a real address was the only shape available anyway. The security argument is why I would choose it; the NAT is why I could not have chosen otherwise.
Since I already owned a domain, I bought the VPS and pointed a DNS record at it through Cloudflare, so I had a name rather than an address to remember. That is all Cloudflare is doing here - a VPN is not HTTP and does not travel through a caching proxy.
@@ -73,33 +83,37 @@ The list does not terminate. Each addition, alone, is one small hole and a reaso
Two honest caveats before the conclusion.
-**The count grows only if you let it.** Three of those four services speak HTTP. One reverse proxy on one VM fronts all of them behind a single port, and now the growth is in proxy configuration rather than in forwarded ports. That is a real answer to the problem and it is what many people do. It also leaves you one internet-facing listener on a VM you patch - which, as the next section admits, is exactly where the VPN hub lands. The two designs are closer relatives than this post originally implied.
+**The count grows only if you let it.** Three of those four services speak HTTP. One reverse proxy - a single web server that accepts every request and forwards it to whichever internal service the hostname belongs to - on one VM fronts all of them behind a single port, and now the growth is in proxy configuration rather than in forwarded ports. That is a real answer to the problem and it is what many people do. It also leaves you one internet-facing listener on a VM you patch - which, as the next section admits, is exactly where the VPN hub lands. The two designs are closer relatives than this post originally implied.
+
+**Not everything can join a VPN.** The camera is the first example in the escalation above, and a camera appliance almost certainly cannot run a VPN client, nor can most home-automation hubs. Those devices never join the tunnel. They stay on the LAN and speak to nothing outside it; what joins the tunnel are the clients, and the tunnel gives those clients a route onto the LAN where the appliances already live.
-**Not everything can join a VPN.** The camera is the first example in my own escalation, and a camera appliance almost certainly cannot run a VPN client, nor can most home-automation hubs. Those devices never join the tunnel. They stay on the LAN and speak to nothing outside it; what joins the tunnel are the clients, and the tunnel gives those clients a route onto the LAN where the appliances already live. The appliance does not need to participate for this to work, which is fortunate, because it cannot.
+That route does not appear by itself, and this is the step Algo does not do for you. Out of the box it builds a road-warrior server: clients dial in and reach the internet, not each other's home networks. To reach the camera you need one always-on machine at home to be a peer as well, carrying the LAN prefix in its allowed addresses and routing for the subnet, with the matching prefix configured on the client side. A machine that is already running anyway is the natural choice, which is the recurring theme of this entire homelab. The appliance still does not participate, which is fortunate, because it cannot.
## What the hub actually buys
-The VPN hub does not remove the internet-facing listener. It has one: the VPN server itself, on a public address, which is the whole point of it being reachable.
+The VPN hub does not remove the internet-facing listener. It has at least one: the VPN server itself, on a public address, which is the whole point of it being reachable.
+
+I spent this entire post arguing against internet-facing services, so let me state the real claim rather than the flattering one. The gain is not zero exposure, and it is not even down to one: Algo leaves SSH running, because that is how you administer the box. Call it two, of which one is silent and the other is the conventional hardened thing everybody already runs.
-I spent this entire post arguing against internet-facing listeners, so let me state the real claim rather than the flattering one. The gain is not zero exposure. It is that the count stops tracking the service list, and that the one remaining listener sits on a machine I chose, administer and can patch the same day a vulnerability is published. Four listeners on ISP firmware become one listener on my own box, and the fifth service adds none.
+The gain is that the count stops tracking the service list. Four reachable services across machines of mixed provenance become two on a single box I chose, administer, and can patch the day a fix ships. The fifth service adds none, and neither does the tenth.
## What it costs, stated plainly
Three bills come with this that the 5 USD does not cover.
-**You now run a server.** The modem was condemned for being unpatchable; the replacement is patchable and therefore must be patched. Algo takes the worst of that away by building on an Ubuntu LTS with unattended security upgrades on by default, which is more maintenance than most home routers ever receive. It does not take all of it away: unattended upgrades cover distribution packages, not the day the LTS reaches end of life and the whole instance needs rebuilding. A VPN server abandoned across that boundary is worse than the port forward it replaced.
+**You now run a server.** The modem was condemned for being unpatchable; the replacement is patchable and therefore must be patched. Algo takes the worst of that away by building on an Ubuntu LTS with unattended upgrades on by default, which is more maintenance than most home routers ever receive. It does not take all of it away: unattended upgrades cover distribution packages, not the day that support window closes and the whole instance needs rebuilding. A VPN server abandoned across that boundary is worse than the port forward it replaced.
**It is a single point of failure.** A forwarded port fails one service at a time. A hub fails everything at once, including the route you would use to get back in and fix it. That is a genuinely worse failure mode and the only mitigation is a second way in, which costs either money or the exact exposure you were avoiding.
diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md
index ef58b14..27a4a0d 100644
--- a/content/posts/merklision-merkle-second-preimage.md
+++ b/content/posts/merklision-merkle-second-preimage.md
@@ -1,19 +1,21 @@
---
-title: "Merklision: composing two Merkle bugs into 2047 collisions"
+title: "Merklision: composing two Merkle bugs into 2048 collisions"
date: 2020-10-24T17:00:00+07:00
draft: false
-description: "A SVATTT 2020 qualifier challenge asked for 2020 distinct inputs that all hash to one Merkle root. Neither of the two implementation gaps gets close on its own. Alternating between them does, because each one's output is the other one's input."
-summary: "The challenge wanted 2020 distinct strings colliding on one Merkle root. A second-preimage attack gives about ten. An odd-length duplication bug gives about ten more. Alternating the two gives 2047, which I measured rather than derived."
+description: "A SVATTT 2020 qualifier challenge asked for 2020 distinct inputs that all hash to one Merkle root. A second-preimage rewrite gives ten, an odd-length padding bug gives a thousand, and alternating between them gives one answer at every length up to 2048, because each rule's output is the other rule's input."
+summary: "The challenge wanted 2020 distinct strings colliding on one Merkle root. A second-preimage attack gives about ten. An odd-length duplication bug gives about a thousand. Alternating the two gives one answer per length up to 2048. My 2020 solver reported 2047, and the one it missed took five years to notice."
tags: ["ctf", "cryptography", "merkle-tree", "writeup"]
---
-> *Archive note. Written in October 2020 for a blog I have since retired, and reproduced here as it stood. The challenge server is long gone and nothing here has been re-verified against a current implementation.*
+> *Archive note. Written in October 2020 for a blog I have since retired. The challenge server is long gone, but the challenge source and my solver survive in a gist, and this version has been corrected against both, with the solver re-run in 2025. Several counts in the original were wrong, and each correction is marked where it occurs.*
-**TL;DR.** A CTF challenge hands you a random string of `n` characters and asks for 2020 other distinct strings whose Merkle hash matches. A textbook second-preimage attack on a Merkle tree yields about `log2(n)` collisions, roughly ten. A second bug - odd-length input silently duplicating its last element - yields about ten more. Neither is enough, and twenty is not 2020. What closes the gap is that the two rules compose: each one's output is a valid input to the other, so you alternate them up the tree instead of picking one. Choosing `n = 1025`, which makes every level odd, produced 2047 collisions in eleven seconds. I measured that number rather than deriving it, and the distinction matters - see below.
+**TL;DR.** A CTF challenge hands you a random string of `n` characters and asks for 2020 distinct strings whose Merkle hash matches. A textbook second-preimage attack on a Merkle tree yields about `log2(n)` collisions, roughly ten. A second bug - odd-length input silently duplicating its last element - yields about a thousand, half of what is needed. What closes the gap is that the two rules compose: each one's output is a valid input to the other, so you alternate them up the tree instead of picking one. Choosing `n = 1025`, which makes every row odd down to the final pair, gives one answer at every length from 1 to 2048. My solver printed 2047 on the night, and where the missing one went is the most instructive part of this writeup.
## The challenge
-This was the crypto challenge in the SVATTT 2020 online qualifier - Sinh vien voi An Toan Thong Tin, the ASEAN Student Contest in Information Security - authored by `ndh`. I had played SVATTT from 2015 to 2017 on the crypto side and wrote a chain of elliptic-curve challenges for the 2018 edition, so the first thing I do with one of these is look at who set it. That tells you the ratio. Some authors write challenges that are 50% thinking and 50% code. `ndh` writes challenges that are 80% thinking and 20% code, and this one held to that.
+This was the crypto challenge in the SVATTT 2020 online qualifier - Sinh vien voi An Toan Thong Tin, the ASEAN Student Contest in Information Security - authored by `ndh`. I had played SVATTT from 2015 to 2017 on the crypto side and wrote a chain of elliptic-curve challenges for the 2018 edition. I was not competing in 2020. The university team that was competing asked me, after the round closed, how the crypto challenge should have gone, and this writeup is the answer.
+
+The first thing I do with one of these is look at who set it. That tells you the ratio. Some authors write challenges that are 50% thinking and 50% code. `ndh` writes challenges that are 80% thinking and 20% code, and this one held to that.
The server does three things:
@@ -25,15 +27,15 @@ Two details from the source matter more than they look.
The cap `n <= 2020` is what makes this a challenge rather than a formality: without it you would name an enormous `n` and let a single rule run away. You pick `n` within the cap, and that choice is the whole exploit.
-The submission format is a comma-separated list of base64 blobs, decoded straight back into a tuple of byte strings. Elements are arbitrary bytes. There is no alphabet restriction on what you send back, which turns out to matter a great deal. Nor is there any check that a submission differs from the original tuple - only that the 2020 are pairwise distinct - so the original is itself a legal answer.
+The submission format is a comma-separated list of base64 blobs, decoded straight back into a tuple of byte strings. Elements are arbitrary bytes. There is no alphabet restriction on what you send back, which turns out to matter a great deal. Nor is there any check that a submission differs from the original tuple - only that the 2020 are pairwise distinct - so the original is itself a legal answer. And nothing caps the length of a submitted list; the cap is on `n` alone.
## Merkle hashes, briefly
-A Merkle tree hashes a block of data by hashing each leaf, then hashing each adjacent pair of hashes, and repeating until one root remains. Here the hash is SHA-256 truncated to its first 6 bytes, a 48-bit digest, which the challenge does to keep the base64 traffic small. It is the structure under Bitcoin and Ethereum block bodies, and its point is that you can verify one leaf against the root without holding the rest of the data.
+A Merkle tree hashes a block of data by hashing each leaf, then hashing each adjacent pair of hashes, and repeating until one root remains. Here the hash is SHA-256 truncated to its first 6 bytes, a 48-bit digest, which the source says is to keep the base64 traffic small. It is the structure under Bitcoin and Ethereum block bodies, and its point is that you can verify one leaf against the root without holding the rest of the data.
-Three pieces of notation. `h(x)` is the 6-byte digest of one element. `||` is byte concatenation. `M(...)` is the Merkle root of a whole input list, which is what the challenge compares.
+Three pieces of notation. `h(x)` is the 6-byte digest of any byte string - a leaf, or a pair of digests glued together. `||` is byte concatenation. `M(...)` is the Merkle root of a whole input list, which is what the challenge compares.
-The build, stated exactly, because the detail matters below: hash every element to get a row of digests, then repeatedly replace each adjacent pair `a, b` with `h(a || b)` until one value is left.
+The build, stated exactly, because the detail matters below: hash every element to get a row of digests, then repeatedly replace each adjacent pair `a, b` with `h(a || b)` until one value is left. A one-element input never enters the loop, so `M([x])` is just `h(x)`.
The property that breaks it is that every row is just a list of byte strings, and the function that consumes a row does not record which row it was. As far as `M` is concerned, rows are interchangeable. That is the root cause of everything below.
@@ -43,15 +45,15 @@ A second-preimage attack means: given one input, find a different input with the
Take leaves `L1, L2, L3, L4`. The first pass produces digests `h(L1) h(L2) h(L3) h(L4)`. The row above is `h(h(L1)||h(L2))` and `h(h(L3)||h(L4))`, and the root is the hash of those two concatenated.
-So what two-element input reproduces that root? Not the internal nodes themselves. The implementation hashes every element before pairing anything, so you have to submit the values that *hash to* the internal nodes. The node is `h(h(L1)||h(L2))`, so the element you send is `h(L1)||h(L2)`: the two child digests, concatenated, twelve bytes.
+So what two-element input reproduces that root? Call this a rewrite: submitting a higher row in place of the leaves. Not the internal nodes themselves. The implementation hashes every element before pairing anything, so you have to submit the values that *hash to* the internal nodes. The node is `h(h(L1)||h(L2))`, so the element you send is `h(L1)||h(L2)`: the two child digests, concatenated, twelve bytes.
```
-M(L1, L2, L3, L4) == M(h(L1)||h(L2), h(L3)||h(L4))
+M(L1, L2, L3, L4) = M(h(L1)||h(L2), h(L3)||h(L4))
```
-The original writeup glossed this, and the source settles it. The solver builds its rewrite rows as `[hashes[i] + hashes[i+1] for i in range(0, len(hashes), 2)]`, and `+` on `bytes` in Python is concatenation - not addition, and not a further hash. Submitting a twelve-byte element is legal because the wire format base64-decodes to arbitrary bytes and the challenge never inspects them.
+The original writeup glossed this, and the source settles it. The solver builds its rewrite rows as `[hashes[i] + hashes[i+1] for i in range(0, len(hashes), 2)]`, run after the odd-row duplicate has already been appended so the pairing is even, and `+` on `bytes` in Python is concatenation - not addition, and not a further hash. Submitting a twelve-byte element is legal because the wire format base64-decodes to arbitrary bytes and the challenge never inspects them.
-Every level gives you one such rewrite, so a tree over `n` leaves gives about `log2(n)` of them. With `n` capped at 2020 that is about ten. Two hundred times short.
+Every row gives you one such rewrite, so a tree over `n` leaves gives about `log2(n)` of them. With `n` capped at 2020 that is about ten. Two hundred times short.
## Rule two: the odd-length duplication
@@ -61,19 +63,20 @@ The implementation has a second gap. When a row has an odd number of elements, t
M(1, 2, 3, 4, 5) = M(1, 2, 3, 4, 5, 5)
```
-That is worth one collision per odd row, and there are only about ten rows. Another ten. Still short, and adding the two rules gives twenty, not 2020.
+The original writeup counted this as one collision per odd row, about ten in total, and I repeated that for years. It is wrong, and badly. Nothing stops you appending a second copy, and a third: the padding cascades, and every length from 1026 up to 2048 collides with the original. Rule two on its own is worth 1023 collisions, not ten.
+
+So the framing the original built - two rules worth ten each, twenty against a requirement of 2020, hopeless without some trick - was never the real situation. Rule two alone gets you halfway. What the composition buys is the rest, and a much tidier account of where the total comes from.