DEV Community

코딩나우(하늘아래)
코딩나우(하늘아래)

Posted on Originally published at coding-now.com

WiFi QR codes: the five escapes, the all-hex SSID trap, and what ECC level H costs you

The payload behind a "join this network" QR code is a single line of text:

WIFI:T:WPA;S:cafe-wifi;P:p@ssw0rd;;
Enter fullscreen mode Exit fullscreen mode

Worth knowing up front: this is not an official standard. It is the convention ZXing introduced, which the early Android scanners shipped with, and which everything else then matched. There is no RFC to point at when something disagrees.

Which matters, because the moment a password contains punctuation, the format starts biting.

Five characters have to be escaped

Inside S: and P:, a backslash escapes the five characters that would otherwise end a field: \ ; , : ".

So I built payloads for a set of passwords and parsed them two ways — once by naively splitting on ; and : (how you write it the first time), once following the escape rule:

  password 'p@ssw0rd'
     payload   WIFI:T:WPA;S:cafe-wifi;P:p@ssw0rd;;
     naive     'p@ssw0rd'          OK
     spec      'p@ssw0rd'          OK

  password 'coffee;time'
     payload   WIFI:T:WPA;S:cafe-wifi;P:coffee\;time;;
     naive     'coffee\'           <- truncated
     spec      'coffee;time'       OK

  password 'a:b:c'
     payload   WIFI:T:WPA;S:cafe-wifi;P:a\:b\:c;;
     naive     'a\:b\:c'           <- backslashes kept
     spec      'a:b:c'             OK

  password 'back\slash'
     payload   WIFI:T:WPA;S:cafe-wifi;P:back\\slash;;
     naive     'back\\slash'       <- doubled
     spec      'back\slash'        OK

  password 'say"hi"'
     payload   WIFI:T:WPA;S:cafe-wifi;P:say\"hi\";;
     naive     'say\"hi\"'         <- backslashes kept
     spec      'say"hi"'           OK
Enter fullscreen mode Exit fullscreen mode

The ; case is the dangerous one. It does not throw, it does not warn — it hands back coffee\ and the user gets an authentication failure with no clue why. The other three come back visibly wrong, which at least gets noticed.

One nuance from the same run: I escaped a comma in the SSID (cafe\,shop) and the naive parser still read the password correctly, because a comma never splits a ;-delimited string. The comma is in the escape set for spec compliance, not because ;-splitting needs it. Worth knowing if you are auditing someone else's encoder and wondering why their commas are bare.

Does it survive an actual QR code?

Escaping is only half the question. The other half is whether a real encoder and a real decoder agree once the payload has backslashes in it. So I baked each payload into an actual QR and read it back with OpenCV 5.0.0:

import cv2

def encode(text, ecc=cv2.QRCODE_ENCODER_CORRECT_LEVEL_M):
    p = cv2.QRCodeEncoder.Params()
    p.correction_level = ecc
    return cv2.QRCodeEncoder.create(p).encode(text)

