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..bcf2b59
--- /dev/null
+++ b/content/posts/five-dollars-instead-of-an-open-port.md
@@ -0,0 +1,160 @@
+---
+title: "Five dollars a month instead of an open port"
+date: 2021-03-16T21:50:00+07:00
+draft: false
+description: "Every home service you add tempts you to forward one more port on a modem you do not own. Renting a small server as a VPN hub does not remove the internet-facing listener; it replaces several you cannot patch with one you can."
+summary: "The honest reason the homelab exists. Port forwarding on the ISP modem accumulates: one listener per service, on a box you do not administer. A 5 USD virtual server does not make the listener disappear - it moves it somewhere you control."
+tags: ["homelab", "networking", "vpn", "security"]
+---
+
+> *Archive note. Written in March 2021 in Hanoi, for a blog I have since retired, and the earliest post in that blog's homelab sequence - everything else grew out of it. Prices and products are of their time, and the arrangement itself did not last long: I moved to the Netherlands within about a year, and a VPN hub hosted by a Vietnamese carrier stops being the obvious choice once you live on the other side of the planet from it. What follows chronologically is [mining ETH in a homelab](/posts/mining-eth-in-a-homelab/).*
+
+**TL;DR.** You want to reach a machine at home from outside. You can forward a port on the ISP modem, or you can rent a VPS - a virtual private server, a small Linux machine rented by the month - and run a VPN on it that everything dials out to. Both designs put a process on the internet where strangers can reach it. The difference is how many, and whose. Forwarding grows one internet-facing listener per service, on firmware you did not choose and cannot patch. The VPN hub has exactly one listener, on a machine you administer, and that count does not move when the service list grows. The reason this matters is not the first service. It is the fourth.
+
+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
+
+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 ready-made answers
+
+The hosted remote-desktop tools do this job and do it well:
+
+- **TeamViewer** is free only while your use counts as personal, and the moment it decides otherwise you are paying. The figure I had in mind at the time was 24.90 USD. I did not write down whether that was per month or per year, and I am not going to reconstruct it now - which means I cannot honestly claim the VPS was cheaper, only that it was not obviously more expensive and did considerably more.
+- **AnyDesk** is the same shape with fewer features.
+- **Chrome Remote Desktop** is genuinely free, and it is what I would still point a non-technical person at. It also renegotiates to a direct route instead of hauling traffic through Google's servers. Its catch is small: it runs in Chrome, so some key combinations never reach the far machine - press Ctrl-W expecting to close a window over there and you close a tab over here.
+
+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, 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.
+
+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.
+
+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 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 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. 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.
+
+Two honest caveats before the conclusion.
+
+**The count grows only if you let it.** Three of those four services speak HTTP. One reverse proxy on one VM fronts all of them behind a single port, and now the growth is in proxy configuration rather than in forwarded ports. That is a real answer to the problem and it is what many people do. It also leaves you one internet-facing listener on a VM you patch - which, as the next section admits, is exactly where the VPN hub lands. The two designs are closer relatives than this post originally implied.
+
+**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.
+
+## What the hub actually buys
+
+The VPN hub does not remove the internet-facing listener. It has one: the VPN server itself, on a public address, which is the whole point of it being reachable.
+
+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.
+
+
+
+## What it costs, stated plainly
+
+Three bills come with this that the 5 USD does not cover.
+
+**You now run a server.** The modem was condemned for being unpatchable; the replacement is patchable and therefore must be patched. Algo takes the worst of that away by building on an Ubuntu LTS with unattended security upgrades on by default, which is more maintenance than most home routers ever receive. It does not take all of it away: unattended upgrades cover distribution packages, not the day the LTS reaches end of life and the whole instance needs rebuilding. A VPN server abandoned across that boundary is worse than the port forward it replaced.
+
+**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 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 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.
diff --git a/content/posts/merklision-merkle-second-preimage.md b/content/posts/merklision-merkle-second-preimage.md
new file mode 100644
index 0000000..ef58b14
--- /dev/null
+++ b/content/posts/merklision-merkle-second-preimage.md
@@ -0,0 +1,219 @@
+---
+title: "Merklision: composing two Merkle bugs into 2047 collisions"
+date: 2020-10-24T17:00:00+07:00
+draft: false
+description: "A SVATTT 2020 qualifier challenge asked for 2020 distinct inputs that all hash to one Merkle root. Neither of the two implementation gaps gets close on its own. Alternating between them does, because each one's output is the other one's input."
+summary: "The challenge wanted 2020 distinct strings colliding on one Merkle root. A second-preimage attack gives about ten. An odd-length duplication bug gives about ten more. Alternating the two gives 2047, which I measured rather than derived."
+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, 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 - 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 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.
+
+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. 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.
+
+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 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 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(L1, L2, L3, L4) == M(h(L1)||h(L2), h(L3)||h(L4))
+```
+
+The original writeup glossed this, and the source settles it. The solver builds its rewrite rows as `[hashes[i] + hashes[i+1] for i in range(0, len(hashes), 2)]`, and `+` on `bytes` in Python is concatenation - not addition, and not a further hash. Submitting a twelve-byte element is legal because the wire format base64-decodes to arbitrary bytes and the challenge never inspects them.
+
+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
+
+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.
+
+
+
+## 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 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, 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.
+
+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 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.
+
+## 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.
+
+
+
+## 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. 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 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
+Maximum length can collide is 2048
+There are 2047 collisions
+Takes 11 sec to produce
+```
+
+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.
+
+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
+
+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
new file mode 100644
index 0000000..15ff936
--- /dev/null
+++ b/content/posts/mining-eth-in-a-homelab.md
@@ -0,0 +1,141 @@
+---
+title: "Mining ETH in a homelab, and the day it stopped being about mining"
+date: 2021-04-26T17:00:00+07:00
+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"]
+---
+
+> *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, 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
+
+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: 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
+
+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, 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 - 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
+
+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.
+
+
+
+## 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. 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. 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). 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
+
+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..a7281ed
--- /dev/null
+++ b/content/posts/the-economics-that-make-cryptojacking-work.md
@@ -0,0 +1,116 @@
+---
+title: "The economics that make cryptojacking work"
+date: 2021-07-25T17:00:00+07:00
+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"]
+---
+
+> *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, 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, 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 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 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 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
+
+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 - miner fee - pool fee
+```
+
+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.
+
+
+
+## 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 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
+
+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.** 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.
+
+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.