Hoppa yfir í efnið

Unicode-staðallinn, ósýnileg tákn og eðli bila

Tveir textastrengir geta virst vera nákvæmlega eins útlits á skjánum, en geymt í raun gerólík tákn. Stafur kann að skarta engri sjáanlegri breidd. Bil getur hæglega verið af hversdagslegum toga, óslítanlegt bil eða hreinlega dálkmerki (e. tab). Stýritákn getur jafnvel ráðið því með hvaða hætti röðun textans birtist okkur.

Innan ramma gagnaglímuverkefna má hæglega nýta þessa tækni til þess að fela skilaboð. Innan raunverulegra kerfa getur nákvæmlega sama tækni valdið notendum ruglingi, sem og ruglað auðkenni, skráarheiti og lén.

Í þessum kafla munum við nýta okkur Python-málið til þess að rýna af fyllstu nákvæmni í það hvaða Unicode-tákn leynast í textanum. Við skulum vara okkur á því að líma grunsamlegan texta milliliðalaust inn í skelina og síst af öllu leggja traust á augun ein saman.

Útlit er ekki auðkenni

Leturgerð varpar einvörðungu fram myndrænum texta, á meðan tölvan sjálf leggur mat á sjálfa kóðapunktana. Sú staðreynd að tákn „líkist“ öðru felur engan veginn í sér að um sama stafinn sé að ræða.

Unicode og UTF-8 í stuttu máli

Við höfum áður fjallað um Unicode og stafakóðun. Hér skiptir máli að greina á milli:

  • Unicode skilgreinir stafi og kóðapunkta,
  • UTF-8 segir hvernig kóðapunktarnir eru geymdir sem bæti.

Kóðapunktur (e. code point) telst vera auðkenni innan Unicode-staðalsins og er jafnan ritaður sem U+ fylgt eftir af sextándatölu:

A  → U+0041
á  → U+00E1
🙂 → U+1F642

Sami kóðapunktur getur þurft misjafnan fjölda bæta í UTF-8:

for stafur in "Aá🙂":
    print(stafur, hex(ord(stafur)), stafur.encode("utf-8").hex())

Mögulegt frálag:

A 0x41 41
á 0xe1 c3a1
🙂 0x1f642 f09f9982

Skipunin ord() dregur fram kóðapunkt staks stafs. Aðferðin encode("utf-8") skilar hins vegar þeim bætum sem varðveita hann.

Gerum ósýnilegt sýnilegt

Fallið repr() í Python-málinu varpar iðulega ljósi á stýri- og bilstafi í formi undanlátsraða (e. escape sequences):

texti = "A\tB\nC"
print(texti)
print(repr(texti))

Frálag:

A       B
C
'A\tB\nC'

Okkur er fært að prenta út kóðapunktinn, Unicode-heitið og flokkun sérhvers stafs:

import unicodedata

texti = "A\tá\u200bB"

for nr, stafur in enumerate(texti):
    print(
        nr,
        f"U+{ord(stafur):04X}",
        unicodedata.name(stafur, "<ekkert heiti>"),
        unicodedata.category(stafur),
        repr(stafur),
    )

Mögulegt frálag:

0 U+0041 LATIN CAPITAL LETTER A Lu 'A'
1 U+0009 <ekkert heiti> Cc '\t'
2 U+00E1 LATIN SMALL LETTER A WITH ACUTE Ll 'á'
3 U+200B ZERO WIDTH SPACE Cf '\u200b'
4 U+0042 LATIN CAPITAL LETTER B Lu 'B'

Flokkurinn Cf gefur til kynna ósýnilegt sniðstýritákn (e. format control). Flokkunin ein og sér ber þó síst af öllu vitni um það hvort notkunin teljist skaðleg; slík tákn gegna nefnilega veigamiklu hlutverki í fjölmörgum tungumálum og við mótun emoji-raða.

Æfing: hvað er á milli A og B?

Æfing 1

print(texti) virðist sýna AB, en repr(texti) sýnir:

'A\u200bB'
  1. Hversu margir kóðapunktar skipa þennan streng?
  2. Hver er sá kóðapunktur sem hvílir í miðjunni?
  3. Telst það forsvaranlegt að eyða honum sjálfkrafa úr öllum texta?
