From b0142e22f78eba4cbef91e45636b9a65664da26e Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:56:23 +0200 Subject: [PATCH 1/8] feat(posts): migrate three posts from the old blog Ports the surviving material from teamkhunglong.github.io, a Jekyll blog dormant since 2022, translated from Vietnamese and rewritten to this repo's conventions. Each post opens with an archive note giving its original date and saying plainly that the subject matter is history. - Merklision: the SVATTT 2020 CTF writeup, a Merkle second-preimage attack composed with an odd-length padding bug to reach 2047 collisions against a requirement of 2020. - Mining ETH in a homelab: the 2021-04 GPU passthrough narrative with the 2021-09 Kubernetes how-to folded in, since the how-to was the second half of the same arc. - The arithmetic that makes cryptojacking worth it: the 2021-07 post on rented-GPU economics, reframed around what the missing cost line means defensively. Each carries one inline SVG built from the svg.dg classes, verified in both theme states. All numbers trace to the source posts; the swept facts and their verification live in homelab-platform under docs/blog/old-blog-archive.md. Dropped rather than ported: four generic DevOps and Kubernetes tutorials that were largely renderings of upstream documentation, and the 2020 post about building the old blog itself. The whoami bio is left for the about-page rewrite. Posts stay draft: true pending Minh's read. Co-Authored-By: Claude Opus 5 --- .../merklision-merkle-second-preimage.md | 143 ++++++++++++++++++ content/posts/mining-eth-in-a-homelab.md | 139 +++++++++++++++++ ...-economics-that-make-cryptojacking-work.md | 108 +++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 content/posts/merklision-merkle-second-preimage.md create mode 100644 content/posts/mining-eth-in-a-homelab.md create mode 100644 content/posts/the-economics-that-make-cryptojacking-work.md diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md new file mode 100644 index 0000000..6842063 --- /dev/null +++ b/content/posts/merklision-merkle-second-preimage.md @@ -0,0 +1,143 @@ +--- +title: "Merklision: 2020 collisions out of one Merkle tree" +date: 2020-10-24T17:00:00+07:00 +draft: true +description: "A SVATTT 2020 qualifier challenge asked for 2020 distinct inputs that all hash to one Merkle root. Two implementation gaps, composed recursively, produce 2047 of them in eleven seconds." +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. Composing the two recursively gives 2047." +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.* + +**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. Composing the two rules recursively turns the additive count into a multiplicative one, and picking `n = 1025` so that every level of the tree is odd produces 2047 collisions in eleven seconds of brute force. + +## The challenge + +This was the crypto challenge in the SVATTT 2020 online qualifier, authored by `ndh`. I had played SVATTT from 2015 to 2017 on the crypto side and wrote an ECC chain 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 percent thinking and 50 percent code. `ndh` writes challenges that are 80 percent thinking and 20 percent code, and this one held to that. + +The server does three things: + +1. Reads an integer `n` from you, and generates a random string of `n` elements, each a letter from `a-zA-Z`. +2. Shows you that string and asks for 2020 further strings, pairwise distinct and distinct from the original. +3. Accepts if every one of them has the same Merkle hash as the generated string. + +You choose `n`. That turns out to be the whole challenge. + +## 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. 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. + +The property that matters here is that the tree is built bottom-up from a list, and the intermediate rows are hashes of exactly the same shape as the leaf row. If the implementation does not record which row an element came from, the rows are interchangeable. + +## Rule one: the classic second preimage + +A second-preimage attack means: given one input, find a different input with the same hash. For an unvalidated Merkle construction this is not hard. + +Take leaves `L1, L2, L3, L4`. The next row up is `H(L1,L2)` and `H(L3,L4)`, and the root is `H` of those two. Now hand the same implementation a two-element input consisting of `H(L1,L2), H(L3,L4)` directly. It hashes them as a pair, gets the same root, and has no way to tell the difference: + +``` +M(1, 2, 3, 4) = M(H(1,2), H(3,4)) +``` + +Every level of the tree gives you one such rewrite, so a tree over `n` leaves gives `log2(n)` collisions. With `n` bounded below 2020 that is about ten. Two hundred times short. + +## Rule two: the odd-length duplication + +The implementation has a second gap. When a row has an odd number of elements, the last element is duplicated so the pairing works out. So padding an odd-length input by repeating its final element is invisible: + +``` +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. + + +Two Merkle collision rules and their composition +Left: the generated input of four leaves hashes pairwise into two intermediate nodes and then one root. Right: handing the implementation those two intermediate nodes as a fresh two-element input reproduces the same root, because the construction never records which row an element came from. Below: the odd-length rule duplicates a trailing element without changing the hash, and alternating the two rules across levels multiplies the collision count instead of adding to it. + + + + + + +What the server generated +four leaves, hashed pairwise upward + +root + +H(L1,L2) + +H(L3,L4) + +L1 + +L2 + +L3 + +L4 + + + + + + +input length 4 + +Rule one: feed the middle row back in +the tree never records which row an element came from + +root + +H(L1,L2) + +H(L3,L4) + + +submitted as a two-element input +same root, different string, counts as a collision +input length 2 + +Rule two, and why composing them multiplies +M(1,2,3,4,5) = M(1,2,3,4,5,5) odd row duplicates its last element +pad, collapse a level, pad again, expand back: 1,2,3,4,5 -> 1,2,3,4,5,5,5,5 + + +## Composing the rules + +Treating the two rules as separate buckets gives twenty collisions. They are not separate. Each one changes the shape of the input, and the changed shape is eligible for the other rule. + +Start from `1, 2, 3, 4, 5`: + +1. Odd length, so pad: collides with `1, 2, 3, 4, 5, 5`. +2. That is six elements, so collapse a level: collides with `H(1,2), H(3,4), H(5,5)`. +3. That row is odd, so pad again: collides with `H(1,2), H(3,4), H(5,5), H(5,5)`. +4. Expand that back down a level and you get `1, 2, 3, 4, 5, 5, 5, 5`, which is a plain string of the original alphabet. + +One five-element input produced four collisions before the higher levels were even touched, and the last one is longer than anything the first rule alone reaches. The ceiling is the first power of two above `n`: for `n = 5`, `max_length` is 8. + +So the choice of `n` is the actual exploit. You want every level of the tree to land on an odd count, so that the padding rule fires at each one. Working downward, `n = (((2*2-1)*2-1)*2-1)...` and staying under 2020 gives `n = 1025`, with `max_length = 2048`. + +## Solving it + +There is a clever way to enumerate the collisions and a stupid way. I took the stupid way, because the challenge scores a flag and not an algorithm: + +1. The first level gets padded regardless, so loop, repeating the last element until the string reaches length 2048. +2. For each of those, compute the Merkle hash and record the collisions found across its levels, applying rule one. +3. Push every candidate into a set, the same structure the challenge uses, which drops the duplicates. There are a lot of duplicates. +4. Send the set back to the server. + +```console +$ python ./merklision.py +Maximum length can collide is 2048 +There are 2047 collisions +Takes 11 sec to produce +``` + +2047 against a requirement of 2020, in eleven seconds. Source is in [the merklision solver gist](https://gist.github.com/minhtt159/af8e19e2ac7088be48889ccd5c6e0e0b). + +## What it was worth + +Five teams solved it inside the four-hour window. For an online CTF with four-person teams and four challenges, that means one person had to land this cleanly while the other three landed theirs, which is a reasonable definition of a hard qualifier problem. + +The lesson generalises past the challenge. Neither bug is exotic: one is the documented second-preimage weakness of unvalidated Merkle constructions, the other is a one-line padding convenience. Individually each is worth ten collisions and would probably survive a review as a curiosity. What made them worth 2047 is that one rule's output is the other rule's input, and nothing in the implementation stops you from alternating between them all the way up the tree. diff --git a/content/posts/mining-eth-in-a-homelab.md b/content/posts/mining-eth-in-a-homelab.md new file mode 100644 index 0000000..51d599a --- /dev/null +++ b/content/posts/mining-eth-in-a-homelab.md @@ -0,0 +1,139 @@ +--- +title: "Mining ETH in a homelab, and the day it stopped being about mining" +date: 2021-04-26T17:00:00+07:00 +draft: true +description: "Spare GPU slots in machines that were already running, a 4U case because a workstation chassis cooks cards, and a path from GPU passthrough on Proxmox to a Kubernetes Deployment that treats a miner as any other workload." +summary: "The goal was to make idle hardware pay the electricity bill. The route ran through IOMMU passthrough into a Windows VM, then out again into containers, and what survived the mining was the scheduling pattern underneath it." +tags: ["homelab", "proxmox", "kubernetes", "gpu", "virtualization"] +--- + +> *Archive note. Written in April 2021, with the Kubernetes half added that September, for a blog I have since retired. Ethereum left proof of work in 2022, so the mining is history. The passthrough and scheduling mechanics are the part that outlived it.* + +**TL;DR.** During the Covid-era GPU shortage I had spare PCIe slots in machines that were already powered on for other reasons, and a few cards accumulated over the years. Mining ETH on them was a way to make that hardware cover the electricity, water and internet bills. Getting a GPU into a virtual machine meant IOMMU passthrough on Proxmox, because the cards that virtualise themselves properly cost enterprise money. The Windows guest turned out to be the unstable part. Containers removed the passthrough step entirely, and once the miner was a container, a Kubernetes Deployment with a `nodeSelector` scheduled it onto GPU nodes like any other workload. The mining paid for itself. The scheduling pattern is what I actually kept. + +## Why bother + +Four things lined up in early 2021: + +1. Lockdowns gave people time to trade, and crypto and equities both moved, mostly upward. +2. The same lockdowns broke the semiconductor supply chain. GPUs were the scarce part, and GPUs are what mines ETH, so mining got harder and the price went up together. +3. I was already running a homelab, which means several PCs, workstations and servers at home. Almost none of them needed graphics, so I had a lot of empty PCIe slots. +4. I already owned a handful of GPUs, accumulated across years of student machine-building. Running them at full duty felt like getting value out of money already spent. + +So: mine to cover the monthly bills, and a bit beyond. The condition attached is worth stating, because it is the whole economic argument. I would not have done this without the background to run it, and I would not have done it if it meant buying the hardware from scratch. + +## Not building a rig + +A dedicated miner builds a frame holding six to eight GPUs. Everything that is not a GPU is chosen to be as cheap as possible: a board with many PCIe slots, minimal CPU, minimal RAM, a small disk, a power supply that is adequate rather than good. Then a mining-specific operating system such as HiveOS goes on top. The machine mines and does nothing else, which is both the strength and the weakness. + +I went the other way. The workstations already existed, so adding GPUs to them raised the utilisation of machines that were running regardless, and left the CPU and RAM free for the rest of the homelab services. That constraint - the machine has a day job - is what forced every decision that follows. + +## Getting a GPU into a virtual machine + +Mining software runs on Windows or on Linux, so the card needs to reach an operating system. Since I virtualise rather than installing on bare metal, a guest by default has no idea what hardware exists underneath it. Handing it real hardware needs IOMMU, the input-output memory management unit, which is what lets a device be mapped safely into a guest's address space. + +In server hardware the clean answer is SR-IOV, single-root IO virtualization, where the card presents itself as several virtual functions. Cards that do that are priced accordingly. Compare an RTX A6000 against an RTX 3090 sometime: the same chip, the same CUDA core count, wildly different price. Enterprise cards were out of reach, so consumer cards it was, and the one I bought for this was an RTX 3080 at 24.5 million VND. + +Consumer parts work for passthrough as long as the CPU and motherboard support IOMMU. I tend to buy high-end desktop CPUs, so this was never the blocker. + +For the hypervisor there were three serious options: QEMU-KVM, Xen, and VMware ESXi. I was poor and partial to open source, which picks QEMU-KVM. Several distributions expose it, and the friendliest of those is Proxmox. + +Then Proxmox got a Windows guest with the GPU passed through. Windows over HiveOS was a personal call - I needed Windows for other things anyway, and one card, an RTX 3060, only reached 48 MH/s after a driver unlock that exists on Windows. Down the rabbit hole: once it had been running a while, the Windows guest turned out to be the least stable component in the stack. + +With the guest up, the rest is mundane. Install the miner, join a pool. My hashrate was small, so I picked ethermine for its 0.1 ETH payout floor, which suits a small operation - a higher floor means waiting months to see anything. + +One quiet benefit of virtualising: when a bare-metal rig hangs, someone has to walk over and power-cycle it. Under Proxmox the guest reboots from a web interface, the same as VMware or VirtualBox. + +Heat was the real problem. GPUs crammed into a workstation chassis cook. I bought a 4U case to dedicate to this, and that configuration ran about three months without drama. + +## The part that outlived the mining + +Some months in, a more experienced friend pointed out the obvious thing I had walked past: run the miner in a Docker container and the passthrough problem disappears. He was right. Containers skip the passthrough setup and avoid the CPU penalty that comes with it. Mining is not CPU-hungry enough for that penalty to matter much, but the simplification does. + +Once the miner is a container, it is just another workload, and the whole apparatus for running workloads applies to it. That is where this stopped being about mining. + + +Two ways to get a miner onto a GPU: passthrough versus container scheduling +Top row, the April 2021 path: Proxmox maps the card into a Windows guest through IOMMU passthrough, the miner runs inside that guest, and the guest is the fragile link. Bottom row, the September 2021 path: a Dockerfile is built by GitHub Actions into the GitHub container registry, a Kubernetes Deployment pulls it, and a nodeSelector plus the NVIDIA device plugin place the pod on a GPU node. The container path has no passthrough step and scales by changing a replica count. + + + + + + +April 2021: pass the card into a guest + +Proxmox +QEMU-KVM host + +IOMMU +passthrough + +Windows VM +driver unlock + +miner +one card + +pool +0.1 ETH floor + + + + +scales by building another machine; the guest is the fragile link + +September 2021: schedule it like any other workload + +Dockerfile +wraps T-Rex + +Actions +builds on push + +GHCR +free 500 MB + +Deployment +replicas: N + +GPU node +device plugin + +pool +same wallet + + + + + +no passthrough step; a failed pod is rescheduled, not walked over to +scales by changing one number, on hardware you own or hardware you rent + +nodeSelector keeps the pod off the nodes that have no card to give it + + +## Building the container path + +The miner itself is someone else's tool. Phoenix Miner and T-Rex are both easy to drive and take a one percent developer fee, switching over to mine for their author for that share of the time. T-Rex has good Linux support, so that is the one I wrapped. + +Using T-Rex is a matter of reading its command-line options and filling in the fields. Nothing about it needs modification, which is the point - the less of someone else's tool you touch, the less you maintain. + +**Build the image.** Write a Dockerfile on the development machine, so that what runs in production is what you tested. Docker Compose can expose a GPU to a container for local testing. Worth knowing: in production it is not Docker that enables the GPU for the container. That is the device plugin's job, and it is a different mechanism with different failure modes. + +**Build it in CI.** Push the repository to GitHub and set up a workflow that publishes the image. GitHub hosts a container registry with a free tier around 500 MB, which is far more than a mining container needs. After that the pipeline builds the package on every push and the cluster pulls it. GitLab, Docker Hub or a self-hosted registry would work identically - GitHub just kept the number of accounts down. + +**Deploy it.** With the image in a registry, four things matter on the Kubernetes side: + +- Install a device plugin on the GPU nodes. The [NVIDIA k8s-device-plugin](https://github.com/NVIDIA/k8s-device-plugin) advertises the cards as a schedulable resource. +- Set `replicas` to the number of agents you want. On a cloud provider backed by a scale set, that number can grow as far as the budget does. +- Set a `nodeSelector` so the miner only lands on nodes that have a card. Without it the scheduler will happily place the pod somewhere with nothing to mine on. +- Set `hostAliases` for the pool's domains. Skip this and you will end up explaining to whoever runs the DNS server why those lookups exist. + +A working example is at [minhtt159/ghcr-tictactoe](https://github.com/minhtt159/ghcr-tictactoe). Fork it, change the wallet address, and it runs. + +## What was actually learned + +The mining was a means. What the exercise taught, and what stayed useful long after ETH stopped being mineable, is the shape of the answer: a GPU workload is only awkward while it is pinned to one machine. Pass a card into a guest and you have bought yourself a fragile, hand-placed, manually-recovered unit of work. Put the same binary in a container and let a scheduler place it against an advertised resource, and the unit of work becomes fungible. The hardware stops being something you log into and becomes something a replica count consumes. + +Which raises a question I did not have a good answer for at the time: if this is fungible, and it schedules onto anything with a card, what stops it scheduling onto someone else's? diff --git a/content/posts/the-economics-that-make-cryptojacking-work.md b/content/posts/the-economics-that-make-cryptojacking-work.md new file mode 100644 index 0000000..61fb824 --- /dev/null +++ b/content/posts/the-economics-that-make-cryptojacking-work.md @@ -0,0 +1,108 @@ +--- +title: "The arithmetic that makes cryptojacking worth it" +date: 2021-07-25T17:00:00+07:00 +draft: true +description: "Renting GPUs to mine has a return-on-investment calculation that usually says no. Removing the cost line from that calculation is what turns a miner into malware, and it explains why GPU credentials are worth stealing." +summary: "A miner that schedules itself onto any GPU node is a workload like any other, and its economics come down to one subtraction. Drive the cost term to zero and the answer is always yes, which is the whole reason cryptojacking exists." +tags: ["security", "threat-model", "kubernetes", "gpu", "cloud"] +--- + +> *Archive note. Written in July 2021 for a blog I have since retired, when Ethereum still ran on proof of work and EIP-1559 was about to change the yield. The coin is gone and the numbers with it. The economic argument is the part that still holds, and it now applies to whatever GPUs are used for next.* + +**TL;DR.** Once a miner is a container scheduled by a replica count, running it on rented cloud GPUs is a straightforward calculation: mined value minus rental cost. That subtraction usually comes out negative, which is why renting to mine is mostly a bad idea and buying the coin is the rational move. The interesting case is what happens to the calculation when the cost term is zero, because someone else is paying for the compute. Then there is no threshold to clear and no break-even to wait for, and that is the entire economic engine behind cryptojacking. If your organisation runs GPUs, that is what makes the credentials in front of them worth stealing. + +## Where this picks up + +Three months earlier I had been [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/) by passing a GPU into a Windows virtual machine. The conclusion of that exercise was that the passthrough was unnecessary: a container skips it, and once the miner is a container, Kubernetes will place it against an advertised GPU resource like any other workload. + +Three months of running it produced some coin, some experience, and one observation I had not expected to make - which is that I had been looking at an attack surface without recognising it. + +## Renting, and the problem it creates + +Cloud providers are generous with GPU instances, and people do rent them for exactly this. Around that time it was common to see acquaintances renting compute VPS capacity to plot Chia. + +The obstacle is not renting one instance; it is managing many. A single VM tops out at around four GPUs. Suppose the price moves and you want ten GPU instances. You can deploy them quickly with a tool like `ansible`, but when one of them breaks you have to work out *which* one and go fix it by hand. Ten machines with individual identities is ten things to babysit. + +Kubernetes answers that directly. On a cloud provider it grows the instance count when you need capacity and shrinks it with one action, and a pod that dies is rescheduled rather than investigated. The unit of work becomes fungible. It does start to sound like passive income. + +None of this is new. Back in 2017, when ETH last ran up hard, people were already building on this stack to rent GPUs and mine. In 2018 the price crashed and nobody mentioned it again. + +## When information technology does return on investment + +The idea is a few lines of configuration. The hard question is not how, it is when: when is it worth renting at all? + +Buying hardware means accounting for the capital, the cooling, the electricity to run it, and how many months until it pays back. Renting is simpler, and comes down to one subtraction: + +``` +profit = value mined - rental cost +``` + +If that is positive you rent. If it is negative you buy the coin on an exchange and skip the engineering, or you leave crypto alone entirely if you have no conviction about it. + + +Three ways to fund the same miner, and what each one has to clear +One container image feeds three funding models. Owning the hardware carries capital, power and cooling and must clear a break-even period. Renting cloud GPUs carries an hourly cost that the mined value has to exceed, which it usually does not. Running on compute someone else pays for carries no cost term at all, so the subtraction has nothing to clear and the answer is always yes. That missing cost line is what makes the third column an attack rather than a business. + + + + + +One miner container +replicas: N, nodeSelector: gpu + + + + +Own the hardware +capital: cards, case, power supply +running: electricity, cooling +recovery: walk to the machine +must clear: +break-even, in months +answer: sometimes + +Rent the hardware +capital: none +running: hourly instance cost +recovery: reschedule the pod +must clear: +mined minus rental +answer: usually no + +Pay for neither +capital: none +running: billed to the owner +recovery: not your problem +must clear: +nothing at all +answer: always yes + +The cost line is the only thing holding the third column back +Remove it and there is no threshold, no break-even, and no reason to stop. +That is why the credentials in front of a GPU fleet are worth more than they look. + + +## The version without a cost line + +Now hold the mined value constant and set the rental cost to zero. + +Every threshold in the calculation disappears. There is no break-even period to survive, no price level the coin has to reach, and no reason to ever turn it off. A workload that is marginal at market rates becomes unconditionally profitable the moment somebody else is paying the bill. Nothing about the miner changes - same image, same replica count, same pool. Only the invoice moves. + +That is not a hypothetical. It is the standing economic reason that idle compute gets stolen, and it says something specific about what an attacker is shopping for. They do not need to reach your data. They need to reach something that can start a process on a machine with a card in it, and they need it to stay unnoticed while it bills you. + +Two things fit that description uncomfortably well in a GPU estate. The first is service credentials for an inference endpoint, which by design sit in front of exactly the hardware a miner wants and are handled by more systems than anyone has fully enumerated. The second is anything that shapes how a node comes up - the initialisation path of a GPU scale set, for instance - because it runs with the node's authority on every instance the group ever creates, and nobody reads it after the first time it works. + +## What to do about it + +The defensive reading is not "watch for miners". Signature-matching a known miner binary catches the lazy version and nothing else. The useful reading is that GPU capacity is a directly monetisable asset on your balance sheet, and should be protected the way other directly monetisable assets are: + +- **Treat inference and GPU-scheduling credentials as money.** Scope them, rotate them, and alert on use from somewhere they have never been used from. They are not read-only API keys; they are a way to spend your compute budget. +- **Version and review node initialisation the way you review production code.** A mutable startup script attached to a scale set is an unreviewed root shell that runs on every machine the group creates. +- **Alert on utilisation, not on binaries.** Sustained high GPU utilisation with no corresponding job, or instance counts that grow without a workload behind them, describe the condition regardless of which miner is running. The billing anomaly and the capacity anomaly are the same event seen from two directions. +- **Ask what a compromised credential is worth in cash.** For most of the industry this question came into focus only once the hardware became scarce and expensive. GPUs got there first. + +## Closing + +EIP-1559 is coming, and at the rates involved renting GPUs legitimately to mine ETH cannot be made profitable anyway. That kills this particular instance of the arithmetic, not the arithmetic. Some future coin, or some future thing GPUs are scarce for, will rebuild the same subtraction, and the same term will be the one worth removing. + +Writing this down probably reduces the number of instances I can quietly rent. That trade seems fine. The interesting work was never the mining. From a7a123a66348bc63a2c8c51af7095e72fd235837 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:03:12 +0200 Subject: [PATCH 2/8] fix(posts): correct errors the cold read found A fresh subagent read the three posts with no context and returned FIX FIRST on all three. Most of what it caught was real, and three of them were errors introduced in translation rather than inherited. Corrected against the Vietnamese sources: - The RTX 3060 needed the Windows driver unlock to *reach* 48 MH/s. The migrated text said it "only reached" that, inverting the sense. - "The mining paid for itself" was never claimed in the source, which states the goal and never reports the outcome. Removed; the posts now say plainly that no revenue figure was ever recorded. - The Chia example was offered as evidence that people rent GPUs to mine. Chia plotting is a storage and CPU workload on plain compute instances, so it evidences the instinct, not the hardware. Also fixed: the 2047 collision count is now stated as measured rather than derived, since the original author explicitly wrote that he did not know the closed form; the cap on n is stated where the challenge is described; M and H are defined before use; the Windows-guest instability and the 4U case's three stable months are separated, being claims about different things; the April/July/September timeline is made explicit, since the container insight belongs to the July post and the build instructions to September. Diagram fixes: post one's colour now encodes role consistently and says so, rather than drawing identical values in two colours; post two stops claiming the passthrough path "scales by building another machine", which contradicts its own prose, and marks IOMMU as a mapping rather than a pipeline stage; post three's first column no longer renders a verdict the prose never reaches, and no longer pairs a shared container input with "walk to the machine" recovery. The hostAliases bullet is kept and named as evasion, with a pointer to the companion post that argues the defender's side. Post three is retitled to match its slug and its content: it contains no arithmetic, and said so in its title. Co-Authored-By: Claude Opus 5 --- .../merklision-merkle-second-preimage.md | 53 +++++++++++-------- content/posts/mining-eth-in-a-homelab.md | 30 ++++++----- ...-economics-that-make-cryptojacking-work.md | 46 +++++++++------- 3 files changed, 75 insertions(+), 54 deletions(-) diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md index 6842063..f5a57bb 100644 --- a/content/posts/merklision-merkle-second-preimage.md +++ b/content/posts/merklision-merkle-second-preimage.md @@ -1,45 +1,47 @@ --- -title: "Merklision: 2020 collisions out of one Merkle tree" +title: "Merklision: composing two Merkle bugs into 2047 collisions" date: 2020-10-24T17:00:00+07:00 draft: true -description: "A SVATTT 2020 qualifier challenge asked for 2020 distinct inputs that all hash to one Merkle root. Two implementation gaps, composed recursively, produce 2047 of them in eleven seconds." -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. Composing the two recursively gives 2047." +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." 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.* -**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. Composing the two rules recursively turns the additive count into a multiplicative one, and picking `n = 1025` so that every level of the tree is odd produces 2047 collisions in eleven seconds of brute force. +**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. ## The challenge -This was the crypto challenge in the SVATTT 2020 online qualifier, authored by `ndh`. I had played SVATTT from 2015 to 2017 on the crypto side and wrote an ECC chain 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 percent thinking and 50 percent code. `ndh` writes challenges that are 80 percent thinking and 20 percent 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 Vietnamese inter-university student security competition - 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 percent thinking and 50 percent code. `ndh` writes challenges that are 80 percent thinking and 20 percent code, and this one held to that. The server does three things: 1. Reads an integer `n` from you, and generates a random string of `n` elements, each a letter from `a-zA-Z`. -2. Shows you that string and asks for 2020 further strings, pairwise distinct and distinct from the original. +2. Shows you that string and asks for 2020 further strings, pairwise distinct and distinct from the original. It collects them in a set, so exact repeats are silently discarded rather than rejected. 3. Accepts if every one of them has the same Merkle hash as the generated string. -You choose `n`. That turns out to be the whole challenge. +`n` has to stay below 2020. That cap is what makes this a challenge rather than a formality - without it you would pick an enormous `n` and let a single rule run away. You choose `n` within the cap, and that choice turns out to be the whole exploit. ## 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. 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. -The property that matters here is that the tree is built bottom-up from a list, and the intermediate rows are hashes of exactly the same shape as the leaf row. If the implementation does not record which row an element came from, the rows are interchangeable. +Two pieces of notation for the rest of this post. `H(a,b)` is the hash of one adjacent pair, one internal node of the tree. `M(...)` is the Merkle hash of a whole input list, the root you get after collapsing every row. The challenge compares `M` values. + +The property that matters here is that the tree is built bottom-up from a list, and the intermediate rows are hashes of exactly the same shape as the leaf row. The construction hashes pairs without recording which row they came from, so as far as `M` is concerned, the rows are interchangeable. That is the root cause of everything below. ## Rule one: the classic second preimage A second-preimage attack means: given one input, find a different input with the same hash. For an unvalidated Merkle construction this is not hard. -Take leaves `L1, L2, L3, L4`. The next row up is `H(L1,L2)` and `H(L3,L4)`, and the root is `H` of those two. Now hand the same implementation a two-element input consisting of `H(L1,L2), H(L3,L4)` directly. It hashes them as a pair, gets the same root, and has no way to tell the difference: +Take leaves `L1, L2, L3, L4`. The next row up is `H(L1,L2)` and `H(L3,L4)`, and the root is `H` of those two. Now hand the same implementation a two-element input consisting of `H(L1,L2), H(L3,L4)` directly. It hashes them as a pair, gets the same root, and has no way to tell the difference. Writing the leaves as `1, 2, 3, 4` for brevity: ``` M(1, 2, 3, 4) = M(H(1,2), H(3,4)) ``` -Every level of the tree gives you one such rewrite, so a tree over `n` leaves gives `log2(n)` collisions. With `n` bounded below 2020 that is about ten. Two hundred times short. +Every level of the tree gives you one such rewrite, so a tree over `n` leaves gives about `log2(n)` of them. With `n` capped below 2020 that is about ten. Two hundred times short. ## Rule two: the odd-length duplication @@ -53,7 +55,7 @@ That is worth one collision per odd row, and there are only about ten rows. Anot Two Merkle collision rules and their composition -Left: the generated input of four leaves hashes pairwise into two intermediate nodes and then one root. Right: handing the implementation those two intermediate nodes as a fresh two-element input reproduces the same root, because the construction never records which row an element came from. Below: the odd-length rule duplicates a trailing element without changing the hash, and alternating the two rules across levels multiplies the collision count instead of adding to it. +Left: the generated input of four leaves hashes pairwise into two intermediate nodes and then one root. Right: the same two intermediate node values, byte for byte identical to the ones on the left, submitted as a fresh two-element input. They reproduce the same root, because the construction never records which row a value came from. Colour marks role, not value: blue is what the server generated, red is what the attacker submits. Below: the odd-length rule duplicates a trailing element without changing the hash, and the chain of four steps shows the two rules being alternated, which is what the prose derives the collision count from. @@ -64,9 +66,9 @@ That is worth one collision per odd row, and there are only about ten rows. Anot four leaves, hashed pairwise upward root - + H(L1,L2) - + H(L3,L4) L1 @@ -80,8 +82,8 @@ That is worth one collision per odd row, and there are only about ten rows. Anot - - + + input length 4 Rule one: feed the middle row back in @@ -94,8 +96,9 @@ That is worth one collision per odd row, and there are only about ten rows. Anot H(L3,L4) -submitted as a two-element input +same bytes as the blue row on the left, now the whole input same root, different string, counts as a collision +red marks what the attacker submits, not a different value input length 2 Rule two, and why composing them multiplies @@ -114,19 +117,25 @@ Start from `1, 2, 3, 4, 5`: 3. That row is odd, so pad again: collides with `H(1,2), H(3,4), H(5,5), H(5,5)`. 4. Expand that back down a level and you get `1, 2, 3, 4, 5, 5, 5, 5`, which is a plain string of the original alphabet. -One five-element input produced four collisions before the higher levels were even touched, and the last one is longer than anything the first rule alone reaches. The ceiling is the first power of two above `n`: for `n = 5`, `max_length` is 8. +One five-element input produced four collisions from the bottom two levels alone, without touching the levels above, and the last of them is longer than anything the first rule reaches on its own. There is a ceiling: padding cannot grow an input past the first power of two above `n`, which for `n = 5` is 8. + +Note what step 4 bought. Steps 2 and 3 pass hash values around as list elements, which works because `M` cannot tell a hash from a leaf. Step 4 expands back down and lands on `1, 2, 3, 4, 5, 5, 5, 5` - a string drawn entirely from the original alphabet. That matters if the server is fussy about what a submission may contain, and it is the step that makes the technique robust rather than clever. + +So the choice of `n` is the actual exploit. You want every level of the tree to land on an odd count, so that the padding rule fires at each one on the way up. Working the recurrence downward, `n = (((2*2-1)*2-1)*2-1)...` while staying under the cap gives `n = 1025`, whose tree is 11 levels deep and whose padding ceiling is 2048. -So the choice of `n` is the actual exploit. You want every level of the tree to land on an odd count, so that the padding rule fires at each one. Working downward, `n = (((2*2-1)*2-1)*2-1)...` and staying under 2020 gives `n = 1025`, with `max_length = 2048`. +How many collisions does that yield? At the time I wrote this, I did not know. I still do not have a closed form for it. What follows is a count I measured, not one I derived, and the honest version of this writeup says so rather than dressing up the output of a brute-force loop as a result. ## Solving it There is a clever way to enumerate the collisions and a stupid way. I took the stupid way, because the challenge scores a flag and not an algorithm: 1. The first level gets padded regardless, so loop, repeating the last element until the string reaches length 2048. -2. For each of those, compute the Merkle hash and record the collisions found across its levels, applying rule one. -3. Push every candidate into a set, the same structure the challenge uses, which drops the duplicates. There are a lot of duplicates. +2. For each candidate, compute its Merkle hash and keep it only if it matches the target, recording the collisions found across its levels by applying rule one. +3. Push every survivor into a set, the same structure the challenge uses, which drops the duplicates. There are a lot of duplicates. 4. Send the set back to the server. +Step 2 is doing the filtering. Padding to an arbitrary length does not collide in general - a 1027-element input pads to a different node count than the original and produces a different root - so the loop generates candidates freely and the hash comparison throws most of them away. That is what makes this the stupid way: the search is not constructed to only emit valid answers, it is constructed to emit many answers cheaply and check them all. + ```console $ python ./merklision.py Maximum length can collide is 2048 @@ -136,8 +145,10 @@ Takes 11 sec to produce 2047 against a requirement of 2020, in eleven seconds. Source is in [the merklision solver gist](https://gist.github.com/minhtt159/af8e19e2ac7088be48889ccd5c6e0e0b). +2047 is `2^11 - 1`, and 1025 leaves give an 11-level tree. That is a suggestive coincidence and I want to be careful with it: it looks like one binary choice per level with the do-nothing case removed, but I never proved that, and the small case does not obviously match - `n = 5` gives a 3-level tree and I counted 4 collisions from its lower levels, not 7. Treat the formula as a guess and the 2047 as an observation. + ## What it was worth -Five teams solved it inside the four-hour window. For an online CTF with four-person teams and four challenges, that means one person had to land this cleanly while the other three landed theirs, which is a reasonable definition of a hard qualifier problem. +Five teams solved it inside the four-hour window. The original version of this writeup drew a conclusion from that about four-person teams each landing one challenge perfectly; I have left the observation in and the inference out, because the solve count alone does not support it and I never knew how many teams competed. The lesson generalises past the challenge. Neither bug is exotic: one is the documented second-preimage weakness of unvalidated Merkle constructions, the other is a one-line padding convenience. Individually each is worth ten collisions and would probably survive a review as a curiosity. What made them worth 2047 is that one rule's output is the other rule's input, and nothing in the implementation stops you from alternating between them all the way up the tree. diff --git a/content/posts/mining-eth-in-a-homelab.md b/content/posts/mining-eth-in-a-homelab.md index 51d599a..1bfb231 100644 --- a/content/posts/mining-eth-in-a-homelab.md +++ b/content/posts/mining-eth-in-a-homelab.md @@ -3,13 +3,13 @@ title: "Mining ETH in a homelab, and the day it stopped being about mining" date: 2021-04-26T17:00:00+07:00 draft: true description: "Spare GPU slots in machines that were already running, a 4U case because a workstation chassis cooks cards, and a path from GPU passthrough on Proxmox to a Kubernetes Deployment that treats a miner as any other workload." -summary: "The goal was to make idle hardware pay the electricity bill. The route ran through IOMMU passthrough into a Windows VM, then out again into containers, and what survived the mining was the scheduling pattern underneath it." +summary: "The goal was to make idle hardware pay the electricity bill. The route ran through IOMMU passthrough into a Windows VM, then out again into containers, and what survived the mining was the scheduling pattern underneath it. I never measured whether it paid." tags: ["homelab", "proxmox", "kubernetes", "gpu", "virtualization"] --- -> *Archive note. Written in April 2021, with the Kubernetes half added that September, for a blog I have since retired. Ethereum left proof of work in 2022, so the mining is history. The passthrough and scheduling mechanics are the part that outlived it.* +> *Archive note. This is two posts from a blog I have since retired, stitched together because they were two halves of one arc. The passthrough half was written in April 2021; the Kubernetes half in September 2021. The container insight that joins them landed in between, in July, and has [its own post](/posts/the-economics-that-make-cryptojacking-work/). Ethereum left proof of work in 2022, so the mining is history. The mechanics are the part that outlived it.* -**TL;DR.** During the Covid-era GPU shortage I had spare PCIe slots in machines that were already powered on for other reasons, and a few cards accumulated over the years. Mining ETH on them was a way to make that hardware cover the electricity, water and internet bills. Getting a GPU into a virtual machine meant IOMMU passthrough on Proxmox, because the cards that virtualise themselves properly cost enterprise money. The Windows guest turned out to be the unstable part. Containers removed the passthrough step entirely, and once the miner was a container, a Kubernetes Deployment with a `nodeSelector` scheduled it onto GPU nodes like any other workload. The mining paid for itself. The scheduling pattern is what I actually kept. +**TL;DR.** During the Covid-era GPU shortage I had spare PCIe slots in machines that were already powered on for other reasons, and a few cards accumulated over the years. Mining ETH on them was a way to make that hardware cover the electricity, water and internet bills. Getting a GPU into a virtual machine meant IOMMU passthrough on Proxmox, because the cards that virtualise themselves properly cost enterprise money. The Windows guest turned out to be the unstable part. Containers removed the passthrough step entirely, and once the miner was a container, Kubernetes could place it on whichever machines had a card to give it. I never wrote down what it earned, so this post cannot tell you whether it paid - only what it cost and how it was built. The scheduling pattern is what I actually kept. ## Why bother @@ -20,7 +20,9 @@ Four things lined up in early 2021: 3. I was already running a homelab, which means several PCs, workstations and servers at home. Almost none of them needed graphics, so I had a lot of empty PCIe slots. 4. I already owned a handful of GPUs, accumulated across years of student machine-building. Running them at full duty felt like getting value out of money already spent. -So: mine to cover the monthly bills, and a bit beyond. The condition attached is worth stating, because it is the whole economic argument. I would not have done this without the background to run it, and I would not have done it if it meant buying the hardware from scratch. +So: the goal was to cover the monthly bills, and a bit beyond. The condition attached is worth stating, because it is the whole economic argument. I would not have done this without the background to run it, and I would not have built a mining rig from nothing - the premise was spare capacity in machines already earning their keep. + +That premise leaked. Over the following months I bought a GPU for this and a case to put it in, which are both on the "from scratch" side of the line I had just drawn. I am leaving the original framing in rather than quietly tightening it, because the drift from "use what I have" to "buy one more thing" is the most honest part of the story and it is what a payback calculation would have caught. I never ran one. ## Not building a rig @@ -38,23 +40,23 @@ Consumer parts work for passthrough as long as the CPU and motherboard support I For the hypervisor there were three serious options: QEMU-KVM, Xen, and VMware ESXi. I was poor and partial to open source, which picks QEMU-KVM. Several distributions expose it, and the friendliest of those is Proxmox. -Then Proxmox got a Windows guest with the GPU passed through. Windows over HiveOS was a personal call - I needed Windows for other things anyway, and one card, an RTX 3060, only reached 48 MH/s after a driver unlock that exists on Windows. Down the rabbit hole: once it had been running a while, the Windows guest turned out to be the least stable component in the stack. +Then Proxmox got a Windows guest with the GPU passed through. Windows over HiveOS was a personal call - I needed Windows for other things anyway, and one card, an RTX 3060, needed a driver unlock that only existed on Windows in order to reach 48 MH/s, megahashes per second, the rate at which it can attempt the mining puzzle. Without that unlock the card ran at a fraction of its capability, because the vendor shipped it deliberately limited to make it unattractive to miners. Down the rabbit hole: once it had been running a while, the Windows guest itself turned out to be the least reliable component in the stack, and rebooting it was a recurring chore rather than a one-off. With the guest up, the rest is mundane. Install the miner, join a pool. My hashrate was small, so I picked ethermine for its 0.1 ETH payout floor, which suits a small operation - a higher floor means waiting months to see anything. One quiet benefit of virtualising: when a bare-metal rig hangs, someone has to walk over and power-cycle it. Under Proxmox the guest reboots from a web interface, the same as VMware or VirtualBox. -Heat was the real problem. GPUs crammed into a workstation chassis cook. I bought a 4U case to dedicate to this, and that configuration ran about three months without drama. +Heat was the real problem. GPUs crammed into a workstation chassis cook. I bought a 4U case - four rack units tall, so roughly 18 cm of internal height against the 8 or so a desktop tower gives a card, which is enough room for the airflow the cards actually want - and dedicated it to this. Thermally that configuration ran about three months without drama. That is a claim about the hardware, not about the Windows guest running on it; those two things were stable and unstable respectively, at the same time. ## The part that outlived the mining -Some months in, a more experienced friend pointed out the obvious thing I had walked past: run the miner in a Docker container and the passthrough problem disappears. He was right. Containers skip the passthrough setup and avoid the CPU penalty that comes with it. Mining is not CPU-hungry enough for that penalty to matter much, but the simplification does. +By July, a more experienced friend had pointed out the obvious thing I walked past: run the miner in a Docker container and the passthrough problem disappears. He was right. Containers skip the passthrough setup, and they avoid the CPU cost of emulating a machine around the card - a virtual machine spends real cycles on virtualised interrupts and device access that a container simply does not incur. For a miner that overhead is small, because mining hammers the GPU and barely touches the CPU. The simplification is the part worth having. Once the miner is a container, it is just another workload, and the whole apparatus for running workloads applies to it. That is where this stopped being about mining. Two ways to get a miner onto a GPU: passthrough versus container scheduling -Top row, the April 2021 path: Proxmox maps the card into a Windows guest through IOMMU passthrough, the miner runs inside that guest, and the guest is the fragile link. Bottom row, the September 2021 path: a Dockerfile is built by GitHub Actions into the GitHub container registry, a Kubernetes Deployment pulls it, and a nodeSelector plus the NVIDIA device plugin place the pod on a GPU node. The container path has no passthrough step and scales by changing a replica count. +Top row, the April 2021 path: Proxmox uses IOMMU, a hardware address-mapping feature rather than a stage the work travels through, to hand a physical card to a Windows guest; the miner runs inside that guest against that one card, and the guest is the fragile link. Bottom row, the September 2021 path: a Dockerfile wrapping the T-Rex miner is built by GitHub Actions into the GitHub container registry, a Kubernetes Deployment pulls it, and a nodeSelector plus the NVIDIA device plugin place each pod on a node that has a card. The container path has no passthrough step, recovers a failed pod by rescheduling it, and grows by changing a replica count rather than by preparing another machine by hand. @@ -67,13 +69,13 @@ Once the miner is a container, it is just another workload, and the whole appara QEMU-KVM host IOMMU -passthrough +mapping, not a hop Windows VM driver unlock miner -one card +one guest, one card pool 0.1 ETH floor @@ -81,7 +83,7 @@ Once the miner is a container, it is just another workload, and the whole appara -scales by building another machine; the guest is the fragile link +each card is set up by hand, in its own guest; the guest is the fragile link September 2021: schedule it like any other workload @@ -126,11 +128,11 @@ Using T-Rex is a matter of reading its command-line options and filling in the f **Deploy it.** With the image in a registry, four things matter on the Kubernetes side: - Install a device plugin on the GPU nodes. The [NVIDIA k8s-device-plugin](https://github.com/NVIDIA/k8s-device-plugin) advertises the cards as a schedulable resource. -- Set `replicas` to the number of agents you want. On a cloud provider backed by a scale set, that number can grow as far as the budget does. +- Set `replicas` to the number of agents you want. Where the node pool is backed by a cloud scale set - a managed group that creates identical machines on demand, which every major provider offers under its own name - that number can grow as far as the budget does. - Set a `nodeSelector` so the miner only lands on nodes that have a card. Without it the scheduler will happily place the pod somewhere with nothing to mine on. -- Set `hostAliases` for the pool's domains. Skip this and you will end up explaining to whoever runs the DNS server why those lookups exist. +- Set `hostAliases` for the pool's domains. This pins the names to addresses in the pod's own `/etc/hosts` so the lookups never reach the cluster resolver. The original post recommended this to avoid awkward questions from whoever runs the DNS server, which is worth naming for what it is: that is evasion, and the [companion post](/posts/the-economics-that-make-cryptojacking-work/) argues the defender's side of exactly this. Left in because it is what the post said. -A working example is at [minhtt159/ghcr-tictactoe](https://github.com/minhtt159/ghcr-tictactoe). Fork it, change the wallet address, and it runs. +A working example is at [minhtt159/ghcr-tictactoe](https://github.com/minhtt159/ghcr-tictactoe). The name is left over from what the repository started as, a throwaway for testing GitHub container registry publishing; what it holds is the Dockerfile and workflow described above. Fork it, change the wallet address, and it runs. ## What was actually learned diff --git a/content/posts/the-economics-that-make-cryptojacking-work.md b/content/posts/the-economics-that-make-cryptojacking-work.md index 61fb824..fe822b6 100644 --- a/content/posts/the-economics-that-make-cryptojacking-work.md +++ b/content/posts/the-economics-that-make-cryptojacking-work.md @@ -1,31 +1,33 @@ --- -title: "The arithmetic that makes cryptojacking worth it" +title: "The economics that make cryptojacking work" date: 2021-07-25T17:00:00+07:00 draft: true -description: "Renting GPUs to mine has a return-on-investment calculation that usually says no. Removing the cost line from that calculation is what turns a miner into malware, and it explains why GPU credentials are worth stealing." -summary: "A miner that schedules itself onto any GPU node is a workload like any other, and its economics come down to one subtraction. Drive the cost term to zero and the answer is always yes, which is the whole reason cryptojacking exists." +description: "Renting GPUs to mine has a return-on-investment calculation that usually says no. Removing the cost line from that calculation is what turns a miner into malware, and it explains why GPU credentials are worth stealing. The shape of the argument, without the numbers, which I never wrote down." +summary: "A miner that schedules itself onto any GPU node is a workload like any other, and its economics come down to one subtraction. Drive the cost term to zero and the answer is always yes, which is the whole reason cryptojacking exists. A qualitative argument: the original never recorded the figures." tags: ["security", "threat-model", "kubernetes", "gpu", "cloud"] --- -> *Archive note. Written in July 2021 for a blog I have since retired, when Ethereum still ran on proof of work and EIP-1559 was about to change the yield. The coin is gone and the numbers with it. The economic argument is the part that still holds, and it now applies to whatever GPUs are used for next.* +> *Archive note. Written in July 2021 for a blog I have since retired, when Ethereum still ran on proof of work. EIP-1559, the fee-market change then weeks away, redirected most of each transaction fee to be destroyed rather than paid to whoever mined the block, cutting miner income at a stroke. Ethereum left proof of work entirely the following year. The coin is gone and the numbers with it; the economic argument is the part that still holds, and it applies to whatever GPUs turn out to be scarce for next.* -**TL;DR.** Once a miner is a container scheduled by a replica count, running it on rented cloud GPUs is a straightforward calculation: mined value minus rental cost. That subtraction usually comes out negative, which is why renting to mine is mostly a bad idea and buying the coin is the rational move. The interesting case is what happens to the calculation when the cost term is zero, because someone else is paying for the compute. Then there is no threshold to clear and no break-even to wait for, and that is the entire economic engine behind cryptojacking. If your organisation runs GPUs, that is what makes the credentials in front of them worth stealing. +**TL;DR.** Once a miner is a container scheduled by a replica count, running it on rented cloud GPUs is a straightforward calculation: mined value minus rental cost, minus the fees the miner and the pool take off the top. At mid-2021 rates that subtraction came out negative often enough that renting to mine was mostly not worth doing. The interesting case is what happens when the cost term is zero, because someone else is paying for the compute. Then there is no threshold to clear and no break-even to wait for, and that is the entire economic engine behind cryptojacking. If your organisation runs GPUs, that is what makes the credentials in front of them worth stealing. + +A warning about what this post is not: I never wrote down the instance prices, the hashrates, or what any of it earned. The argument here is about the *shape* of the calculation, and you should not take "usually negative" from me as a measured result. It was my impression at the time. ## Where this picks up -Three months earlier I had been [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/) by passing a GPU into a Windows virtual machine. The conclusion of that exercise was that the passthrough was unnecessary: a container skips it, and once the miner is a container, Kubernetes will place it against an advertised GPU resource like any other workload. +Three months earlier I had been [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/) by passing a GPU into a Windows virtual machine, which is a fiddly way to spend a weekend. Since then someone more experienced had pointed out that the passthrough was unnecessary: a container skips it, and once the miner is a container, Kubernetes will place it against an advertised GPU resource like any other workload. Working through what that implied is what this post is. The build instructions came later, in September, and are folded into that earlier post. -Three months of running it produced some coin, some experience, and one observation I had not expected to make - which is that I had been looking at an attack surface without recognising it. +Three months of running the original setup produced some coin - I did not record how much - some experience, and one observation I had not expected to make, which is that I had been looking at an attack surface without recognising it. ## Renting, and the problem it creates -Cloud providers are generous with GPU instances, and people do rent them for exactly this. Around that time it was common to see acquaintances renting compute VPS capacity to plot Chia. +Cloud providers are generous with GPU instances, and renting one to mine on is entirely possible. Renting cloud capacity to farm a coin was certainly in the air: acquaintances were renting plain compute instances - a VPS, a virtual private server, with no GPU attached - to plot Chia, which is a storage-and-CPU workload rather than a GPU one. That is the same instinct applied to different hardware, not evidence about GPUs, and I should not have offered it as though it were. The obstacle is not renting one instance; it is managing many. A single VM tops out at around four GPUs. Suppose the price moves and you want ten GPU instances. You can deploy them quickly with a tool like `ansible`, but when one of them breaks you have to work out *which* one and go fix it by hand. Ten machines with individual identities is ten things to babysit. Kubernetes answers that directly. On a cloud provider it grows the instance count when you need capacity and shrinks it with one action, and a pod that dies is rescheduled rather than investigated. The unit of work becomes fungible. It does start to sound like passive income. -None of this is new. Back in 2017, when ETH last ran up hard, people were already building on this stack to rent GPUs and mine. In 2018 the price crashed and nobody mentioned it again. +None of this is new. Back in 2017, when ETH last ran up hard, people were already building on this stack to rent GPUs and mine. In 2018 the price crashed and the technique stopped being discussed as a business. That is worth stating precisely, because the rest of this post argues the economics are permanent: what the crash ended was the version where you pay for the compute. The version where you do not has no price floor to fall through, and it never went away. ## When information technology does return on investment @@ -34,14 +36,16 @@ The idea is a few lines of configuration. The hard question is not how, it is wh Buying hardware means accounting for the capital, the cooling, the electricity to run it, and how many months until it pays back. Renting is simpler, and comes down to one subtraction: ``` -profit = value mined - rental cost +profit = value mined - rental cost - miner fee - pool fee ``` -If that is positive you rent. If it is negative you buy the coin on an exchange and skip the engineering, or you leave crypto alone entirely if you have no conviction about it. +The two fee terms are not rounding errors: T-Rex and its peers take about one percent by mining for their own author part of the time, and the pool takes its own cut. A calculation that omits them flatters the rental case. + +If that comes out positive you rent. If it comes out negative, renting is not the way to get exposure to the coin - whether you should buy it instead is a different question with different risk, and nothing in this subtraction answers it. All this arithmetic tells you is whether to run the machines. Three ways to fund the same miner, and what each one has to clear -One container image feeds three funding models. Owning the hardware carries capital, power and cooling and must clear a break-even period. Renting cloud GPUs carries an hourly cost that the mined value has to exceed, which it usually does not. Running on compute someone else pays for carries no cost term at all, so the subtraction has nothing to clear and the answer is always yes. That missing cost line is what makes the third column an attack rather than a business. +The same container image feeds three funding models, so the three columns differ only in who pays for the node underneath. Owning the hardware carries capital, power and cooling, and must clear a break-even period measured in months; this post does not work out whether it does. Renting cloud GPUs carries an hourly cost that the mined value has to exceed, which at the rates of the time it usually did not. Running on compute someone else pays for carries no cost term at all, so the subtraction has nothing to clear and the answer is always yes. That missing cost line, not any difference in the software, is what makes the third column an attack rather than a business. @@ -52,14 +56,14 @@ If that is positive you rent. If it is negative you buy the coin on an exchange - + Own the hardware capital: cards, case, power supply running: electricity, cooling -recovery: walk to the machine +recovery: reschedule; you buy must clear: break-even, in months -answer: sometimes +answer: not worked out here Rent the hardware capital: none @@ -67,7 +71,7 @@ If that is positive you rent. If it is negative you buy the coin on an exchange recovery: reschedule the pod must clear: mined minus rental -answer: usually no +answer: usually no, in 2021 Pay for neither capital: none @@ -90,7 +94,11 @@ Every threshold in the calculation disappears. There is no break-even period to That is not a hypothetical. It is the standing economic reason that idle compute gets stolen, and it says something specific about what an attacker is shopping for. They do not need to reach your data. They need to reach something that can start a process on a machine with a card in it, and they need it to stay unnoticed while it bills you. -Two things fit that description uncomfortably well in a GPU estate. The first is service credentials for an inference endpoint, which by design sit in front of exactly the hardware a miner wants and are handled by more systems than anyone has fully enumerated. The second is anything that shapes how a node comes up - the initialisation path of a GPU scale set, for instance - because it runs with the node's authority on every instance the group ever creates, and nobody reads it after the first time it works. +Two things fit that description uncomfortably well in any organisation that runs GPUs. + +The first is machine-learning serving. Mining is not the only reason to buy a GPU fleet; the far more common one is running models, and a business that serves model predictions puts an inference endpoint - an internal service that takes a request and answers it using a model on a GPU - in front of that hardware. The credentials for such a service are handled by more systems than anyone has fully enumerated, and by design they sit in front of exactly the hardware a miner wants. + +The second is anything that shapes how a node comes up. A cloud scale set - the managed group that creates identical machines on demand, which every major provider offers under its own name - takes an initialisation script that runs on every instance it ever creates, with that instance's authority. If that script can be edited, editing it once is enough, and nobody reads it again after the first time it works. ## What to do about it @@ -99,10 +107,10 @@ The defensive reading is not "watch for miners". Signature-matching a known mine - **Treat inference and GPU-scheduling credentials as money.** Scope them, rotate them, and alert on use from somewhere they have never been used from. They are not read-only API keys; they are a way to spend your compute budget. - **Version and review node initialisation the way you review production code.** A mutable startup script attached to a scale set is an unreviewed root shell that runs on every machine the group creates. - **Alert on utilisation, not on binaries.** Sustained high GPU utilisation with no corresponding job, or instance counts that grow without a workload behind them, describe the condition regardless of which miner is running. The billing anomaly and the capacity anomaly are the same event seen from two directions. -- **Ask what a compromised credential is worth in cash.** For most of the industry this question came into focus only once the hardware became scarce and expensive. GPUs got there first. +- **Ask what a compromised credential is worth in cash.** Most credential risk gets modelled as data loss, because that is what a breach usually costs. A credential in front of a GPU fleet has a second price: what an attacker can bill you for by simply using it. GPUs made that second price obvious earlier than the rest of the estate did, because the hardware was scarce and the resale value of its output was quoted publicly, by the hour. ## Closing EIP-1559 is coming, and at the rates involved renting GPUs legitimately to mine ETH cannot be made profitable anyway. That kills this particular instance of the arithmetic, not the arithmetic. Some future coin, or some future thing GPUs are scarce for, will rebuild the same subtraction, and the same term will be the one worth removing. -Writing this down probably reduces the number of instances I can quietly rent. That trade seems fine. The interesting work was never the mining. +The original version of this post signed off by joking that publishing it would cut the number of instances I could quietly rent. I have kept the line in view because it dates the piece honestly, but it does not survive the argument above it: if renting is not profitable, publishing costs me nothing, and the only version the disclosure could affect is the version I was arguing against. The interesting work was never the mining. From cc59e06f718d8fc6b1a58e175968e28abd19fef4 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:17:11 +0200 Subject: [PATCH 3/8] feat(posts): add the homelab origin story Ports the 2021-03-16 post from the old blog, which the first pass had misfiled with the generic DevOps tutorials. It is not a tutorial: it argues that port forwarding on the ISP modem fails by accumulation, one defensible hole per service, on hardware the owner does not administer, and that a 5 USD VPS running a VPN hub holds the count at zero however long the service list gets. This is the earliest post in the sequence and the decision the rest of the homelab grew out of, so it now sits in front of the mining post. The original carried three raster topology diagrams. They are replaced by one inline SVG contrasting the two shapes, per the repo's rule that topologies get redrawn rather than copied. The home ISP named in the third original diagram is genericised. Co-Authored-By: Claude Opus 5 --- .../five-dollars-instead-of-an-open-port.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 content/posts/five-dollars-instead-of-an-open-port.md diff --git a/content/posts/five-dollars-instead-of-an-open-port.md b/content/posts/five-dollars-instead-of-an-open-port.md new file mode 100644 index 0000000..01c8a64 --- /dev/null +++ b/content/posts/five-dollars-instead-of-an-open-port.md @@ -0,0 +1,121 @@ +--- +title: "Five dollars a month instead of an open port" +date: 2021-03-16T21:50:00+07:00 +draft: true +description: "Every home service you add tempts you to forward one more port on a modem you do not own. Renting a small VPS as a VPN hub costs less than the remote-desktop licence it replaces and means the count of open ports stays at zero." +summary: "The honest reason the homelab exists. Port forwarding on the ISP modem scales badly and scales dangerously: one hole per service, on a box you do not control. A 5 USD VPS turns every connection into an outbound one." +tags: ["homelab", "networking", "vpn", "security"] +--- + +> *Archive note. Written in March 2021 for a blog I have since retired, and the earliest post in this sequence - everything else here grew out of it. The prices and the specific products are of their time. The ISP is genericised. What follows next 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. The obvious answers are a hosted remote-desktop product or forwarding a port on the ISP modem. The product costs more than a small VPS and does less; the port forward is free but puts a listening service on a box you do not own, that your ISP may forbid you to change, and that the whole internet can reach. Renting the cheapest VPS available at 5 USD a month and running a VPN on it inverts the direction: every device dials out to the hub, sees every other device as though they shared a LAN, and the number of inbound holes at home stays zero. The reason that matters is not the first service. It is the fourth. + +## The network everyone starts with + +Home networks are not standardised. Two flats with identical floor plans end up wired differently depending on who lives there. The common shape is an ISP modem and router combo with everything hanging off it: a PC on Ethernet, a laptop and a phone on wifi, maybe a second router or access point bridging a far room. + +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. + +## The obvious products, and what they cost + +The ready-made answers are the hosted remote-desktop tools, and they work well at the job they do. Each has a catch: + +- **TeamViewer** is free only if your use counts as personal, and the moment it decides otherwise you are paying. The licence I was comparing against was 24.90 USD. +- **AnyDesk** is the same shape with fewer features. +- **Chrome Remote Desktop** is genuinely free and it is the one I would point a non-technical person at. It also renegotiates to a direct route rather than hauling traffic through Google's servers. The catch is that it runs in Chrome, so some key combinations never reach the remote machine - press Ctrl-W expecting to close a window over there and you close a tab over here. + +I was lazy and short of money, which is a combination that pushes you toward the option that solves more than one problem. The cheapest VPS on offer was 5 USD a month. That is less than the remote-desktop licence, and a VPS is a general-purpose machine rather than a single-purpose product. + +Point the devices at a VPN running on that VPS and they stop being machines on separate networks. They see each other as if they were on one LAN, which means the free, built-in remote-desktop tools - RDP, VNC - just work, with no third party in the path and no product tier to worry about. Laptop, phone, tablet, all reaching the machines at home, for 5 USD a month. + +## The port forward, and why I did not + +The objection is immediate and fair: run the VPN server on the ISP modem itself, forward a port, and save the 5 USD. If the contract does not include a static IP, dynamic DNS services will hand you a free domain that tracks your address as it changes. People do this and it works. + +I am not going to call it wrong. I will say what it costs, because the cost is not obvious when there is only one service. + +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, ships its firmware, and in many contracts reserves the right to refuse you the configuration anyway. That is a large attack surface for a saving of 5 USD, and the asymmetry is unpleasant: you are betting the security of everything behind that modem against a rounding error on a monthly bill. + +Since I already owned a domain, I bought the VPS and pointed Cloudflare at it. That part was preference rather than necessity. + +## Then it happens again + +Here is the part that changes the arithmetic, and the reason this post exists. + +You put up a security camera. It has a box, the box wants a port, you forward one so the phone app can see the feed. Fine - one port. + +A few days later you want to host something at home, a blog perhaps. A VM, and a port exposed to the internet. Two. + +A few days after that it is home automation: the garage door, the water heater, the lights. Another VM, another port. Three. + +Then there is the several hundred gigabytes of material on the PC that you would like to watch in bed, or away from the house entirely. Four. + +The list of things you might want to run at home does not terminate. Each one, taken alone, is one small hole and a reasonable trade. Taken together they are a steadily growing inbound attack surface on hardware you do not control, added one defensible decision at a time. That is what makes it dangerous: no single step feels like the wrong call. + +The VPN hub does not make any individual service safer. It makes the count stop growing. Every service stays bound to the private network, every client arrives through the tunnel, and the number of ports open on the modem stays at zero no matter how long the list of services gets. + + +A port per service versus one outbound tunnel +Left: the internet reaches the ISP modem, and each home service the owner adds - camera, blog, home automation, media - needs its own forwarded inbound port on that modem, so the number of internet-reachable listeners grows with the number of services, on hardware the owner does not administer. Right: a rented VPS runs a VPN hub. The modem opens nothing. Devices away from home and the home network itself both dial outward to the hub and meet there, so adding services leaves the count of inbound ports at zero. + + + + + + +A port for each thing +the count grows with the service list + +internet + + +ISP modem +4 inbound ports open + + + + + +camera + +blog + +home auto + +media +every listener is reachable from the whole internet +on a box the ISP chose, ships firmware for, and may lock +no single one of these feels like the wrong call + +One tunnel out +5 USD a month, nothing listening at home + +VPS +VPN hub + +laptop, phone +anywhere + +ISP modem +0 ports open + + + +home services +the same four, and the next + +both ends dial outward and meet on the hub +add the fifth service and the port count is still zero + +The VPN does not make any one service safer +It stops the number of internet-reachable listeners growing with the number of things you run. + + +## What this actually was + +I wrote this as a log of my own decisions, so that when something broke months later I would know where to look, and on the off-chance it helped somebody else circling the same choice. + +Reading it back, it is the load-bearing decision. Once every machine at home sits on one private network that outside devices can join, the questions stop being about connectivity and start being about capacity: what else could run here, which box should run it, and what happens when one of them dies. That is a homelab. It started as an argument about 5 USD and a port. From d23b40ef13c5aca7603188a9e93274de791eee75 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:24:50 +0200 Subject: [PATCH 4/8] fix(posts): admit the VPS is the listener now A cold read of the origin post returned FIX FIRST, and its central objection was correct: the post spent its length arguing against internet-facing listeners while proposing a design that has one. A VPN server on a public address is exactly the thing being condemned. The claim is now the honest one. The hub does not remove the exposure, it relocates it: four listeners on ISP firmware nobody can patch become one listener on a machine the owner administers, and that count does not move when the service list grows. The diagram says this too - red marks an internet-facing listener in both panels, so the single red arrow on the right is the admission rather than an omission. Other corrections from the same read: - The cost comparison had no billing period on the 24.90 USD figure, so it could not support "cheaper". The source never recorded the period; the post now says so and drops the claim. - Chrome Remote Desktop was described as free two bullets before the summary asserted the products cost more than a VPS. The post now admits the remote desktop was the excuse and a general-purpose machine was the reason. - "No third party in the path" was false. The rented box is in the path for every packet, and unlike a direct-routing product it always is. - The modem cannot be both unconfigurable and a viable place to run a VPN. Where the ISP locks the firmware the alternative is unavailable, which is a different objection than insecurity. - A camera appliance cannot run a VPN client, and it was the first example in the escalation. Appliances stay on the LAN; clients join the tunnel and reach them. - One reverse proxy collapses three HTTP services onto one port, so the port count grows only if you let it. That makes the two designs closer relatives than the post first implied, and it now says so. - Added what the design costs: a server that must be patched forever, a single point of failure that takes the recovery path with it, and all traffic transiting the cheapest instance available. One real drawing bug: the home-side flow line carried no marker-end while every other line did, so the direction the whole argument rests on was not drawn. All nine lines now have arrowheads. Co-Authored-By: Claude Opus 5 --- .../five-dollars-instead-of-an-open-port.md | 131 +++++++++++------- 1 file changed, 79 insertions(+), 52 deletions(-) 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 01c8a64..5f6bc68 100644 --- a/content/posts/five-dollars-instead-of-an-open-port.md +++ b/content/posts/five-dollars-instead-of-an-open-port.md @@ -2,14 +2,16 @@ title: "Five dollars a month instead of an open port" date: 2021-03-16T21:50:00+07:00 draft: true -description: "Every home service you add tempts you to forward one more port on a modem you do not own. Renting a small VPS as a VPN hub costs less than the remote-desktop licence it replaces and means the count of open ports stays at zero." -summary: "The honest reason the homelab exists. Port forwarding on the ISP modem scales badly and scales dangerously: one hole per service, on a box you do not control. A 5 USD VPS turns every connection into an outbound one." +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." tags: ["homelab", "networking", "vpn", "security"] --- -> *Archive note. Written in March 2021 for a blog I have since retired, and the earliest post in this sequence - everything else here grew out of it. The prices and the specific products are of their time. The ISP is genericised. What follows next chronologically is [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/).* +> *Archive note. Written in March 2021 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 home ISP is genericised. 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. The obvious answers are a hosted remote-desktop product or forwarding a port on the ISP modem. The product costs more than a small VPS and does less; the port forward is free but puts a listening service on a box you do not own, that your ISP may forbid you to change, and that the whole internet can reach. Renting the cheapest VPS available at 5 USD a month and running a VPN on it inverts the direction: every device dials out to the hub, sees every other device as though they shared a LAN, and the number of inbound holes at home stays zero. The reason that matters is not the first service. It is the fourth. +**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. + +This post is an argument, not a runbook. The original never recorded which VPN software I used, so I am not going to pretend otherwise - the shape of the design is what carried over, and the shape is what is worth reading. ## The network everyone starts with @@ -19,61 +21,73 @@ That is enough for what most people need. Web, streaming, games with low latency Then one day you want to reach the PC at home while you are not at home, and to do it safely. -## The obvious products, and what they cost +## The ready-made answers -The ready-made answers are the hosted remote-desktop tools, and they work well at the job they do. Each has a catch: +The hosted remote-desktop tools do this job and do it well: -- **TeamViewer** is free only if your use counts as personal, and the moment it decides otherwise you are paying. The licence I was comparing against was 24.90 USD. +- **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. - **AnyDesk** is the same shape with fewer features. -- **Chrome Remote Desktop** is genuinely free and it is the one I would point a non-technical person at. It also renegotiates to a direct route rather than hauling traffic through Google's servers. The catch is that it runs in Chrome, so some key combinations never reach the remote 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 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. + +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. -I was lazy and short of money, which is a combination that pushes you toward the option that solves more than one problem. The cheapest VPS on offer was 5 USD a month. That is less than the remote-desktop licence, and a VPS is a general-purpose machine rather than a single-purpose product. +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. -Point the devices at a VPN running on that VPS and they stop being machines on separate networks. They see each other as if they were on one LAN, which means the free, built-in remote-desktop tools - RDP, VNC - just work, with no third party in the path and no product tier to worry about. Laptop, phone, tablet, all reaching the machines at home, for 5 USD a month. +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. ## The port forward, and why I did not -The objection is immediate and fair: run the VPN server on the ISP modem itself, forward a port, and save the 5 USD. If the contract does not include a static IP, dynamic DNS services will hand you a free domain that tracks your address as it changes. People do this and it works. +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. -I am not going to call it wrong. I will say what it costs, because the cost is not obvious when there is only one service. +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. -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, ships its firmware, and in many contracts reserves the right to refuse you the configuration anyway. That is a large attack surface for a saving of 5 USD, and the asymmetry is unpleasant: you are betting the security of everything behind that modem against a rounding error on a monthly bill. +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. -Since I already owned a domain, I bought the VPS and pointed Cloudflare at it. That part was preference rather than necessity. +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. ## Then it happens again Here is the part that changes the arithmetic, and the reason this post exists. -You put up a security camera. It has a box, the box wants a port, you forward one so the phone app can see the feed. Fine - one port. +You put up a security camera. Its recorder wants to be reachable so the phone app can see the feed, so you forward a port. Fine - one. + +A few days later you want to host something at home, a blog perhaps. A VM, another port. Two. + +Then home automation: the garage door, the water heater, the lights. Another VM, another port. Three. + +Then the several hundred gigabytes of material on the PC that you want to watch from outside the house. Four. + +The list does not terminate. Each addition, alone, is one small hole and a reasonable trade. Together they are a steadily growing inbound attack surface on hardware you do not control, assembled one defensible decision at a time. That is what makes it dangerous: no single step feels like the wrong call. -A few days later you want to host something at home, a blog perhaps. A VM, and a port exposed to the internet. Two. +Two honest caveats before the conclusion. -A few days after that it is home automation: the garage door, the water heater, the lights. Another VM, another port. Three. +**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. -Then there is the several hundred gigabytes of material on the PC that you would like to watch in bed, or away from the house entirely. Four. +**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. -The list of things you might want to run at home does not terminate. Each one, taken alone, is one small hole and a reasonable trade. Taken together they are a steadily growing inbound attack surface on hardware you do not control, added one defensible decision at a time. That is what makes it dangerous: no single step feels like the wrong call. +## What the hub actually buys -The VPN hub does not make any individual service safer. It makes the count stop growing. Every service stays bound to the private network, every client arrives through the tunnel, and the number of ports open on the modem stays at zero no matter how long the list of services gets. +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. + +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. -A port per service versus one outbound tunnel -Left: the internet reaches the ISP modem, and each home service the owner adds - camera, blog, home automation, media - needs its own forwarded inbound port on that modem, so the number of internet-reachable listeners grows with the number of services, on hardware the owner does not administer. Right: a rented VPS runs a VPN hub. The modem opens nothing. Devices away from home and the home network itself both dial outward to the hub and meet there, so adding services leaves the count of inbound ports at zero. +Where the internet-facing listener lives, in two designs +Both designs have something listening on the internet; the diagram is about how many and on whose hardware. Left: the internet reaches the ISP modem, and each service the owner adds - camera, blog, home automation, media - needs its own forwarded inbound port there, so the number of internet-reachable listeners grows with the service list, on firmware the owner did not choose and cannot patch. Right: the internet reaches one listener, the VPN server on a rented virtual server the owner administers. The ISP modem forwards nothing; the home network and any device away from home both dial outward to that hub and meet on it. Adding a fifth service leaves the listener count at one. Red marks an internet-facing listener in both panels, so the single red arrow on the right is the honest admission that the exposure moved rather than vanished. -A port for each thing +Four listeners, none of them yours the count grows with the service list - -internet - + +internet + ISP modem -4 inbound ports open +4 ports forwarded @@ -86,36 +100,49 @@ The VPN hub does not make any individual service safer. It makes the count stop home auto media -every listener is reachable from the whole internet -on a box the ISP chose, ships firmware for, and may lock +firmware you did not choose and cannot patch +when a bug lands in it, your option is to wait no single one of these feels like the wrong call -One tunnel out -5 USD a month, nothing listening at home - -VPS -VPN hub - -laptop, phone -anywhere - -ISP modem -0 ports open - - - -home services -the same four, and the next - -both ends dial outward and meet on the hub -add the fifth service and the port count is still zero +One listener, and it is yours +the count does not move when the list grows + +internet + + +VPS: VPN hub +1 listener, yours to patch + +laptop, phone +away from home + + +ISP modem +0 ports forwarded + + +home LAN +the four, and the next + +green dials outward; nothing at home accepts a connection +appliances never join the tunnel - the clients do, and reach them -The VPN does not make any one service safer -It stops the number of internet-reachable listeners growing with the number of things you run. +The hub does not remove the exposure. It relocates it. +Four listeners on firmware you cannot patch become one on a machine you can, and the fifth service adds none. +## 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, by me, indefinitely. A VPN server left unattended for two years is worse than the port forward it replaced, because at least the ISP occasionally ships firmware. + +**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. + +**Everything transits the rented box.** Including, if you go through with the media plan, video. On the cheapest instance available, which comes with the bandwidth allowance you would expect at that price. Watching something in bed never needed the tunnel - that traffic is local - but watching it from elsewhere does, and that is where this design meets its limit. + ## What this actually was -I wrote this as a log of my own decisions, so that when something broke months later I would know where to look, and on the off-chance it helped somebody else circling the same choice. +I wrote it as a log of my own decisions, so that when something broke months later I would know where to look, and on the chance it helped somebody circling the same choice. -Reading it back, it is the load-bearing decision. Once every machine at home sits on one private network that outside devices can join, the questions stop being about connectivity and start being about capacity: what else could run here, which box should run it, and what happens when one of them dies. That is a homelab. It started as an argument about 5 USD and a port. +Reading it back, it is the load-bearing one. Once every machine at home sits on one private network that outside devices can join, the questions stop being about connectivity and start being about capacity: what else could run here, which box should run it, and what happens when one of them dies. That is a homelab. It started as an argument about 5 USD and a port. From afea4ded6cc7ee4472df92da4f3d224b0d286729 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:33:56 +0200 Subject: [PATCH 5/8] fix(posts): rewrite Merklision against the actual challenge source Read the gist rather than relying on the 2020 writeup's summary of itself, and re-ran the solver. Several things the post asserted were wrong. The second-preimage rewrite does not submit the internal nodes. The implementation hashes every element before pairing any of them, so the element that reproduces a node is the concatenation of that node's two child digests - twelve bytes, not the six-byte node. The post and the first diagram both said the wrong thing; both now say M(L1, L2, L3, L4) == M(h(L1)||h(L2), h(L3)||h(L4)) which is what the solver's `hashes[i] + hashes[i+1]` actually computes, `+` on bytes being concatenation. Where 2047 comes from is now derived rather than shrugged at. The answers come out one per list length, from 2 up to the padding ceiling of 2048, so the count is 2048 - 2 + 1. It is not 2^11 - 1; the resemblance is the ceiling being a power of two. Re-running the solver reproduces 2047 with every length present exactly once. The set also includes the original tuple, so there are 2046 genuine collisions, and the post says so rather than leaving two defensible numbers implicit. Other corrections from the source: - The bound is `assert 0 <= n <= 2020`, stated exactly. - A duplicate submission trips an assertion and ends the session. The draft said repeats were silently discarded. - There is no check that a submission differs from the original, only that the 2020 are pairwise distinct, so the original is a legal answer. The draft claimed otherwise. - Submissions are base64-decoded to arbitrary bytes, so there is no alphabet restriction. The draft had invented one and built a paragraph on it about why the composition's last step mattered. - The hash is SHA-256 truncated to six bytes, which the draft never mentioned. SVATTT expands to the ASEAN Student Contest in Information Security. The draft called it a Vietnamese inter-university competition. Adds a second diagram, for the length ladder that produces 2047. Co-Authored-By: Claude Opus 5 --- .../merklision-merkle-second-preimage.md | 141 +++++++++++++----- 1 file changed, 103 insertions(+), 38 deletions(-) diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md index f5a57bb..eb7931e 100644 --- a/content/posts/merklision-merkle-second-preimage.md +++ b/content/posts/merklision-merkle-second-preimage.md @@ -13,35 +13,45 @@ tags: ["ctf", "cryptography", "merkle-tree", "writeup"] ## The challenge -This was the crypto challenge in the SVATTT 2020 online qualifier - Sinh vien voi An toan thong tin, the Vietnamese inter-university student security competition - 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 percent thinking and 50 percent code. `ndh` writes challenges that are 80 percent thinking and 20 percent 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, 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. The server does three things: -1. Reads an integer `n` from you, and generates a random string of `n` elements, each a letter from `a-zA-Z`. -2. Shows you that string and asks for 2020 further strings, pairwise distinct and distinct from the original. It collects them in a set, so exact repeats are silently discarded rather than rejected. -3. Accepts if every one of them has the same Merkle hash as the generated string. +1. Reads an integer `n` from you and asserts `0 <= n <= 2020`, then builds a tuple of `n` single-letter byte strings drawn from `a-zA-Z`. +2. Shows you that tuple and reads 2020 more. Each one must be pairwise distinct from the others - a repeat trips an assertion and ends the session, it is not silently dropped - and each must have the same Merkle hash. +3. Prints the flag. -`n` has to stay below 2020. That cap is what makes this a challenge rather than a formality - without it you would pick an enormous `n` and let a single rule run away. You choose `n` within the cap, and that choice turns out to be the whole exploit. +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. ## 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. 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 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. + +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. -Two pieces of notation for the rest of this post. `H(a,b)` is the hash of one adjacent pair, one internal node of the tree. `M(...)` is the Merkle hash of a whole input list, the root you get after collapsing every row. The challenge compares `M` values. +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 property that matters here is that the tree is built bottom-up from a list, and the intermediate rows are hashes of exactly the same shape as the leaf row. The construction hashes pairs without recording which row they came from, so as far as `M` is concerned, the rows are interchangeable. That is the root cause of everything below. +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. ## Rule one: the classic second preimage A second-preimage attack means: given one input, find a different input with the same hash. For an unvalidated Merkle construction this is not hard. -Take leaves `L1, L2, L3, L4`. The next row up is `H(L1,L2)` and `H(L3,L4)`, and the root is `H` of those two. Now hand the same implementation a two-element input consisting of `H(L1,L2), H(L3,L4)` directly. It hashes them as a pair, gets the same root, and has no way to tell the difference. Writing the leaves as `1, 2, 3, 4` for brevity: +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. ``` -M(1, 2, 3, 4) = M(H(1,2), H(3,4)) +M(L1, L2, L3, L4) == M(h(L1)||h(L2), h(L3)||h(L4)) ``` -Every level of the tree gives you one such rewrite, so a tree over `n` leaves gives about `log2(n)` of them. With `n` capped below 2020 that is about ten. Two hundred times short. +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. + +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. ## Rule two: the odd-length duplication @@ -55,7 +65,7 @@ That is worth one collision per odd row, and there are only about ten rows. Anot Two Merkle collision rules and their composition -Left: the generated input of four leaves hashes pairwise into two intermediate nodes and then one root. Right: the same two intermediate node values, byte for byte identical to the ones on the left, submitted as a fresh two-element input. They reproduce the same root, because the construction never records which row a value came from. Colour marks role, not value: blue is what the server generated, red is what the attacker submits. Below: the odd-length rule duplicates a trailing element without changing the hash, and the chain of four steps shows the two rules being alternated, which is what the prose derives the collision count from. +Left: the four generated leaves are hashed to digests, each adjacent pair of digests is concatenated and hashed into an internal node, and the two nodes combine into the root. Right: a two-element submission where each element is the concatenation of two child digests. Because the implementation hashes every element before pairing, hashing those concatenations reproduces the internal nodes exactly, and therefore the same root. Note the elements submitted are the concatenated digests, twelve bytes each, not the six-byte internal nodes. Colour marks role: blue is what the server generated, red is what the attacker submits. Below: the odd-length rule repeats a trailing element without changing the root, and alternating the two rules is what produces the collision count the post derives. @@ -66,10 +76,10 @@ That is worth one collision per odd row, and there are only about ten rows. Anot four leaves, hashed pairwise upward root - -H(L1,L2) - -H(L3,L4) + +h(h(L1)||h(L2)) + +h(h(L3)||h(L4)) L1 @@ -87,18 +97,18 @@ That is worth one collision per odd row, and there are only about ten rows. Anot input length 4 Rule one: feed the middle row back in -the tree never records which row an element came from +submit what hashes to the row above, not the row itself root - -H(L1,L2) - -H(L3,L4) + +h(L1)||h(L2) + +h(L3)||h(L4) -same bytes as the blue row on the left, now the whole input -same root, different string, counts as a collision -red marks what the attacker submits, not a different value +12 bytes each: the child digests, concatenated +hashing them reproduces the blue row, so the root matches +not the 6-byte nodes themselves - elements get hashed first input length 2 Rule two, and why composing them multiplies @@ -113,28 +123,83 @@ Treating the two rules as separate buckets gives twenty collisions. They are not Start from `1, 2, 3, 4, 5`: 1. Odd length, so pad: collides with `1, 2, 3, 4, 5, 5`. -2. That is six elements, so collapse a level: collides with `H(1,2), H(3,4), H(5,5)`. -3. That row is odd, so pad again: collides with `H(1,2), H(3,4), H(5,5), H(5,5)`. -4. Expand that back down a level and you get `1, 2, 3, 4, 5, 5, 5, 5`, which is a plain string of the original alphabet. +2. That is six elements, so collapse a level: collides with the three concatenated digest pairs of that row. +3. That row is three long, so pad it: repeat its last element to get four. +4. Expand back down a level and you are holding `1, 2, 3, 4, 5, 5, 5, 5`, a plain list of the original single-letter elements again. -One five-element input produced four collisions from the bottom two levels alone, without touching the levels above, and the last of them is longer than anything the first rule reaches on its own. There is a ceiling: padding cannot grow an input past the first power of two above `n`, which for `n = 5` is 8. +One five-element input produced four collisions from the bottom two levels alone, and the last of them is longer than anything the first rule reaches by itself. There is a ceiling: padding cannot grow an input past the first power of two at or above `n`, which for `n = 5` is 8. -Note what step 4 bought. Steps 2 and 3 pass hash values around as list elements, which works because `M` cannot tell a hash from a leaf. Step 4 expands back down and lands on `1, 2, 3, 4, 5, 5, 5, 5` - a string drawn entirely from the original alphabet. That matters if the server is fussy about what a submission may contain, and it is the step that makes the technique robust rather than clever. +An earlier draft of this post claimed step 4 mattered because it lands back in the original alphabet, and that the server might be fussy about what a submission contains. Reading the source, it is not: elements arrive base64-decoded as arbitrary bytes and nothing inspects them. Rows of concatenated digests are just as acceptable as rows of letters. Step 4 is a consequence of the composition, not a precaution. -So the choice of `n` is the actual exploit. You want every level of the tree to land on an odd count, so that the padding rule fires at each one on the way up. Working the recurrence downward, `n = (((2*2-1)*2-1)*2-1)...` while staying under the cap gives `n = 1025`, whose tree is 11 levels deep and whose padding ceiling is 2048. +So the choice of `n` is the actual exploit. You want every row on the way up to have an odd count, so the padding rule fires at each one. Working the recurrence downward, `n = (((2*2-1)*2-1)*2-1)...` while staying under the cap gives `n = 1025`, whose padding ceiling is 2048. -How many collisions does that yield? At the time I wrote this, I did not know. I still do not have a closed form for it. What follows is a count I measured, not one I derived, and the honest version of this writeup says so rather than dressing up the output of a brute-force loop as a result. +## Where 2047 comes from + +The original writeup asked how many collisions that yields and answered, in as many words, that I did not know. Having the source back, the answer is exact and a little deflating. + +Every answer the search produces is a list, and the lists come out at one distinct length each, from 2 elements up to 2048. That is it. One answer per length: + +``` +2048 - 2 + 1 = 2047 +``` + +The ceiling of 2048 is the padding limit, the floor of 2 is the shortest row a Merkle tree can have above the root, and every length in between is reachable. The requirement was 2020, so the margin was 27 lengths - closer than it felt at the time. + +One wrinkle worth stating because it affects the number you can actually submit: the solver seeds its result set with the original tuple before searching, so 2047 counts the input itself. There are 2046 genuine collisions. The challenge never checks a submission against the original, only that the 2020 are pairwise distinct, so submitting it is legal and the point is moot - but 2047 and 2046 both being defensible answers is exactly the sort of thing a writeup should not leave implicit. + +I re-ran the solver against the original challenge source while preparing this post. It reproduces 2047, every length from 2 to 2048 present exactly once, in about five seconds on current hardware. + + +Why the answer count is 2047 +The search emits one valid answer at each possible list length. The shortest is a two-element list, the longest is a 2048-element list, which is the padding ceiling for a 1025-element input, and every length in between is reachable exactly once. The count is therefore 2048 minus 2 plus 1, which is 2047, against a requirement of 2020 - a margin of 27. The original input, 1025 elements long, sits inside that range and is counted by the solver, so 2046 of the 2047 are collisions distinct from it. + + + +One answer per list length +every length from 2 to 2048 is reachable, and each is reached exactly once + +length 2 +shortest row + +length 3 + +length 4 + +... + +length 1025 +the original + +length 2048 +padding ceiling + + + + + + +The count +2048 - 2 + 1 = 2047 +required: 2020 +margin: 27 lengths + +2047 or 2046? +2047 counts the original input, which the solver +seeds into its own result set before searching. +2046 are collisions distinct from it. Either clears +2020, and the challenge accepts the original too. + ## Solving it There is a clever way to enumerate the collisions and a stupid way. I took the stupid way, because the challenge scores a flag and not an algorithm: -1. The first level gets padded regardless, so loop, repeating the last element until the string reaches length 2048. -2. For each candidate, compute its Merkle hash and keep it only if it matches the target, recording the collisions found across its levels by applying rule one. -3. Push every survivor into a set, the same structure the challenge uses, which drops the duplicates. There are a lot of duplicates. -4. Send the set back to the server. +1. Take the original tuple and append copies of its last element, one more each time, walking every length from 1025 up to 2048. +2. Keep a padded candidate only if its Merkle hash still equals the target. Most lengths do not survive this. +3. For each survivor, walk the tree and collect the rewrite rows that rule one licenses - the concatenated digest pairs at each row where padding occurred. Add the survivor itself alongside them. +4. Check every one of those against the target again, drop anything already seen, and keep the rest. -Step 2 is doing the filtering. Padding to an arbitrary length does not collide in general - a 1027-element input pads to a different node count than the original and produces a different root - so the loop generates candidates freely and the hash comparison throws most of them away. That is what makes this the stupid way: the search is not constructed to only emit valid answers, it is constructed to emit many answers cheaply and check them all. +Step 2 is the filter, and it is why this is the stupid way. Padding to an arbitrary length does not collide in general; the loop emits candidates cheaply and lets the hash comparison discard most of them, rather than being constructed to only produce valid ones. The recorded rewrites in step 3 come from the rows the implementation had to pad, which is exactly where the two rules meet. ```console $ python ./merklision.py @@ -143,9 +208,9 @@ There are 2047 collisions Takes 11 sec to produce ``` -2047 against a requirement of 2020, in eleven seconds. Source is in [the merklision solver gist](https://gist.github.com/minhtt159/af8e19e2ac7088be48889ccd5c6e0e0b). +2047 against a requirement of 2020, in eleven seconds on 2020 hardware. Both the challenge and the solver are in [the merklision gist](https://gist.github.com/minhtt159/af8e19e2ac7088be48889ccd5c6e0e0b); every claim in this post about how the challenge behaves was checked against that source rather than against my memory of it, and several of them needed correcting. -2047 is `2^11 - 1`, and 1025 leaves give an 11-level tree. That is a suggestive coincidence and I want to be careful with it: it looks like one binary choice per level with the do-nothing case removed, but I never proved that, and the small case does not obviously match - `n = 5` gives a 3-level tree and I counted 4 collisions from its lower levels, not 7. Treat the formula as a guess and the 2047 as an observation. +Note what 2047 is not. It is tempting to read it as `2^11 - 1`, one binary choice per level of a 1025-element tree with the empty case removed, and I believed something like that for years. The lengths say otherwise: the answers are one per list length from 2 to 2048, so the count is a range size and the resemblance to a power of two is just the padding ceiling being one. ## What it was worth From 4f37f2bb2677cd9c79777c5375f440fbcc22bb03 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:49:38 +0200 Subject: [PATCH 6/8] feat(posts): name the VPN, and the provider it ran on Minh supplied what the original post never recorded: the VPN was WireGuard, deployed with Algo, the Trail of Bits Ansible scripts, on a virtual machine from Viettel IDC. Both facts carry real weight in the argument, so neither is a footnote. Algo's opinions are half of why exposing this server is defensible: it refuses to install OpenVPN or Tor, drops L2TP, IKEv1 and RSA, does not rest on TLS, and builds on an Ubuntu LTS with unattended security upgrades. That last one partly answers the post's own objection about having to patch the replacement forever, so the cost section now says what it covers and what it does not - distribution packages, yes; the day the LTS goes end of life, no. WireGuard closes the harder gap. It does not reply to packets that fail authentication, so the one remaining listener does not announce itself to a port scan, which is a different exposure profile from a forwarded port where whatever sits behind it answers anyone who knocks. That is the strongest available answer to the objection the previous commit conceded, and it is now in the prose, the diagram label and the description. Viettel IDC was the hosting arm of the same carrier terminating the line at home, so tunnel traffic stayed inside one national network instead of transiting Singapore or Frankfurt. For a tunnel every interactive session crosses, that is the difference between a remote desktop feeling local and feeling remote - and it is the piece that stopped making sense when he moved to the Netherlands within the year. The disclosure rule in blog-inspirations.md is scoped to current employer material, so a personal 2021 hosting provider is nameable. The home ISP stays generic; the argument never needed it. Co-Authored-By: Claude Opus 5 --- .../five-dollars-instead-of-an-open-port.md | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) 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 5f6bc68..d0443b3 100644 --- a/content/posts/five-dollars-instead-of-an-open-port.md +++ b/content/posts/five-dollars-instead-of-an-open-port.md @@ -7,11 +7,11 @@ summary: "The honest reason the homelab exists. Port forwarding on the ISP modem tags: ["homelab", "networking", "vpn", "security"] --- -> *Archive note. Written in March 2021 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 home ISP is genericised. What follows chronologically is [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/).* +> *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. -This post is an argument, not a runbook. The original never recorded which VPN software I used, so I am not going to pretend otherwise - the shape of the design is what carried over, and the shape is what is worth reading. +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, 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 @@ -31,10 +31,22 @@ The hosted remote-desktop tools do this job and do it well: 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. +Buying domestically mattered more than the price did. The instance was at Viettel IDC, the hosting business of the carrier already terminating the line at my flat, so traffic between home and hub stayed inside one national network instead of taking a detour through Singapore or Frankfurt to come back. For a tunnel that every interactive session traverses, that is the difference between a remote desktop feeling local and feeling remote. It is also the part of this design that did not survive moving continents. + 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. +## 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. + +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. + +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. @@ -73,7 +85,7 @@ I spent this entire post arguing against internet-facing listeners, so let me st Where the internet-facing listener lives, in two designs -Both designs have something listening on the internet; the diagram is about how many and on whose hardware. Left: the internet reaches the ISP modem, and each service the owner adds - camera, blog, home automation, media - needs its own forwarded inbound port there, so the number of internet-reachable listeners grows with the service list, on firmware the owner did not choose and cannot patch. Right: the internet reaches one listener, the VPN server on a rented virtual server the owner administers. The ISP modem forwards nothing; the home network and any device away from home both dial outward to that hub and meet on it. Adding a fifth service leaves the listener count at one. Red marks an internet-facing listener in both panels, so the single red arrow on the right is the honest admission that the exposure moved rather than vanished. +Both designs have something listening on the internet; the diagram is about how many and on whose hardware. Left: the internet reaches the ISP modem, and each service the owner adds - camera, blog, home automation, media - needs its own forwarded inbound port there, so the number of internet-reachable listeners grows with the service list, on firmware the owner did not choose and cannot patch. Right: the internet reaches one listener, a WireGuard server on a rented virtual machine the owner administers, which does not answer packets that fail authentication and so does not announce itself to a port scan. The ISP modem forwards nothing; the home network and any device away from home both dial outward to that hub and meet on it. Adding a fifth service leaves the listener count at one. Red marks an internet-facing listener in both panels, so the single red arrow on the right is the honest admission that the exposure moved rather than vanished. @@ -110,8 +122,8 @@ I spent this entire post arguing against internet-facing listeners, so let me st internet -VPS: VPN hub -1 listener, yours to patch +VPS: WireGuard +1 listener, silent to scans laptop, phone away from home @@ -135,7 +147,7 @@ I spent this entire post arguing against internet-facing listeners, so let me st 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, by me, indefinitely. A VPN server left unattended for two years is worse than the port forward it replaced, because at least the ISP occasionally ships firmware. +**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. **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. From 684ff1167941f995ae5442def703a4d6d538b4f4 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:52:11 +0200 Subject: [PATCH 7/8] docs(posts): say what Viettel IDC is, for readers outside Vietnam The provider was named without context, which assumes a reader who already knows it. Both mentions now place it: a Vietnamese cloud provider whose small instances sit where an equivalent DigitalOcean droplet sits. That also repairs the cost reasoning. The post argued for buying domestically on latency grounds while leaving the impression that the price was a concession, when it was competitive on its own terms. Co-Authored-By: Claude Opus 5 --- content/posts/five-dollars-instead-of-an-open-port.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d0443b3..7149fdb 100644 --- a/content/posts/five-dollars-instead-of-an-open-port.md +++ b/content/posts/five-dollars-instead-of-an-open-port.md @@ -11,7 +11,7 @@ tags: ["homelab", "networking", "vpn", "security"] **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. -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, 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 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. ## The network everyone starts with @@ -31,7 +31,7 @@ The hosted remote-desktop tools do this job and do it well: 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. -Buying domestically mattered more than the price did. The instance was at Viettel IDC, the hosting business of the carrier already terminating the line at my flat, so traffic between home and hub stayed inside one national network instead of taking a detour through Singapore or Frankfurt to come back. For a tunnel that every interactive session traverses, that is the difference between a remote desktop feeling local and feeling remote. It is also the part of this design that did not survive moving continents. +Buying domestically mattered more than the price did, though the price was competitive anyway - Viettel IDC's small instances sit where an equivalent DigitalOcean droplet sits. The machine was with the hosting business of the carrier already terminating the line at my flat, so traffic between home and hub stayed inside one national network instead of detouring through Singapore or Frankfurt and back. For a tunnel that every interactive session traverses, that is the difference between a remote desktop feeling local and feeling remote. It is also the part of this design that did not survive moving continents. 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. From 3962e0b801c2b3468c34eb5152ab38acc88e5fa4 Mon Sep 17 00:00:00 2001 From: Minh Tran <4847344+minhtt159@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:25:15 +0200 Subject: [PATCH 8/8] feat(posts): publish the four migrated posts Flips draft to false on the material ported from the retired teamkhunglong.github.io blog, after Minh's read. Verified before flipping: hugo --minify builds with no ERROR and emits all four; content/ is ASCII throughout; no post carries a hex value, fill= or stroke=; all five inline SVGs render as markup with nothing escaped by Goldmark; and no employer name appears in the output. Co-Authored-By: Claude Opus 5 --- content/posts/five-dollars-instead-of-an-open-port.md | 2 +- content/posts/merklision-merkle-second-preimage.md | 2 +- content/posts/mining-eth-in-a-homelab.md | 2 +- content/posts/the-economics-that-make-cryptojacking-work.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 7149fdb..bcf2b59 100644 --- a/content/posts/five-dollars-instead-of-an-open-port.md +++ b/content/posts/five-dollars-instead-of-an-open-port.md @@ -1,7 +1,7 @@ --- title: "Five dollars a month instead of an open port" date: 2021-03-16T21:50:00+07:00 -draft: true +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." tags: ["homelab", "networking", "vpn", "security"] diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md index eb7931e..ef58b14 100644 --- a/content/posts/merklision-merkle-second-preimage.md +++ b/content/posts/merklision-merkle-second-preimage.md @@ -1,7 +1,7 @@ --- title: "Merklision: composing two Merkle bugs into 2047 collisions" date: 2020-10-24T17:00:00+07:00 -draft: true +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." tags: ["ctf", "cryptography", "merkle-tree", "writeup"] diff --git a/content/posts/mining-eth-in-a-homelab.md b/content/posts/mining-eth-in-a-homelab.md index 1bfb231..15ff936 100644 --- a/content/posts/mining-eth-in-a-homelab.md +++ b/content/posts/mining-eth-in-a-homelab.md @@ -1,7 +1,7 @@ --- title: "Mining ETH in a homelab, and the day it stopped being about mining" date: 2021-04-26T17:00:00+07:00 -draft: true +draft: false description: "Spare GPU slots in machines that were already running, a 4U case because a workstation chassis cooks cards, and a path from GPU passthrough on Proxmox to a Kubernetes Deployment that treats a miner as any other workload." summary: "The goal was to make idle hardware pay the electricity bill. The route ran through IOMMU passthrough into a Windows VM, then out again into containers, and what survived the mining was the scheduling pattern underneath it. I never measured whether it paid." tags: ["homelab", "proxmox", "kubernetes", "gpu", "virtualization"] diff --git a/content/posts/the-economics-that-make-cryptojacking-work.md b/content/posts/the-economics-that-make-cryptojacking-work.md index fe822b6..a7281ed 100644 --- a/content/posts/the-economics-that-make-cryptojacking-work.md +++ b/content/posts/the-economics-that-make-cryptojacking-work.md @@ -1,7 +1,7 @@ --- title: "The economics that make cryptojacking work" date: 2021-07-25T17:00:00+07:00 -draft: true +draft: false description: "Renting GPUs to mine has a return-on-investment calculation that usually says no. Removing the cost line from that calculation is what turns a miner into malware, and it explains why GPU credentials are worth stealing. The shape of the argument, without the numbers, which I never wrote down." summary: "A miner that schedules itself onto any GPU node is a workload like any other, and its economics come down to one subtraction. Drive the cost term to zero and the answer is always yes, which is the whole reason cryptojacking exists. A qualitative argument: the original never recorded the figures." tags: ["security", "threat-model", "kubernetes", "gpu", "cloud"]