Hoppa yfir í efnið

Unicode, ósýnileg tákn og bil

Tveir textastrengir geta litið alveg eins út á skjánum en innihaldið ólík tákn. Stafur getur haft enga sjáanlega breidd. Bil getur verið venjulegt bil, óslítanlegt bil eða dálkmerki. Stjórntákn getur jafnvel breytt því í hvaða röð texti birtist.

Í CTF-verkefni getur þetta falið skilaboð. Í raunverulegum kerfum getur sama tækni ruglað notendur, auðkenni, skráarheiti og lén.

Í kaflanum notum við Python til að skoða nákvæmlega hvaða Unicode-tákn eru til staðar. Við límum ekki grunsamlegan texta beint í skel og treystum ekki augunum einum.

Útlit er ekki auðkenni

Leturgerð sýnir mynd af texta en tölvan ber saman kóðapunkta. „Líkist“ þýðir ekki „er sami stafur“.

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) er auðkenni í Unicode og er oft skrifað U+ ásamt 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 úttak:

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

ord() gefur kóðapunkt eins stafs. encode("utf-8") gefur bætin sem geyma hann.

Gerum ósýnilegt sýnilegt

Python-fallið repr() sýnir oft stýri- og bilstafi sem undanlátsröð:

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

Úttak:

A       B
C
'A\tB\nC'

Við getum prentað kóðapunkt, Unicode-heiti og flokk hvers 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 úttak:

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 merkir ósýnilegt sniðstýritákn (e. format control). Flokkurinn einn segir þó ekki hvort notkunin sé skaðleg; slík tákn eru nauðsynleg í mörgum tungumálum og emoji-runum.

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

Æfing 1

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

'A\u200bB'
  1. Hve margir kóðapunktar eru í strengnum?
  2. Hvaða kóðapunktur er í miðjunni?
  3. Er öruggt að eyða honum úr öllum texta sjálfkrafa?
Lausn við æfingu 1
  1. Strengurinn hefur þrjá kóðapunkta: 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. Í almennu textakerfi geta núllbreiddartákn haft lögmætt hlutverk í orðskiptingu og ritkerfum. Hreinsun þarf reglur sem hæfa samhengi.

Stafir sem líta eins út

Unicode inniheldur stafi úr mörgum stafrófum. Sumir eru útlitslíkir stafir (e. confusables eða homoglyphs).

Berum saman:

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

Þeir geta litið eins út í mörgum leturgerðum en eru ekki sami kóðapunktur.

latin = "a"
kyrilliskur = "\u0430"

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

Í CTF gæti fáni innihaldið einn kyrillískan staf sem þarf að finna. Í öryggissamhengi getur villandi lén blandað ritkerfum.

Skoðum lén án þess að heimsækja það

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

IDNA-kóðunin breytir alþjóðlega nafninu í ASCII-snið sem byrjar oft á xn--. Við þurfum ekki að senda DNS-fyrirspurn eða opna vefslóð til að sjá að stafrófum er blandað.

Villandi lén

Ekki skrá þig inn á síðu aðeins af því að lénið lítur kunnuglega út. Skoðaðu raunverulega kóðapunkta, punycode-framsetningu og vottorð. Í æ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

Hvernig finnum við frávikið og hvað er textinn í raun?

Lausn við æfingu 2

Við prentum hvern kóðapunkt:

import unicodedata

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

Úttakið sýnir:

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 er því ekki ASCII-orðið flag; hann inniheldur kyrillískt а. Við skiptum stafnum aðeins út ef verkefnið segir að endurheimta eigi ASCII-texta. Annars skráum við einfaldlega frávikið.

Unicode-samræming

Sama sýnilega táknið getur stundum verið sett saman á fleiri en einn hátt.

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

Fyrri útgáfan er einn kóðapunktur. Seinni útgáfan er 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) setur jafngildar framsetningar í ákveðið form:

import unicodedata

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

Algeng form:

  • NFC sameinar tákn þegar staðallinn skilgreinir samsetta útgáfu,
  • NFD sundrar táknum í grunnstaf og samsetningarmerki,
  • NFKC og NFKD framkvæma einnig samhæfingarsamræmingu sem getur breytt merkingarlegum mun.