Lausn við æfingu 1
  1. Strenginn skipa þrír kóðapunktar: A, núllbreiddarbil og B.
  2. Miðjan er U+200B ZERO WIDTH SPACE.
  3. Nei. Í afmörkuðu CTF-verkefni gætum við túlkað eða fjarlægt það samkvæmt vísbendingu. Innan hversdagslegra textakerfa geta tákn af þessu tagi gegnt lögmætu hlutverki við orðskiptingu og innan ólíkra ritkerfa. Öll hreinsun texta útheimtir því reglur sem taka mið af samhenginu.

Stafir sem deila sama útliti

Unicode-staðallinn hýsir stafi úr fjölmörgum ólíkum stafrófum. Sumir þeirra teljast vera útlitslíkir stafir (e. confusables eða homoglyphs).

Berum saman:

Latin a:     a  U+0061
Cyrillic а:  а  U+0430

Þeir geta sýnst nákvæmlega eins í fjölmörgum leturgerðum, en hafa ber í huga að þar er engan veginn um sama kóðapunktinn að ræða.

latin = "a"
kyrilliskur = "\u0430"

print(latin == kyrilliskur)
print(ord(latin), ord(kyrilliskur))
False
97 1072

Í gagnaglímum gæti svo farið að fáni geymdi einn stakan kyrillískan staf sem rannsakanda ber að hafa uppi á. Þegar hugað er að öryggi, geta villandi lén hæglega fléttað saman ólíkum ritkerfum.

Rýnum í lén án þess að vitja þess

heiti = "еxample.invalid"  # fyrsti stafurinn er kyrillískt е
print(repr(heiti))
print(heiti.encode("idna").decode("ascii"))

IDNA-kóðunin umbreytir hinu alþjóðlega heiti yfir á ASCII-snið sem jafnan hefst á merkingu á borð við xn--. Við þurfum ekki að senda DNS-fyrirspurn eða opna vefslóð til að sjá að stafrófum er blandað.

Villandi lén

Varaðu þig á því að skrá þig inn á vefsvæði einvörðungu á þeim forsendum að lénið virðist vera kunnuglegt útlits. Rýnið fremur í hina raunverulegu kóðapunkta, punycode-framsetninguna og sjálf vottorðin. Í æfingum notum við frátekna .invalid endingu.

Æfing: finndu ólíka stafinn

Æfing 2

Eftirfarandi strengur kemur úr tilbúnu verkefni:

texti = "flаg"  # þriðji sýnilegi stafurinn er U+0430

Með hvaða hætti leiðum við frávikið í ljós, og hvert er hið raunverulega eðli textans?

Lausn við æfingu 2

Við skulum prenta út hvern og einn kóðapunkt:

import unicodedata

for stafur in texti:
    print(f"U+{ord(stafur):04X}", unicodedata.name(stafur))

Frálagið ber þessu vitni:

U+0066 LATIN SMALL LETTER F
U+006C LATIN SMALL LETTER L
U+0430 CYRILLIC SMALL LETTER A
U+0067 LATIN SMALL LETTER G

Strengurinn telst því síst af öllu vera ASCII-orðið flag, heldur hýsir hann hið kyrillíska а. Við skulum einvörðungu hnika stafnum til, ef fyrirmæli verkefnisins kveða á um að endurheimta skuli hreinræktaðan ASCII-texta. Að öðrum kosti látum við okkur nægja að færa frávikið til bókar.

Unicode-samræming

Hið sama sýnilega tákn má endur og eins setja saman með margvíslegum hætti.

U+00E1                 → á
U+0061 + U+0301        → a + SAMSETNINGARBRÁ

Fyrra afbrigðið telst vera einn stakur kóðapunktur. Síðara afbrigðið skipa hins vegar grunnstafur og sérstakt samsetningarmerki.

fyrri = "\u00e1"
seinni = "a\u0301"

print(fyrri, seinni)
print(fyrri == seinni)
print(len(fyrri), len(seinni))
á á
False
1 2

Unicode-samræming (e. Unicode normalization) hlutast til um að færa jafngildar framsetningar yfir á fastmótað form:

import unicodedata

print(unicodedata.normalize("NFC", fyrri) ==
      unicodedata.normalize("NFC", seinni))
True

Meðal algengra forma má nefna:

  • NFC sameinar tákn þar sem staðallinn gerir ráð fyrir samsettu afbrigði,
  • NFD sundrar táknum í grunnstaf og tilheyrandi samsetningarmerki,
  • NFKC og NFKD hlutast sömuleiðis til um samhæfingarsamræmingu (e. compatibility normalization), sem getur hæglega hróflað við merkingarlegum blæbrigðum.

NFKC-samræming telst síst af öllu hversdagsleg í öllum kerfum

