← All notes

The captcha that froze the server

July 26, 20267 min read

In Mexico there is no single place to ask a simple question: which phone lines are registered under my name? Every carrier keeps its own portal, some have none, and there are more than a hundred of them. That gap matters, because registering a SIM with somebody else's CURP — the national ID string every Mexican has — is common enough that people end up tied to numbers they never contracted.

MisLíneas, by Jorge Mora and Hadassah García, closes that gap: one CURP in, every carrier queried in parallel, results streamed back as each one answers. It is GPL-2.0 and it is a genuinely good idea.

I ran it, and it kept half-failing. AT&T would time out. Then a different carrier. Then nothing, then everything. The kind of intermittent that makes you blame the network and move on.

It wasn't the network. It was four separate defects stacked on top of each other, and the most interesting one is a captcha.

1. The proof-of-work that froze everything

Red Altán — the wholesale network behind roughly 65 Mexican virtual operators — protects its lookup endpoint with a proof-of-work captcha. The server hands you a salt and a difficulty target, and you brute-force SHA-256 until you find a nonce whose hash matches. Standard anti-bot economics: cheap to verify, expensive to spam.

The implementation solved it like this:

export function solveChallenge(salt, target) {
  let nonce = 0;
  while (true) {
    const hash = crypto.createHash("sha256").update(salt + nonce).digest("hex");
    if (hash.startsWith(target)) return nonce;
    nonce++;
  }
}

That is correct, and on a single-threaded runtime it is a disaster.

Node runs your JavaScript on one thread. That while (true) grinding hashes is not waiting on anything — it is working, and while it works nothing else on that thread runs. Not the timer that would fire a timeout. Not the callback that would read the bytes AT&T already sent back. Twelve other carriers had their responses sitting in kernel socket buffers, and the process was too busy hashing to pick them up.

So AT&T "timed out" — after fifty seconds of a stopwatch that Node also couldn't service on time. The error pointed at the innocent party. Altán, meanwhile, always looked fine: it was the one holding the thread.

The fix is to hand the loop back periodically:

if ((nonce & 0xfff) === 0) {
  if (Date.now() > deadline) throw new Error("challenge solve exceeded time budget");
  await new Promise((resolve) => setImmediate(resolve));
}

Every 4096 hashes, yield. The other carriers' I/O drains, then hashing resumes. It costs a few milliseconds of throughput on the captcha and it stops one provider from taking the whole request hostage. The deadline is there because a difficulty spike shouldn't be able to spin forever.

The general shape: if a single-threaded runtime does CPU-bound work in a request path, that work is a global lock. Anything that looks like a timeout elsewhere in the process is a suspect, not a witness.

2. Writing requests onto dead sockets

With the event loop free, AT&T still hung — less often, but it hung.

Node's HTTP client, undici, pools keep-alive connections and will hold an idle socket for up to about 600 seconds. att.com.mx closes an idle socket at around 30. When those two disagree, undici picks a socket it believes is alive, writes a request onto a connection the other end already tore down, and waits for a reply that can never come. You get the full abort timeout and a stack trace that says nothing useful.

setGlobalDispatcher(new Agent({
  allowH2: false,
  keepAliveTimeout: 10000,
  keepAliveMaxTimeout: 10000,
  connect: { timeout: 30000 },
}));

Evict idle sockets after ten seconds, comfortably before the carrier does, and the pool never hands out a corpse. allowH2: false is the same bug one layer up: with HTTP/2 a single stale multiplexed connection takes down every request riding on it, so one dead socket becomes a dozen failures. Independent HTTP/1.1 connections fail one at a time.

3. A DNS burst against a local resolver

The first lookup after a cold start failed far more often than later ones, which is the signature of a cache rather than a network.

Firing all thirteen providers at once means about twenty simultaneous name resolutions — each host needs both an A and an AAAA record. A local systemd-resolved under that burst starts returning spurious ENOTFOUND and EAI_AGAIN. Not a real failure; a resolver saying "not right now" in a way indistinguishable from "no such host."

Three changes, because it has three causes:

  • A concurrency pool of five instead of an unbounded fan-out, so the resolver is never asked for twenty answers at once.
  • A retry that only retries transient DNS and connection errors — and explicitly not timeouts. Retrying a fifty-second timeout twice turns one slow request into a two-and-a-half minute one. That distinction is the difference between a fix and an amplifier.
  • A blocking DNS pre-warm at boot, resolving every provider host in small batches before the server accepts its first request. Startup takes a few seconds longer; the first user query stops paying for a cold cache and a burst simultaneously.

There is a fourth, smaller cause worth knowing: getaddrinfo() runs on libuv's thread pool, which defaults to four threads. A dozen concurrent lookups queue four at a time behind each other. UV_THREADPOOL_SIZE=64 removes that line.

4. Fetches with no deadline at all

The least clever and most common defect: most provider calls had no timeout. A hung connection hung that provider forever, and since the response only closes when every provider settles, one stalled carrier held the entire stream open.

Twelve fetch calls got AbortSignal.timeout(50000), and Telcel's raw https request had its 10-second ceiling raised to match. Not elegant — just the difference between a slow answer and no answer.

What actually reaches the carriers

With all four fixed, a lookup that used to fail intermittently now completes in about a second and a half. But there is a second problem no code change solves.

Several carriers block requests coming from datacenter IP ranges. I measured it rather than assumed it: the same request, the same synthetic CURP, the same second, sent from a residential Mexican line and from a server.

Carrier Residential line Server
Red Altán (~65 brands) answers answers
ABIB · Beneleit · Dialo · IENTC · Logística ACN · Mirlo · MoBig · Virgin Mobile answers answers
Telcel 200 403
Vinculatulinea (Freedompop, OUI, OXXO CEL, Uber Cel, AhorroCel, Chedraui Móvil, Yobi Telecom) 200 403
AT&T 403 403

Telcel and Vinculatulinea are IP blocks — plain and reproducible. AT&T is a different animal: it rejects from everywhere, including a residential line, because it fingerprints the client rather than the address. Reaching it takes a real browser driven through a residential proxy, which upstream built and then removed: it cost roughly 170–800 KB per lookup against 7–8 KB for every other provider, about 95% of their proxy budget for one carrier.

So this tool is only ever completely honest when you run it yourself, from your own connection. That is worth saying out loud, because a hosted instance quietly missing Telcel would be worse than one that tells you.

Use it

→ Open MisLíneas

That instance runs the patched fork. It covers Red Altán's ~65 brands plus eight more carriers, and it says on the page which three it can't reach and why. Nothing is stored: results stream straight through, and CURPs are scrubbed from the server logs.

For the complete picture — Telcel and Vinculatulinea included — run it on your own machine:

git clone -b resilience-fixes https://github.com/milojarow/MisLineas
cd MisLineas && pnpm install && pnpm build && pnpm start

The fork lives here. All four fixes are still unmerged upstream, so I'll be offering them as a pull request; the original project remains the one to credit.

One caveat that isn't technical: this is for looking up your own CURP. Querying someone else's without their consent runs against Mexico's federal data-protection law, and the tool makes that easy in a way that deserves saying plainly.