def decode(img):
    # 1 pixel per module is too small for the detector: scale up and add a quiet zone
    big = cv2.resize(img, None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
    big = cv2.copyMakeBorder(big, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255)
    return cv2.QRCodeDetector().detectAndDecode(big)[0]
Enter fullscreen mode Exit fullscreen mode
  'p@ssw0rd'     -> 33x33 modules (version 4)  round-trip matches
  'coffee;time'  -> 33x33 modules (version 4)  round-trip matches
  'a:b:c'        -> 33x33 modules (version 4)  round-trip matches
  'back\slash'   -> 33x33 modules (version 4)  round-trip matches
Enter fullscreen mode Exit fullscreen mode

Byte-identical both ways. The backslashes are ordinary payload bytes as far as QR is concerned — nothing at the barcode layer touches them. Every failure I have seen with these codes lives in the string handling above it.

Two things that cost me time here, in case you run the same experiment: the encoder emits one pixel per module, so feeding its output straight to the detector fails — you have to scale up. And it needs a quiet zone; without the border the detector finds nothing.

The SSID that gets read as hex

This one is not about punctuation at all. If the SSID consists entirely of hex digits, it has to be wrapped in double quotes, or a reader is entitled to decode it as hex bytes rather than as text:

  SSID 'deadbeef'   all hex: True   -> needs quotes
       WIFI:T:WPA;S:"deadbeef";P:pw123456;;

  SSID '5060'       all hex: True   -> needs quotes
       WIFI:T:WPA;S:"5060";P:pw123456;;

  SSID 'cafe-5060'  all hex: False  -> fine as-is
Enter fullscreen mode Exit fullscreen mode

5060 is the trap. A four-digit network name looks like the least exotic thing in the world, and a router that ships with the last four of its serial number as the SSID hits this by accident. cafe and deadbeef and abc123 are all in the same boat.

The check is two lines, and skipping it is the kind of bug that reproduces on exactly one customer's network.

What error correction costs

The other practical question: how much room does a long password actually take? I fixed a 9-character SSID and grew the password, encoding at each of the four error-correction levels. Numbers are the module count per side:

password payload L M Q H
8 chars 35 33 33 37 41
12 39 33 33 37 41
16 43 33 37 37 41
20 47 33 37 41 45
32 59 37 37 41 49
63 90 41 45 53 57

Three things I took from this.

Short passwords are free. 8 and 12 characters produce an identical 33x33 grid at L and M. There is no size argument for a weaker password until you are past about 12 characters.

H is not a small ask. At a 63-character password, H needs 57 modules against L's 41 — the same physical square has modules 1.4x narrower on each side, which is exactly the thing that makes a print-out stop scanning from across a room. H buys you damage tolerance you mostly do not need on a laminated card taped to a wall.

The jump points are what matter, not the length. Growth is a staircase, and the steps land in different places per level: at L the grid holds at 33 all the way to a 20-character password and only steps up at 32, while at M it has already stepped up by 16. If you are sitting just past a step, three fewer characters can keep you a version smaller.

The encoder and parser, in full

SPECIAL = '\\;,:"'

def esc(s):
    return "".join("\\" + c if c in SPECIAL else c for c in s)

def is_hex(s):
    return bool(s) and all(c in "0123456789abcdefABCDEF" for c in s)

def build(ssid, pw, auth="WPA", hidden=False):
    field = esc(ssid)
    if is_hex(ssid):
        field = '"%s"' % field           # quote AFTER escaping - see below
    s = "WIFI:T:%s;S:%s;P:%s;" % (auth, field, esc(pw))
    if hidden:
        s += "H:true;"
    return s + ";"

def parse(payload):
    body = payload[len("WIFI:"):]
    fields, cur, i = [], [], 0
    while i < len(body):
        c = body[i]
        if c == "\\" and i + 1 < len(body):      # escaped: take the next char verbatim
            cur.append(body[i + 1]); i += 2; continue
        if c == ";":
            fields.append("".join(cur)); cur = []; i += 1; continue
        cur.append(c); i += 1
    if cur:
        fields.append("".join(cur))
    out = {}
    for f in fields:
        if f:
            k, _, v = f.partition(":")
            out[k] = v
    s = out.get("S", "")
    if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
        out["S"] = s[1:-1]               # structural quotes, not part of the name
    return out
Enter fullscreen mode Exit fullscreen mode

The parser is a character loop rather than a split, and that is the whole point. There is no regex that splits on unescaped ; without either lookbehind gymnastics or a bug.

The ordering trap I wrote for myself

My first version of build wrapped the SSID in quotes and then escaped the whole thing. That produces:

WIFI:T:WPA;S:\"deadbeef\";P:pw123456;;
Enter fullscreen mode Exit fullscreen mode

The quotes are now escaped, which means they are literal " characters in the network name rather than the wrapper that says "read this as text, not hex". You get a payload that is valid, parses cleanly, and describes a network called "deadbeef" — with the quotes — which does not exist.

Escape first, wrap second. And on the way back out, strip the wrapper, or you hand the caller the same wrong name.

Note T:nopass for an open network, and that the trailing ;; is two semicolons — one ends the last field, one ends the payload.


What I did not test: actual phone cameras. Everything above is OpenCV encoding and decoding on one machine. Whether iOS Camera and the Android scanner agree on the all-hex SSID rule, or on a bare comma, I cannot tell you — I have no way to assert what a closed scanner does without testing every one of them. If you have a phone in hand and a minute, that is the gap I would most like filled in the comments.

The version for people who just want a code for the guest network, with the placement and safety bits: https://www.coding-now.com/en/guides/wifi-qr-code?utm_source=devto

Top comments (0)