Samhæfingarsamræming búa yfir þeim eiginleika að leggja ólík útlitstákn að jöfnu. Það getur verið gagnlegt fyrir leit en rangt fyrir lykilorð, stafrænar undirskriftir eða gögn þar sem nákvæmir kóðapunktar skipta máli.

Dulritunarkássur sjá bætin

Strengir, sem virðast vera nákvæmlega eins útlits, búa yfir ólíkum UTF-8 bætum og því verður SHA-256 tætigildi þeirra (e. hash) gerólíkt, sé samræmingu ekki beitt:

import hashlib

for texti in (fyrri, seinni):
    print(texti.encode("utf-8").hex())
    print(hashlib.sha256(texti.encode("utf-8")).hexdigest())

Færum textann í samræmt form löngu áður en lagt er í útreikning tætigildis svo frammi sem samskiptareglur kveða á um að jafngild Unicode-form skuli teljast sem eitt og hið sama. Okkur ber að forðast að beita samræmingu eftir á í þeirri von að hún skili okkur hinu upprunalega tætigildi.

Æfing: jafnir eftir samræmingu

Æfing 3

Strengirnir eru:

x = "o\u0308"  # o og samsetningartvípunktur
y = "\u00f6"   # ö
  1. Teljast þeir jafnir sé litið til þeirra með beinum hætti?
  2. Hver er lengd hvors strengs um sig, sé hún mæld í kóðapunktum?
  3. Með hvaða hætti reynist okkur farsælast að bera þá saman sem NFC-form?
Lausn við æfingu 3
  1. Þeir teljast síst af öllu jafnir sé litið til þeirra með beinum hætti.
  2. x hefur tvo kóðapunkta en y einn.
  3. Við skulum færa þá báða á samræmt form:
import unicodedata

nx = unicodedata.normalize("NFC", x)
ny = unicodedata.normalize("NFC", y)
print(nx == ny)  # True

NFC-formið hlutast til um að sameina o og samsetningartvípunktinn í stafinn ö, þar sem gert er ráð fyrir slíku samsettu tákni.

Núllbreiddarstafir

Núllbreiddarstafur (e. zero-width character) einkennist af því að taka að jafnaði ekkert sýnilegt rými. Hér má nefna nokkur dæmi:

Kóðapunktur Heiti Hlutverk
U+200B ZERO WIDTH SPACE ósýnileg vísbending um mögulega orðskiptingu
U+200C ZERO WIDTH NON-JOINER kemur í veg fyrir samdrátt stafa innan tiltekinna ritkerfa
U+200D ZERO WIDTH JOINER hlutast til um tengingu stafa eða emoji-raða
U+2060 WORD JOINER stendur gegn línuskiptingu
U+FEFF ZERO WIDTH NO-BREAK SPACE/BOM má nýta til þess að gefa bætarað (e. byte order) til kynna í upphafi texta

Varist að líta á þau sem hreinasta gagnsleysi. Til dæmis notar emoji-runan fyrir sumar fjölskyldur U+200D til að tengja fleiri emoji í eina sýnilega mynd.

Innan ramma gagnaglímunnar reynist unnt að nýta tvo ólíka núllbreiddarstafi sem tvíundagildi (e. binary values):

U+200B → 0
U+200C → 1

Okkur er unnt að hafa uppi á þeim með tilstyrk Python-málsins:

import unicodedata

texti = open("skilabod.txt", encoding="utf-8").read()

for stafur in texti:
    if unicodedata.category(stafur) == "Cf":
        print(f"U+{ord(stafur):04X}", unicodedata.name(stafur, "?"))

Sýnidæmi: einn falinn stafur

Undanlátsröðin er:

falið = "\u200b\u200c\u200b\u200b\u200b\u200b\u200b\u200c"

Með reglunni að ofan fæst:

01000001

sem jafngildir tölunni 0x41, eða ASCII-stafnum A.

kort = {"\u200b": "0", "\u200c": "1"}
bitar = "".join(kort[s] for s in falið)
print(chr(int(bitar, 2)))

Bil eru ekki öll eins

Hér má sjá algeng dæmi um bil og skyld tákn:

Kóðapunktur Heiti repr()
U+0020 SPACE ' '
U+0009 Dálkmerki (e. tabulation) '\t'
U+00A0 NO-BREAK SPACE '\xa0'
U+2009 THIN SPACE '\u2009'
U+3000 IDEOGRAPHIC SPACE '\u3000'