NFKC er ekki saklaus í öllum kerfum

Samhæfingarsamræming getur lagt ó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

Sýnilega eins strengirnir hafa ólík UTF-8-bæti og því ólíka SHA-256-kássu fyrir samræmingu:

import hashlib

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

Samræmum áður en við reiknum kássu ef samskiptareglan segir að jafngild Unicode-form eigi að teljast eins. Við samræmum ekki eftir á og búumst við sömu upprunalegu kássu.

Æfing: jafnir eftir samræmingu

Æfing 3

Strengirnir eru:

x = "o\u0308"  # o og samsetningartvípunktur
y = "\u00f6"   # ö
  1. Eru þeir jafnir beint?
  2. Hve langur er hvor strengur í kóðapunktum?
  3. Hvernig berum við þá saman sem NFC?
Lausn við æfingu 3
  1. Þeir eru ekki jafnir beint.
  2. x hefur tvo kóðapunkta en y einn.
  3. Við samræmum báða:
import unicodedata

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

NFC sameinar o og samsetningartvípunkt í ö þar sem samsett tákn er til.

Núllbreiddarstafir

Núllbreiddarstafur (e. zero-width character) tekur venjulega ekkert sýnilegt pláss. Nokkur dæmi:

Kóðapunktur Heiti Hlutverk
U+200B ZERO WIDTH SPACE ósýnileg möguleg orðskipting
U+200C ZERO WIDTH NON-JOINER hindrar samdrátt stafa í sumum ritkerfum
U+200D ZERO WIDTH JOINER tengir stafi eða emoji-runu
U+2060 WORD JOINER hindrar línuskipti
U+FEFF ZERO WIDTH NO-BREAK SPACE/BOM getur merkt bætarað í upphafi texta

Þau eru ekki sjálfkrafa „rusl“. Til dæmis notar emoji-runan fyrir sumar fjölskyldur U+200D til að tengja fleiri emoji í eina sýnilega mynd.

Í CTF má nota tvo núllbreiddarstafi sem tvíundagildi:

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

Við finnum þá með Python:

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 er 0x41, ASCII-stafurinn A.

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

Bil eru ekki öll eins

Algeng bil og skyld tákn:

Kóðapunktur Heiti repr()
U+0020 SPACE ' '
U+0009 CHARACTER TABULATION '\t'
U+00A0 NO-BREAK SPACE '\xa0'
U+2009 THIN SPACE '\u2009'
U+3000 IDEOGRAPHIC SPACE '\u3000'

Þau geta haft ólíka hegðun í leit, línuskiptingu og gagnavinnslu.

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

str.isspace() þekkir mörg Unicode-bil, en verkfæri og forritunarmál nota ekki alltaf sömu reglur. Regluleg segð með bókstaflega ASCII-bili finnur ekki endilega óslítanlegt bil.

Falin gögn í bilum og dálkmerkjum

Einföld regla:

bil  → 0
dálkmerki → 1

Python-strengurinn:

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

gefur bitana:

01100111

sem eru 0x67, ASCII-stafurinn g.

Við getum gert bilin sýnileg á skipanalínu:

sed -n 'l' skilabod.txt

GNU sed sýnir meðal annars dálkmerki sem \t og línulok sem $. Hex-skoðun er enn nákvæmari:

xxd -g 1 skilabod.txt

Varðveittu bilin

Textaritill, spjallkerfi eða sjálfvirkt snið getur breytt dálkmerkjum í bil, fjarlægt bil í línulokum eða samræmt Unicode. Vinna skal beint með meðfylgjandi skrá, ekki texta sem var afritaður gegnum mörg kerfi.

Æfing: lestu bilakóðann

Æfing 4

Reglan er bil = 0 og dálkmerki = 1. Hvað geymir þessi Python-runa?

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

Við getum afkóðað hana án þess að reyna að telja ósýnilegu táknin með augunum:

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

Runan geymir ASCII-stafinn g.

