The Watch Sale, the QR Code, and the Filter That Never Looked

A 'buyer' sent me a QR code instead of a question. The scam itself was amateur hour. The fact that it reached me at all is the part eBay should be embarrassed about.

I was selling a watch on eBay. A buyer messaged me through eBay's own chat and sent an image, asking me to scan it. No reason given for why buying a watch would ever require the seller to scan anything. I told him he was an amateur, that I could clean him out faster than he could me, and to not be a thief. Then I closed my account, because this was the second time I'd been burned on that platform. I reopened it later because I still needed to sell a watch.

What stuck with me wasn't the scam. QR-redirect scams aren't clever, they're one of the oldest tricks in the book wearing a format most people still haven't learned to distrust. What stuck with me was the question underneath it: eBay runs some kind of inspection on buyer/seller messages. It has to, at that volume. So what did that inspection look at, that let a QR code through clean?

The answer is almost certainly the boring one. A filter built to catch suspicious text — keywords, raw URLs, "pay me outside the platform" phrasing — is reading text. A QR code is an image. Unless something downstream decodes that image and extracts what's encoded in it, the filter has nothing to flag, because as far as it's concerned the message body is empty. The attacker didn't beat a smart filter. He routed around a filter that was never built to look at what he sent.

I don't know eBay's actual moderation pipeline, and I'm not going to pretend otherwise to make this piece hit harder. What I can show is that the gap I'm describing is real and the fix is not exotic.

Built it in an afternoon, in a throwaway lab: qr_phishing_detection_lab. Fifty lines of real logic, nothing hidden:

def decode_qr(path: str) -> str | None:
    img = cv2.imread(path)
    detector = cv2.QRCodeDetector()
    data, _, _ = detector.detectAndDecode(img)
    return data or None


def score_url(url: str) -> dict:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    tld = host.rsplit(".", 1)[-1] if "." in host else ""

    flags = []
    if PLATFORM_DOMAIN not in host:
        flags.append("off-platform domain")
    if host in URL_SHORTENERS:
        flags.append("known URL shortener")
    if tld in SUSPICIOUS_TLDS:
        flags.append(f"low-cost/high-abuse TLD (.{tld})")
    if parsed.scheme != "https":
        flags.append("not HTTPS")

    return {"url": url, "host": host, "risk_flags": flags,
            "risk_level": "high" if flags else "low"}

cv2.QRCodeDetector is stock OpenCV, no custom model, no proprietary vision pipeline. The heuristics are the kind any fraud team already has lying around: off-platform domain, known shortener, disposable TLD, missing HTTPS.

Ran for real, against a synthetic payload shaped like the one I got:

Decoded payload from image: https://bit.ly/ebay-release-payment-x92
Risk level: high
  - off-platform domain: conversation claims to be a marketplace transaction,
    link does not resolve to the marketplace's own domain
  - known URL shortener: destination is hidden behind a redirect,
    can't be inspected before clicking

That's the whole case. A platform processing eBay's message volume has the engineering budget to run this inline, on every image attachment, before it reaches a seller's phone. Whether they've skipped it because full computer-vision inspection at that scale costs more compute than they want to spend, or because nobody prioritized the image-attachment path specifically, I can't say from the outside. Either way the result is the same hole, and it isn't hard to close.

What actually stopped this scam was me recognizing a QR code mid-transaction for what it was and refusing to scan it. That's not a security control, it's luck applied to one person who happened to know better. Everyone else that scammer messages that day gets the same unfiltered image with nothing standing between it and their camera app except whether they personally thought to be suspicious.

A filter that only reads text, on a platform where scammers have already worked out that images don't get read, isn't protecting anyone. It's still catching the laziest attackers, the ones who type "send gift cards" straight into the message box instead of hiding the same ask inside a picture. The ones who've adapted even slightly don't need to be clever. They just need to know where nobody's looking.

GhostInThePrompt.com // The filter read every word and missed the only part that mattered.


qr_phishing_detection_lab on GitHub →