Þau geta búið yfir mismunandi eiginleikum hvenær sem kemur að leit, línuskiptingu eða hvers kyns úrvinnslu gagna.

for stafur in [" ", "\t", "\u00a0", "\u2009", "\u3000"]:
    print(f"U+{ord(stafur):04X}", repr(stafur), stafur.isspace())

Aðferðin str.isspace() ber vissulega kennsl á fjölmörg Unicode-bil, en hafa ber í huga að önnur verkfæri og forritunarmál kunna að styðjast við ólíkar reglur. Regluleg segð, sem styðst við bókstaflegt ASCII-bil, megnar síst af öllu að finna óslítanleg bil með sjálfvirkum hætti.

Leyndargögn faldin í bilum og dálkmerkjum

Einföld regla:

bil  → 0
dálkmerki → 1

Python-strengurinn:

runan = " \t\t  \t\t\t"

gefur bitana:

01100111

sem jafngildir tölunni 0x67, eða ASCII-stafnum g.

Okkur er fært að draga bilin fram í dagsljósið á skipanalínunni:

sed -n 'l' skilabod.txt

Innan GNU-útgáfunnar af sed birtast dálkmerki sem og línulok sem $. Rýni í sextándakerfi (e. hex) færir okkur þó enn meiri nákvæmni:

xxd -g 1 skilabod.txt

Gætið þess að varðveita bilin

Textaritlar, spjallkerfi eða hvers kyns sjálfvirk snið geta hæglega tekið upp á því að umbreyta dálkmerkjum í bil, eyða bilum í línulokum eða hreinlega beita Unicode-samræmingu að eigin vild. Við rannsóknir ber okkur því að sýsla milliliðalaust við hina meðfylgjandi skrá, en forðast texta sem flakkað hefur á milli margvíslegra kerfa.

Æfing: lestu bilakóðann

Æfing 4

Reglan er bil = 0 og dálkmerki = 1. Hvaða leyndarmáli lúrir þessi Python-runa á?

runan = " \t\t  \t\t\t"
Lausn við æfingu 4

Okkur er unnt að ráða úr henni án þess svo mikið sem að freista þess að telja hin ósýnilegu tákn með berum augum:

runan = " \t\t  \t\t\t"
kort = {" ": "0", "\t": "1"}
bitar = "".join(kort[s] for s in runan)

print(bitar)                 # 01100111
print(chr(int(bitar, 2)))    # g

Niðurstaðan leiðir í ljós að runan varðveitir ASCII-stafinn g.

Stefnustýritákn

Tungumál manna fylgja síst af öllu nákvæmlega sömu lögmálum um skrifstefnu. Unicode-staðallinn hýsir því sérstök stýritákn fyrir tvístefnutexta (e. bidirectional text, BiDi).

Dæmi er:

U+202E RIGHT-TO-LEFT OVERRIDE

Því er unnt að láta hluta strengs birtast í öfugri röð, það er frá hægri til vinstri. Slíkt fyrirkomulag telst vissulega nytsamlegt við tilteknar málfræðilegar aðstæður, en getur hins vegar gert skráarheiti eða frumkóða afar villandi í augum rannsakandans.

Við skulum síst af öllu tefla raunverulegu U+202E gildi fram í sýnidæminu, heldur smíðum við það með skýrum hætti:

texti = "skyrsla_" + "\u202e" + "gnp.exe"
print(repr(texti))

for stafur in texti:
    print(f"U+{ord(stafur):04X}", repr(stafur))

Hið sýnilega heiti getur þannig hæglega vakið ranga hugmynd um sjálfa skráarendinguna. Notkun repr(), rýni í kóðapunkta og beiting file-skipunarinnar teljast til mun áreiðanlegri vinnubragða en að treysta á útlitið eitt og sér.

Varað er við því að stýritákn geti ruglað úrvinnslu frumkóða

Vinsamlegast gætið þess að líma aldrei texta, sem hýsir ókunn BiDi-tákn, milliliðalaust inn í frumkóða eða skelina. Rýnið fremur í bætin og sjálfa kóðapunktana í upphafi. Ákveðin þróunarumhverfi eiga það til að varpa fram sérstökum viðvörunum vegna ósýnilegra stefnustýritákna.

Hreinsun texta með varðveislu merkingar

Mörgum reynist tamt að grípa til eftirfarandi bragðs:

hreint = "".join(s for s in texti if ord(s) < 128)