Stefnustýritákn

Tungumál eru skrifuð í ólíkar áttir. Unicode hefur því stjórntákn fyrir tvístefnutexta (e. bidirectional text, BiDi).

Dæmi er:

U+202E RIGHT-TO-LEFT OVERRIDE

Það getur látið hluta strengs birtast frá hægri til vinstri. Þetta er gagnlegt í réttum málfræðilegum aðstæðum en getur gert skráarheiti eða kóða villandi.

Við setjum ekki raunverulegt U+202E í sýnidæmið heldur smíðum það skýrt:

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

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

Sýnilegt heiti getur gefið ranga hugmynd um endinguna. repr(), kóðapunktar og file á skránni eru áreiðanlegri en útlitið.

Stýritákn geta ruglað kóða

Ekki líma texta með óþekktum BiDi-táknum beint í frumkóða eða skel. Skoðaðu bæti og kóðapunkta fyrst. Sum þróunarumhverfi vara sérstaklega við ósýnilegum stefnustýritáknum.

Hreinsun án þess að eyðileggja merkingu

Það er freistandi að gera:

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

Það eyðir líka öllum íslenskum stöfum og öðrum lögmætum texta. Betra vinnulag er:

  1. skilgreina hvaða inntak við búumst við,
  2. skrá óvænta kóðapunkta,
  3. samræma með tilteknu formi ef samskiptareglan krefst þess,
  4. leyfa nákvæmlega skilgreint stafasett þar sem það á við,
  5. halda bæði upprunalega og umbreytta strengnum.

Fyrir CTF-fána sem á að vera ASCII gæti markviss athugun verið:

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}")

Við skiptum ekki sjálfkrafa út öllum frávikum; þau gætu sjálf geymt lausnina.

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'

Skrifaðu greiningarleið og greindu sýnilega textann frá falda laginu.

Möguleg lausn við æfingu 5
  1. Halda nákvæmri skrá og skoða UTF-8-bætin með xxd.
  2. Prenta hvern kóðapunkt með ord() og unicodedata.name().
  3. Athuga að sýnilega a-ið í orðinu flag er í raun:
U+0430 CYRILLIC SMALL LETTER A

Það er ekki latneskt a. Sýnilegi strengurinn er því ekki bókstaflega ASCII-textinn flag{unicode}.

  1. Safna sniðstýritáknum í lokin. Með reglunni U+200B = 0 og U+200C = 1 fást bitarnir:
01000001

Þeir mynda ASCII-stafinn A.

  1. Nú höfum við tvær staðreyndir: útlitslíkan kyrillískan staf og falinn A. Verkefnalýsing eða næsta vísbending þarf að segja hvort skipta eigi U+0430 út fyrir latneskt a, bæta A við fánann eða nota það sem lykil.
  2. Við varðveitum bæði upprunalega strenginn og mögulega umbreytingu í lausnadagbókinni.

Mikilvægast er að fullyrða ekki að sýnilegi textinn sé þegar gildur fáni. Nákvæmu kóðapunktarnir sýna annað.

Samantekt

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

  • Unicode-kóðapunktur er ekki það sama og UTF-8-bæti,
  • repr(), ord() og unicodedata gera ósýnileg tákn mælanleg,
  • stafir úr ólíkum ritkerfum geta litið eins út,
  • villandi lén má skoða sem IDNA/punycode án þess að heimsækja þau,
  • sama sýnilega tákn getur haft samsetta og sundurgreinda framsetningu,
  • NFC, NFD, NFKC og NFKD hafa ólík áhrif,
  • núllbreiddarstafir geta haft bæði lögmætt hlutverk og geymt falda bita,
  • bil, dálkmerki og óslítanleg bil eru ólík gögn,
  • BiDi-stýritákn geta breytt sýnilegri röð texta,
  • hreinsun þarf skýrar reglur og má ekki eyða lögmætum Unicode-texta í blindni.

Í næsta kafla hreinsum við hávaðasaman texta með reglulegum segðum, grep, sort, uniq, cut, jq og litlum Python-forritum.