đŸ‡«đŸ‡· FCSC | DisplayPort ⭐⭐⭐

Table of Contents

Hardware: Display Port ⭐⭐⭐

challenge

Ce challenge nous prĂ©sente le rĂ©sultat d’une capture dĂ©codĂ©e du signal diffĂ©rentiel entre les pin #1 et #3

On a pas mal d’informations Ă  propos de la norme, des dimensions et des caractĂ©ristiques de l’Ă©cran, tout ne va pas nous servir tout de suite. Il faut y aller pas Ă  pas.

Une 1ere recherche rapide sur WikipĂ©dia nous apprend que les pin 1 et 3 correspondent aux donnĂ©es transmises par la ligne 0 sur les 3 lignes correspondantes. Notre “.bin” correspond donc aux donnĂ©es transmises par une seule et mĂȘme ligne. La complexitĂ© du challenge va rĂ©sider dans le dĂ©sencapsulage de chaque couche pour pouvoir dĂ©coder les donnĂ©es.

La pĂȘche aux informations 🎣

Maintenant que nous savons de quoi il s’agit, il va falloir trouver les informations sur displayport 1.2 les plus digestes possibles et les plus comprĂ©hensibles, je dois dĂ©coder une seule fois le tout, pas crĂ©er un driver fonctionnel compatible displayport 1.2.

AprĂšs avoir creusĂ© profondĂ©ment internet en recherche de documentation, j’ai en ma possession:

  • Une PrĂ©sentation PPT du protocole displayport (?)
  • Les standards displayport de 2010 (?)
  • Et surtout une thĂšse d’un Ă©tudiant de Cambridge qui a fait exactement la mĂȘme chose (?)

Les différentes couches de DisplayPort

Dans le chapitre 3 de la thĂšse (p45) on nous prĂ©sente comment retrouver l’image Ă  partir de trames capturĂ©es sur la ligne 0 de displayport, nous avons mĂȘme le droit Ă  une photo ressemblant grandement Ă  ce qui est proposĂ© dans l’Ă©noncĂ© :

these-1

S’il y a bien un schĂ©ma sur lequel j’ai pu m’appuyer, c’est celui-ci :

these-2

Il est clair, il nous fournit des exemples et c’est encore une fois exactement ce en quoi consiste le challenge.

Comme chaque protocole, DisplayPort comporte des “layers”, les donnĂ©es sont encapsulĂ©es sous diffĂ©rentes couches.

Pour résumer:

  • “Framing” des pixels dans des trames avec des symboles spĂ©ciaux appellĂ©s symboles de contrĂŽles, pour dire quand commence les donnĂ©es, quand finissent les donnĂ©es, quand commence le “padding”, etc.
  • “Scrambling” des donnĂ©es dans les trames en xorant chaque octet de donnĂ©e avec un gĂ©nĂ©rateur d’entropie standard (LFSR)
  • Encodage de chaque octet en 8b10b

Nous avons le rĂ©sultat final, les donnĂ©es codĂ©es en 8b10b, c’est un encodage pour Ă©tendre des octets, ça sert notamment au protocole DisplayPort pour faire la diffĂ©rence entre des octets qui correspondent Ă  des pixels et des octets qui sont des codes de contrĂŽle.

C’est parti pour remonter chaque couche jusqu’au dĂ©codage final de l’image.

Décodage

Mon 1er rĂ©flexe a Ă©tĂ© d’aller trouver une bibliothĂšque qui pourrait m’aider Ă  dĂ©coder mes donnĂ©es en blocs de 10 bits en un flux d’octet avec les emplacements des codes de contrĂŽles enregistrĂ©s.

En python nous avons encdec8b10b que je vais utiliser dans un 1er temps.

NOTE: Dans l’Ă©noncĂ©, on nous dit

“J’ai enregistrĂ© ces bits dans un fichier binaire, au format suivant : 8 bits par octet, le bit de poids fort Ă©tant le premier bit transmit (MSB first).”