Slík aðgerð þurrkar þó jafnframt út alla íslenska stafi auk annars lögmæts texta. Vandaðra vinnulag felst í því að:

  1. tilgreina nákvæmlega hvers konar ílags sé vænst,
  2. færa óvænta kóðapunkta til bókar,
  3. beita samræmingu á tilteknu formi, fari samskiptareglur fram á slíkt,
  4. heimila einvörðungu nákvæmlega skilgreind stafasett þar sem við á,
  5. varðveita hvort tveggja; hinn upprunalega streng sem og þann sem sætt hefur umbreytingu.

Þegar glímt er við fána í gagnaglímu, sem ætlað er að vera á ASCII-formi, gæti markviss athugun tekið á sig þessa mynd:

texti = open("fani.txt", encoding="utf-8").read().strip("\n")

for nr, stafur in enumerate(texti):
    if not stafur.isascii():
        print(nr, f"U+{ord(stafur):04X}")

Okkur ber að forðast að hnika öllum frávikum til með sjálfvirkum hætti, enda geta þau sjálf hýst sjálfa úrlausnina.

Lokaverkefni: textinn sem er ekki eins og hann sýnist

Æfing 5

Verkefnið gefur skilabod.txt. Venjuleg prentun sýnir:

fani: flаg{unicode}

En:

texti = open("skilabod.txt", encoding="utf-8").read()
print(repr(texti))

sýnir:

'fani: fl\u0430g{unicode}\u200b\u200c\u200b\u200b\u200b\u200b\u200b\u200c\n'

Ritið niður greiningarferlið og leitist við að greina á milli hins sýnilega texta og þess lags sem dulið er.

Möguleg lausn við æfingu 5
  1. Varðveitið nákvæma skrá og rýnið í UTF-8 bætin með tilstyrk xxd-skipunarinnar.
  2. Prentið út sérhvern kóðapunkt með ord() og unicodedata.name().
  3. Gætið þess að leiða í ljós að stafurinn a í orðinu flag telst í raun vera:
U+0430 CYRILLIC SMALL LETTER A

Þar er síst af öllu um latneskt a að ræða. Hið sýnilega heiti telst því engan veginn vera hinn bókstaflegi ASCII-texti flag{unicode}.

  1. Safnið saman öllum sniðstýritáknum sem fram koma við leiðarlok. Með reglunni U+200B = 0 og U+200C = 1 fást bitarnir:
01000001

Þeir mynda ASCII-stafinn A.

  1. Nú standa eftir tvær veigamiklar staðreyndir: útlitslíkur kyrillískur stafur annars vegar og hins vegar falinn bókstafurinn A. Lýsing verkefnisins eða síðari vísbendingar verða þá að skera úr um hvort rétt sé að skipta út gildi U+0430 fyrir latneskt a, bæta bókstafnum A við sjálfan fánann eða nýta hann hreinlega sem lykil.
  2. Við skulum gæta þess að halda til haga hvort tveggja; hinum upprunalega streng sem og hugsanlegum umbreytingum innan vinnubókar okkar.

Höfuðmálið er að forðast að fullyrða um gildi fánans, einvörðungu með hliðsjón af hinu sýnilega útliti. Hinir nákvæmu kóðapunktar bera nefnilega allt annað vitni.

Samantekt

Í þessum kafla lærðum við að:

  • Unicode-kóðapunktur og UTF-8 bæti telst síst vera eitt og hið sama,
  • skipanirnar repr(), ord() og unicodedata gera okkur kleift að mæla og greina ósýnileg tákn,
  • stafir sem eiga rætur í ólíkum ritkerfum geta búið yfir nákvæmlega sama útliti,
  • villandi lén reynist unnt að rannsaka á IDNA/punycode-formi án þess svo mikið sem að vitja þeirra,
  • hið sama sýnilega tákn getur skartað ýmist samsettri eða sundurgreindri framsetningu,
  • samræmingarformin NFC, NFD, NFKC og NFKD hafa öll sín eigin og ólíku áhrif,
  • núllbreiddarstafir geta gegnt lögmætum hlutverkum en sömuleiðis búið yfir leyndum bitum,
  • bil, dálkmerki og óslítanleg bil telst öll til gerólíkra gagna,
  • BiDi-stýritákn búa yfir þeim mætti að umbreyta hinni sýnilegu röðun textans,
  • öll hreinsun útheimtir skýrar reglur, svo forðast megi að þurrka út lögmætan Unicode-texta í blindni.

Í eftirfarandi kafla munum við glíma við hreinsun á hávaðasömum texta með tilstyrk reglulegra segða, grep, sort, uniq, cut, jq og litlum Python-forritum.