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'
- Hversu margir kóðapunktar skipa þennan streng?
- Hver er sá kóðapunktur sem hvílir í miðjunni?
- Telst það forsvaranlegt að eyða honum sjálfkrafa úr öllum texta?
Lausn við æfingu 1
- Strenginn skipa þrír kóðapunktar:
A, núllbreiddarbil ogB. - Miðjan er
U+200B ZERO WIDTH SPACE. - 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" # ö
- Teljast þeir jafnir sé litið til þeirra með beinum hætti?
- Hver er lengd hvors strengs um sig, sé hún mæld í kóðapunktum?
- Með hvaða hætti reynist okkur farsælast að bera þá saman sem NFC-form?
Lausn við æfingu 3
- Þeir teljast síst af öllu jafnir sé litið til þeirra með beinum hætti.
xhefur tvo kóðapunkta enyeinn.- 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ð:
- tilgreina nákvæmlega hvers konar ílags sé vænst,
- færa óvænta kóðapunkta til bókar,
- beita samræmingu á tilteknu formi, fari samskiptareglur fram á slíkt,
- heimila einvörðungu nákvæmlega skilgreind stafasett þar sem við á,
- 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
- Varðveitið nákvæma skrá og rýnið í UTF-8 bætin með tilstyrk
xxd-skipunarinnar. - Prentið út sérhvern kóðapunkt með
ord()ogunicodedata.name(). - Gætið þess að leiða í ljós að stafurinn
aí orðinuflagtelst í 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}.
- Safnið saman öllum sniðstýritáknum sem fram koma við leiðarlok. Með reglunni U+200B =
0og U+200C =1fást bitarnir:
01000001
Þeir mynda ASCII-stafinn A.
- 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 latneskta, bæta bókstafnumAvið sjálfan fánann eða nýta hann hreinlega sem lykil. - 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()ogunicodedatagera 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.