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.