Cette formulation a Ă©tĂ© un peu dĂ©routante pour moi. En fait c’est simplement que chaque bit dĂ©codĂ© du signal analogique converti a Ă©tĂ© enregistrĂ© dans le fichier au “goute Ă  goute”, Ainsi le fichier qui commence par “52 3d” donne “01010010 00111101” en binaire, nous on va en faire des paquets de 10 bits sans pour autant toucher au sens, donc on va lire “0101001000 111101..”

Mon 1er essai consistait à regarder comment le décodage des données se faisait :

from encdec8b10b import EncDec8B10B

def read_bits_from_file(filename, bit_offset=0):
    with open(filename, "rb") as f:
        data = f.read(75)

    bitstream = []
    for byte in data:
        for i in reversed(range(8)):
            bitstream.append((byte >> i) & 1)

    bitstream = bitstream[bit_offset:]

    return bitstream

def get_10b_blocks(bitstream):
    blocks = []
    for i in range(0, len(bitstream) - 9, 10):
        block = bitstream[i:i+10]
        if len(block) == 10:
            blocks.append(block)
    return blocks

if __name__ == "__main__":
    import sys
    filename = sys.argv[1]
    offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
    dec = EncDec8B10B()
    bitstream = read_bits_from_file(filename, bit_offset=offset)
    blocks = get_10b_blocks(bitstream)

    print(f"{len(blocks)} blocks, offset: {offset} bits.")
    for i, block in enumerate(blocks[:60]):
        
        print(f"{i:04d}: {''.join(map(str, block))}", end=" ")
        try:
            print(dec.dec_8b10b(int(''.join(map(str, block)), 2)))
        except:
            print("False")

Normalement je ne devrais pas avoir trop de “False”, qui correspondent Ă  des erreurs de dec_8b10b. On peut accorder le bĂ©nĂ©fice du doute si trĂšs peu de symboles de 10 bits sont concernĂ©s, mais nous devrions quand mĂȘme avoir un flux correct. Donc trop de “False” indique sĂ»rement dans mon cas que le pointeur de donnĂ©es n’est pas synchronisĂ© avec le flux du display port, en posant la sonde ou en dĂ©marrant le logiciel si l’Ă©cran est dĂ©jĂ  dĂ©marrĂ© et reçoit des flux de donnĂ©es, nous pouvons trĂšs bien avoir un dĂ©calage.

Il nous suffit donc de tester plusieurs dĂ©calages et voir si l’un des dĂ©calages nous donne largement moins de “False” que les autres. Pour tester efficacement, je ne lis que 600 bits et j’affiche le rĂ©sultat des 60 premiers octets dĂ©codĂ©s.

Le 1er nous donne environ 15 “False” pour un Ă©chantillon de 60, c’est beaucoup trop. Pareil pour le 2eme, le 3eme, le 4eme, mais le 5eme ne nous donne aucun “False”, Bingo ! On aurait trouvĂ© le bon alignement ?

J’Ă©tends mon script pour calculer l’entiĂšretĂ© de la capture et calculer le taux d’erreur en %

img1

On a bien un taux d’erreur infĂ©rieur Ă  1%, tandit que tous les autres candidats ont environ ~15% de taux d’erreur, je lui accorde le bĂ©nĂ©fice du doute.

Sauf que quelque chose ne va pas.

En plus qu’aucun code de contrĂŽle trouvĂ© ne corresponde Ă  la spĂ©cification DisplayPort, l’occurrence du symbole “SR” (Scrambler Reset) devrait correspondre Ă  ~512x moins que le symbole “BS”, (on retrouve le “SR” tous les 512 “BS”).

img2

À partir de lĂ  j’ai cherchĂ© dans beaucoup de directions.

AprĂšs avoir fixĂ© une bĂȘte erreur de parsing des donnĂ©es binaires. Je ne retrouve toujours pas mes donnĂ©es correctes. Du moins, que j’estime correctes.

C’est les spĂ©cifications d’USB 3.1 qui m’ont Ă©tonnamment aidĂ©e, l’encodage des donnĂ©es semble ĂȘtre le mĂȘme, je l’ai trouvĂ© en dorkant les constantes de SR (K8.0) (001111 0100) et BS (K28.5) (001111 1010). (Il se trouve derriĂšre un loginwall, il se trouve ici)

Au lieu d’utiliser encdec8b10b je vais utiliser la table “Gen 1 Symbol Encoding” de la spĂ©cification (p585) :

img3

On a d’ailleurs la valeur 10 bit dans les deux sens, ça rajoute une sĂ©curitĂ© en plus. Voici la nouvelle table : img4

Et maintenant bingo !!! (pour de vrai cette fois) Ă  l’offset 9 on a un taux d’erreur Ă  0% (contre ~15% des autres). Et on a SR = BS/467.

img5

Cette fois c’est sĂ»r, on a mis le doigt sur les donnĂ©es.

(On retrouve la relation caractÚre spécial:symbole de contrÎle dans la spécification displayport 1.2) (p353) img3

Voici le code qui décode correctement mes données :

import numpy as np
from custom import custom # ma liste custom

def bytes_to_bit_chunks(byte_data, chunk_size=10, offset=0):
    arr = np.frombuffer(byte_data, dtype=np.uint8)
    bits = np.unpackbits(arr)

    if offset >= len(bits):
        return []

    bits = bits[offset:]
    total_bits = len(bits)
    usable_bits = (total_bits // chunk_size) * chunk_size

    bits = bits[:usable_bits]
    chunked_bits = bits.reshape((-1, chunk_size))

    return chunked_bits.tolist()

def read_bits_from_file(filename=None, offset=0):
    with open(filename, "rb") as f:
        data = f.read()
    arr = bytes_to_bit_chunks(data, 10, offset)
    return arr

def search_for_line(elm, array):
    for e in array:
        if(elm == e[-1] or elm == e[-2]):
            return e[0], e[1]
    return None

if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("python.py fichier.bin [offset_bits]")
        sys.exit(1)

    filename = sys.argv[1]
    offset = int(sys.argv[2]) if len(sys.argv) > 2 else 0
    
    bitlist = read_bits_from_file(filename, offset)

    arr_cntrl = []
    arr_bytes=  []
    err = 0
    t = {}
    bi = 0
    for i, block in enumerate(bitlist):
        binrepr = "".join([chr(x+0x30) for x in block])
        r = search_for_line(int(binrepr, 2), custom)
        # print(dec.dec_8b10b(int(binrepr, 2)))
        if(r != None):
            if(r[0][0] == "K"):
                if r[0] not in t:
                    t[r[0]] = 1
                else:
                    t[r[0]] +=1
                arr_cntrl.append((1, r[1]))
            else:
                arr_cntrl.append((0, r[1]))
            arr_bytes.append(r[1])
            
        else:
            err+=1
        bi+=1

    with open("decoded.bin", "wb") as f:
        f.write(bytes(arr_bytes)) # pour encore plus de tests sur les données
    with open("cntrl.py", "w") as f:
        f.write("flux =[") 
        f.write(",".join([str(x) for x in arr_cntrl]))
        f.write("]")
    print([k+" : "+str(v)  for (k, v) in t.items()])
    print("Taux d'erreur: ", err/bi*100)
    


# K28.0 SR 1c
# K28.1 CP 3c
# K28.2 SS 5c
# K28.3 BF 7c
# K28.5 BS bc
# K23.7 FE f7
# K27.7 BE fb
# K29.7 SE fd
# K30.7 FS fe

J’ai fait le choix de sauvegarder les donnĂ©es dĂ©codĂ©es dans un fichier python qui contiendra un trĂšs grand tableau de tuple (contrĂŽle: bool, octet: int) Le parsing sera long au 1er lancement du dĂ©codeur, mais mon extension python sur Vscode cache les tableaux une fois dĂ©codĂ©s pour la 1ere fois, donc tous les lancements ensuite seront quasiment instantanĂ©s.

Unscrambling

“To flatten the frequency spectrum of electromagnetic interference generated, link data is scrambled using a maximum-length 16-bit linear-feedback shift register (LFSR) defined by the polynomial x16 + x5 + x4 + x3 + 1” (p51 de la thĂšse)

Cette fois, pas question d’utiliser une librairie python sans rĂ©flĂ©chir.

Dans un premier temps on nous a donné un exemple de à quoi devrait ressembler les premiers octets générés par le LFSR. img6

L’objectif va ĂȘtre simple, retrouver l’implĂ©mentation et faire un gĂ©nĂ©rateur en python.

Et bien, c’est encore sur les spĂ©cifications d’USB3.1 qu’on retrouve le mĂȘme scrambler :

img7

On retrouve avec cette sortie l’implĂ©mentation du scrambler (en C).

#include <stdio.h>
#include <stdlib.h>
static unsigned short lfsr = 0xffff; // 16 bit short for polynomial

int unscramble_byte(int inbyte)
{
    static int descrambit[8];
    static int bit[16];
    static int bit_out[16];
    
    int outbyte, i;
    for (i=0; i<16;i++) // convert the LFSR to bit array for legibility
        bit[i] = (lfsr >> i) & 1;
    for (i=0; i<8; i++) // convert byte to be de-scrambled for legibility
        descrambit[i] = (inbyte >> i) & 1;

    // apply the xor to the data
    descrambit[0] ^= bit[15];
    descrambit[1] ^= bit[14];
    descrambit[2] ^= bit[13];
    descrambit[3] ^= bit[12];
    descrambit[4] ^= bit[11];
    descrambit[5] ^= bit[10];
    descrambit[6] ^= bit[9];
    descrambit[7] ^= bit[8];
    // Now advance the LFSR 8 serial clocks
    bit_out[ 0] = bit[ 8];
    bit_out[ 1] = bit[ 9];
    bit_out[ 2] = bit[10];
    bit_out[ 3] = bit[11] ^ bit[ 8];
    bit_out[ 4] = bit[12] ^ bit[ 9] ^ bit[ 8];
    bit_out[ 5] = bit[13] ^ bit[10] ^ bit[ 9] ^ bit[ 8];
    bit_out[ 6] = bit[14] ^ bit[11] ^ bit[10] ^ bit[ 9];
    bit_out[ 7] = bit[15] ^ bit[12] ^ bit[11] ^ bit[10];
    bit_out[ 8] = bit[ 0] ^ bit[13] ^ bit[12] ^ bit[11];
    bit_out[ 9] = bit[ 1] ^ bit[14] ^ bit[13] ^ bit[12];
    bit_out[10] = bit[ 2] ^ bit[15] ^ bit[14] ^ bit[13];
    bit_out[11] = bit[ 3] ^ bit[15] ^ bit[14];
    bit_out[12] = bit[ 4] ^ bit[15];
    bit_out[13] = bit[ 5];
    bit_out[14] = bit[ 6];
    bit_out[15] = bit[ 7];
    lfsr = 0;
    for (i=0; i <16; i++) // convert the LFSR back to an integer
        lfsr += (bit_out[i] << i);
    outbyte = 0;
    for (i= 0; i<8; i++) // convert data back to an integer
        outbyte += (descrambit[i] << i);
    return outbyte;
}
int main(){
    for (int i = 0; i < 65535; i++)
    {
        printf("0x%x,", unscramble_byte(0));
    }
}

On peut alors garder l’implĂ©mentation en C et l’utiliser sur nos octets en python, mais j’ai dĂ©cidĂ© de le rĂ©implĂ©menter rapidement en python histoire d’Ă©viter que ce soit trop le bazar.

#scramble.py

lfsr = 0xffff

def reset_lsfr():
    global lsfr
    lfsr = 0xffff

def unscramble_byte(inbyte):
    global lfsr
    descrambit = [0] * 8
    bit = [0] * 16
    bit_out = [0] * 16

    # Convert LFSR state to bit array
    for i in range(16):
        bit[i] = (lfsr >> i) & 1

    # Convert input byte to bits
    for i in range(8):
        descrambit[i] = (inbyte >> i) & 1

    # XOR descrambit with specific LFSR bits
    for i in range(8):
        descrambit[i] ^= bit[15 - i]

    # Advance the LFSR 8 serial clocks
    bit_out[0] = bit[8]
    bit_out[1] = bit[9]
    bit_out[2] = bit[10]
    bit_out[3] = bit[11] ^ bit[8]
    bit_out[4] = bit[12] ^ bit[9] ^ bit[8]
    bit_out[5] = bit[13] ^ bit[10] ^ bit[9] ^ bit[8]
    bit_out[6] = bit[14] ^ bit[11] ^ bit[10] ^ bit[9]
    bit_out[7] = bit[15] ^ bit[12] ^ bit[11] ^ bit[10]
    bit_out[8] = bit[0] ^ bit[13] ^ bit[12] ^ bit[11]
    bit_out[9] = bit[1] ^ bit[14] ^ bit[13] ^ bit[12]
    bit_out[10] = bit[2] ^ bit[15] ^ bit[14] ^ bit[13]
    bit_out[11] = bit[3] ^ bit[15] ^ bit[14]
    bit_out[12] = bit[4] ^ bit[15]
    bit_out[13] = bit[5]
    bit_out[14] = bit[6]
    bit_out[15] = bit[7]

    # Convert bit_out back to integer LFSR state
    lfsr = 0
    for i in range(16):
        lfsr |= (bit_out[i] << i)

    # Convert descrambit back to output byte
    outbyte = 0
    for i in range(8):
        outbyte |= (descrambit[i] << i)

    return outbyte

Un petit script pour tester notre générateur :

from scrambling import *

reset_lsfr()

print(" ".join(["{:02x}".format(unscramble_byte(0)) for _ in range(0x100)]))

On retrouve bien nos valeurs ff 17 c0 14 b2 e7 02 82 72 6e 28 a6 be 6d bf 8d be 40...

On va pouvoir implémenter ça :

from cntrl import flux
from scrambling import *
from colorama import Fore
table = {
 0x1c : "SR",
 0x3c : "CP",
 0x5c : "SS",
 0x7c : "BF",
 0xbc : "BS",
 0xf7 : "FE",
 0xfb : "BE",
 0xfd : "SE",
 0xfe : "FS",
 
 0x9c : "RESERVED",
 0xDC : "RESERVED",
 0xFC : "RESERVED",

}
Started = False
for i, (ctrl, byte) in enumerate(flux):
    if(Started):
        r = unscramble_byte(0)
    if(ctrl):
        if(table[byte] == "SR"):
            Started = True
            reset_lsfr()
        if(Started):
            print(Fore.RED, "\n",table[byte], Fore.RESET)
    else:
        if(Started):
            print("{:02X}".format(byte^r),end=" ")

Comme le standard nous l’indique, on va commencer Ă  partir d’un symbole “SR” qui indique qu’on rĂ©initialise le registre LFSR Ă  0xffff. Ensuite, si c’est un octet de donnĂ©e (reprĂ©sentĂ© en blanc ici) on va xorer l’octet gĂ©nĂ©rĂ© par le LFSR avec l’octet.

img8

LĂ  on peut ĂȘtre certain d’avoir des donnĂ©es correctes Ă©tant donnĂ© les “chunks” de 0 qui sont gĂ©nĂ©rĂ©s, on n’aurait pas ça si les donnĂ©es Ă©taient incorrectes ou s’il y avait un dĂ©calage.

Retrouver notre image

Maintenant que nous pouvons lire des pixels, il ne reste plus rien pour retrouver notre image.

On va cependant devoir comprendre comment sont envoyés les pixels : sont-ils en ligne, en colonne, encore autrement ?

Dans cet exemple, on nous dĂ©crit les TU (transfert unit) comme l’ensemble “data + padding”, il a un “TU” d’une taille de 64 octets tandis que nous avons un “TU” de 62 octets.

img9

On apprend Ă©galement que les 0 sont invisibles, ils servent uniquement Ă  faire une taille adaptĂ©e pour l’exigence du bitrate.

Donc il faut lire tous les octets en une seule ligne, on devrait avoir une taille de la dimension de notre écran en pixels (800) * 3 (RGB 24 bits).

Started = False
r = 0
dat_arr = []
recording = False
for i, (ctrl, byte) in enumerate(flux[0x171f:]):
    if(Started):
        r = unscramble_byte(0)
    if(ctrl):
        if(table[byte] == "SR"):
            Started = True
            reset_lsfr()
    
        if(Started):
            print(Fore.RED, "\n",table[byte], Fore.RESET)
            if(table[byte] == "SS"):
                recording = False
            if(table[byte] == "BS"):
                recording = False
                if(dat_arr):
                    print(len(dat_arr))
                    exit(1)                    
            if(table[byte] == "BE"):
                recording = True
            if(table[byte] == "FE"):
                recording = True
            if(table[byte] == "FS"):
                recording = False


    else:
        if(Started):
            if(recording):
                print("{:02X}".format(byte^r),end=" ")
                dat_arr.append(byte^r)

img10

On a bien une taille de 2400 (800*3) ! Plus qu’Ă  sauvegarder ces pixels dans un tableau et l’afficher ligne par ligne.

Voici le script pour décoder les pixels et les sauvegarder dans un tableau :

from cntrl import flux
from scrambling import *
from colorama import Fore
table = {
 0x1c : "SR",
 0x3c : "CP",
 0x5c : "SS",
 0x7c : "BF",
 0xbc : "BS",
 0xf7 : "FE",
 0xfb : "BE",
 0xfd : "SE",
 0xfe : "FS",
 
 0x9c : "RESERVED",
 0xDC : "RESERVED",
 0xFC : "RESERVED",

}

Started = False
ligne=0
r = 0
dat_arr = []
bigarr = []
recording = False
for i, (ctrl, byte) in enumerate(flux[0x171f:]): # direct sur le SR
    if(Started):
        r = unscramble_byte(0)
    if(ctrl):
        if(table[byte] == "SR"):
            Started = True
            reset_lsfr()
    
        if(Started):
            print(Fore.RED, "\n",table[byte], Fore.RESET)
            if(table[byte] == "SS"):
                recording = False
            if(table[byte] == "BS"):
                recording = False
                if(dat_arr):
                    bigarr.append(dat_arr)
                    print("Added:", dat_arr, len(dat_arr))
                    dat_arr = []
                    if(len(bigarr) == 300):
                        with open("databarray.py", "w") as f:
                            f.write("bigarr = ")
                            f.write(str(bigarr))
                            exit(1)
                    
            if(table[byte] == "BE"):
                recording = True
            if(table[byte] == "FE"):
                recording = True
            if(table[byte] == "FS"):
                recording = False


    else:
        if(Started):
            if(recording):
                print("{:02X}".format(byte^r),end=" ")
                dat_arr.append(byte^r)

Voici le script pour traiter le tableau et le convertir en une image :

from PIL import Image
import numpy as np
from databarray import bigarr

def decode_dp_trames(trame_blocks, width, height):
    flat = bytearray()
    for blk in trame_blocks:
        flat.extend(blk if isinstance(blk, (bytes, bytearray)) else bytes(blk))

    needed = width * height * 3

    pixel_data = flat[:needed]

    arr = np.frombuffer(pixel_data, dtype=np.uint8).reshape((height, width, 3))
    return Image.fromarray(arr, mode='RGB')

if __name__ == "__main__":
    img = decode_dp_trames(bigarr, 800, 300)
    img.save("output.png")

On a bien notre image finale !

output

FLAG: FCSC{4F72790551DF94E9}

Conclusion

Challenge vraiment trĂšs rigolo Ă  faire, j’avais trĂšs peu l’habitude de travailler avec des protocoles pour de la haute frĂ©quence comme du Display Port.