software 1996 · a few nights · a satellite dish and a healthy dose of secondhand embarrassment

Decoding Canal+ Scrambled Video in x86 Assembly — a 1996 Nagravision Hack

🌐 Leer esta página en español →

Back in '96 I got the idea to try decoding Canal+'s scrambled still images in assembly — Canal+ being Spain's analog pay-TV channel at the time. The idea was clever. The execution, more or less. The result, gloriously crappy. Here's the story as the kid who did it would tell it — that kid being me, although I signed as |ncubuX to keep a bit of mystery going in the BBS scene.

01 — What Canal+ did to the picture

Canal+ in the 90s used a system called Nagravision (or Syster, depending on who you ask) to scramble the analog video signal. It didn't scramble the audio — that went out in the clear, because otherwise the official decoder boxes would have cost twice as much and people would have been even more furious.

What it did to the picture was basically two things:

1. Cut each horizontal line at a random point and
   swap the right-hand chunk over to the left (line cut).

2. Reorder the image's lines in a pseudorandom order
   that depended on the frame's key.

The key changed several times a second. To decode it "properly" you needed the key, and only the official decoder box had it (it received it encrypted on teletext line 16, if memory serves). I didn't have the key. Nobody on the BBS had the key. But there was one thing we did have: physics.

02 — The (brilliant) idea and the (huge) problem

In a natural image — a movie, a football match, whatever — adjacent horizontal lines look a lot like each other. Line 100 and line 101 are nearly identical because the world doesn't suddenly change from one vertical pixel to the next. This is called spatial correlation, and it's the foundation of almost all video compression.

So the idea was: if I have the lines all shuffled, I can try putting them back in order by finding, for each position, which line in the pile has the least difference from the previous one. The line that "fits best" is probably the one that belonged there.

For each line in the image (top to bottom):
    For each candidate line in the buffer:
        error = sum of |current_pixel - candidate_pixel|
    The candidate with the lowest error => goes here
    Swap

Elegant. There was just one problem, but a big one: you don't know which line is the first one. The Nagravision key determined both the line order AND the cut point of each line, and that kept changing frame by frame. So even if my algorithm sorted the lines perfectly from the first one onward... if the first one was wrong, the image still came out a mess.

The solution I came up with was to just try things out by hand: the constant s0 in the code was the initial offset — which line it took as the "starting point". I'd plug in a number (between 0 and 399), compile, run it, look at whether the image looked better or worse, and change it by hand. Old-school engineering.

03 — The minimum-error algorithm

The core of the program, in decent pseudocode:

; Swaps line s0 with the first line as a starting point
; (initial guess at which line is the real "first" one)

for each current_line (from the first to the last):
    min_error = 0xFFFFFFFF
    best_candidate = none

    for each candidate_line in the buffer:
        error = 0
        for each pixel in the line (640 pixels):
            error += abs(current_pixel[i] - candidate_pixel[i])
        if error < min_error:
            min_error = error
            best_candidate = candidate_line

    swap(current_line, best_candidate)

In practice, the error-calculation loop does it in 32 bits (it subtracts bytes but accumulates into DX), and it uses the 486's timestamp counter (RDTSC, opcode 0Fh 31h, which wasn't even officially documented yet in the manuals I had) to measure how long it took. That was the "profiler" the stamp constant at the top refers to.

04 — The code (DOS32 and all)

The program uses DOS32 as its protected mode extender. That's because a 640x480 image takes up 307,200 bytes, which doesn't fit in DOS real mode's 640KB. With DOS32 you could request extended memory via INT 31h (the DPMI interface) and work with 32-bit pointers in flat mode. No segmentation, no 64KB limits. Luxury.

The .ASM file's header said it all about the author and his intentions:

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
;        Enredando con el PLUS... nada de copyright |ncubuX 96-97
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
;  Esta fuente emplea DOS32 como extensor de modo protegido... El programa
;  este trata de decodificar el plus (como le sale de los huevos)... juega
;  con la constante s0 para alcanzar mejores resultados (del 0 al 399).
;  No me hago responsable de nada, naturalmente porque aqui no violo absolu-
;  tamente nada. Al programa le entra un .raw (yo los hago con el alchemy)
;  y escupe un decoded.raw :)
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

"Como le sale de los huevos" — roughly "however the hell it feels like" — was exactly the level of documentation you got back then. The program's flow:

inicializa:
    Text mode (INT 10h), hide cursor
    Show "DECODIFICADOR KUTUSKIANO v1.0 / Setabia's Hell 1.996"
    Parse arguments (name of the input .raw)
    Open the source file
    Request extended memory via DPMI (INT 31h, function EE42h)
    Read the header (size_fichero - 640*480 bytes)
    Create and open decoded.raw
    Write the header to the destination
    Dump the data into the memory buffer

vuelve:
    Swap line 0 with line s0 (initial guess)
    For each line: find the minimum error and swap
    Write the buffer to decoded.raw
    Close files and exit

And the error-search loop, which is the heart of it:

busca_error:
        mov     al,[esi+ebx]    ; pixel de la línea actual
        sub     al,[edi+ebx]    ; menos pixel de la candidata
        jns     oks             ; si positivo, está bien
        neg     al              ; si no, valor absoluto
oks:
        add     dx,ax           ; acumula error
        dec     ebx
        jnz     busca_error

Simple and direct. It computes the sum of absolute differences (SAD) between two 640-byte lines, accumulating into DX. If the result is smaller than the stored minimum, that becomes the new best candidate.

05 — Why it never fully worked

It worked. A little. More or less. Depends on the image.

The fundamental problem is that the greedy algorithm — "always pick the line with the lowest error against the previous one" — doesn't guarantee the globally optimal solution. Line 47 might be the best candidate for position 5, but if you "spend" it on position 3 because it also fit reasonably well there, then you're out of luck at position 5.

The second problem was the one I already mentioned: without knowing which line is line 0, you're playing the lottery with s0. On images with lots of motion or little vertical correlation (highly variable backgrounds) the algorithm went haywire. On calm images — a static shot from a movie, say — something recognizable would come out every once in a while.

The third problem is that Nagravision also cut each line horizontally at a random point, and I never handled that. So even when the line order came out correct, every single line was still split and glued back together the wrong way round. The result looked like an image seen through venetian blinds hung by a madman.

Was it good for watching football? No. Was it good for learning protected mode, DPMI, RDTSC and minimum-error algorithms? Absolutely yes. And that's what it was for.

06 — The complete source (P2011.ASM)

In case anyone feels like reading it. Without changing a single comma.

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
;        Enredando con el PLUS... nada de copyright |ncubuX 96-97
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
;  Esta fuente emplea DOS32 como extensor de modo protegido... El programa
;  este trata de decodificar el plus (como le sale de los huevos)... juega
;  con la constante s0 para alcanzar mejores resultados (del 0 al 399).
;  No me hago responsable de nada, naturalmente porque aqui no violo absolu-
;  tamente nada. Al programa le entra un .raw (yo los hago con el alchemy)
;  y escupe un decoded.raw :)
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

;        includelib debugs.lib

;        debugg   =      1       ; Poner a 1 si incluyo el debug del DOS32
        stamp    =      1       ; Poner a 1 para el profiler
        s0      EQU     32
        xmax    EQU     640
        ymax    EQU     480
        cont    =       0

        CuentaFin MACRO
                pushad
                db      0Fh, 31h
                sub     eax,co_low
                sbb     edx,co_hi
                sub     eax,oh_low
                sbb     edx,0
                mov     baja,eax
                popad
        ENDM

        Contar MACRO
                pushad
                db      0Fh, 31h
                mov     ebx,eax
                db      0Fh, 31h
                sub     eax,ebx
                mov     oh_low,eax
                db      0Fh, 31h
                mov     co_low,eax
                mov     co_hi,edx
                popad
        ENDM

      ;if debugg
;        extrn Debug : near
     ; endif

.386P
.MODEL FLAT
.STACK 1024
.CODE
        caa     DB      8 DUP(0)
                DB      "$"
        oh_low  DD      ?       ; TIMESTAMP
        co_low  DD      ?
        co_hi   DD      ?

        baja            DD      0
        inicio          DB            ">>> DECODIFICADOR KUTUSKIANO v1.0 <<<"
                        DB      13,10,"             Setabia's Hell 1.996$"
        letras          DB      13,10,"Error: debes indicar el archivo a ser"
                        DB      " procesado",10,13,"$"
        buffer_ptr      DD      ?                ; puntero de cabecera
        Fichero_SI      DW      ?                ; manip del archivo fuente
        Fichero_DI      DW      ?                ; manip del archivo destino
        Error_min       DD      0FFFFFFFFh       ; error minimo encontrado
        Error_off       DD      ?                ; offset de la minerrorline
        size_fichero    DD      ?                ; tamanyo del fichero en bytes
        size_arg        DB      ?                ; longitud de los argumentos.
        print_fich      DB      "Fichero:           $"      ; Mensaje
        print_memo      DB      "Memoria requerida: $"
        argumentos      DB      0FFh DUP(0)      ; Linea de comando
        crr             DB      10,13,"$"        ; Retorno de carro
        dos_error       DB      "- Error del DOS -$"
        sin_memoria     DB      13,10,"No hay memoria libre",13,10,"$"
        size_cab        DD      ?                ; tamanyo de la cabecera
        fic_sal         DB      "decoded.raw",0

     comienzo:
;        if debugg
;                call    Debug
 ;       endif

        jmp     inicializa
     vuelve:
        call    cr

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

        mov     esi,buffer_ptr          ; se coge la primera linea
        mov     edi,xmax*s0
        add     edi,buffer_ptr
        xor     ebx,ebx
     er34:
        mov     al,[esi+ebx]
        xchg    al,[edi+ebx]
        mov     [esi+ebx],al
        inc     ebx
        cmp     ebx,xmax
        jna     er34

        mov     esi,buffer_ptr
     pilla:
        mov     edi,esi
        add     edi,xmax
     error_linea:
        mov     ebx,xmax-4
        xor     edx,edx
        xor     eax,eax

  ;      if stamp
        contar
   ;     endif

     busca_error:                               ; Bucle de busqueda de errores
        mov     al,[esi+ebx]
        sub     al,[edi+ebx]
        jns     oks
        neg     al
oks:
        add     dx,ax
        dec     ebx
        jnz     busca_error

    ;    if stamp
        cuentafin
     ;   endif

        cmp     edx,dword ptr error_min
        ja      sigue_buscando
        mov     dword ptr error_off,edi         ; Linea con maximo error
        mov     dword ptr error_min,edx
     sigue_buscando:
        mov     edx,buffer_ptr
        add     edx,xmax*ymax
        cmp     edi,edx
        jae     otra_linea
        add     edi,xmax
        jmp     error_linea
     otra_linea:
        add     esi,xmax
        mov     edi,dword ptr error_off
        xor     ebx,ebx                         ; Intercambio de lineas
     cambia_linea:
        mov     eax,dword ptr [esi+ebx]
        xchg    eax,dword ptr [edi+ebx]
        mov     dword ptr ds:[esi+ebx],eax
        add     bx,4
        cmp     bx,xmax
        jne     cambia_linea

        mov     dword ptr error_min,0FFFFFFFFh  ;(-1) Anyadir desplazamiento
        mov     edx,xmax*ymax                   ; de la siguiente linea
        add     edx,buffer_ptr
        cmp     esi,edx
        ja      final1
        jmp     pilla                           ; Aun quedan lineas


; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
     final1:
        mov     ah,40h                  ; Salva los datos a "decoded.raw"
        mov     bx,fichero_DI
        mov     ecx,size_fichero
        sub     ecx,size_cab
        mov     edx,buffer_ptr
        int     21h
        jc      errores
        mov     ax,3Eh                  ; Cierra el fichero destino
        mov     bx,word ptr fichero_DI
        jc      errores
     final_close:
        mov     ax,3Eh                  ; Cierra el fichero origen
        mov     bx,word ptr fichero_SI
        jc      errores
     final:
        call    Pon_cursor              ; Restaura el tamanyo del cursor

        mov     eax,baja

        mov     ah,4Ch                  ; Salida al DOS
        int     21h                     ; y libera la memoria usada

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
; ALLOCATE: meter en size_fichero el tamanyo que quieras coger
;           EDX: puntero donde esta el bloque
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

     allocate PROC
        pushad
        mov     ax,0EE42h
        mov     edx,dword ptr size_fichero
        int     31h
        sub     eax,dword ptr size_fichero
        jz      allocate_ok
        xor     edx,edx
        stc
        jmp     fin_allocate
       allocate_ok:
        clc
       fin_allocate:
        popad
        ret
     allocate ENDP

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

     Quita_cursor PROC                  ; Esconde el cursor
        pushad
        mov     ch,32
        xor     cl,cl
        mov     ah,1
        int     10h
        popad
        ret
     Quita_cursor ENDP

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

     Pon_cursor PROC                    ; Restaura el cursor
        pushad
        mov     ch,6
        mov     cl,7
        mov     ah,1
        int     10h
        popad
        ret
     Pon_cursor ENDP

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

     CR PROC                            ; Imprime un retorno de carro
        push    edx
        push    ax
        mov     ah,9
        mov     edx,offset crr
        int     21h
        pop     ax
        pop     edx
        ret
     CR ENDP

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
;  ERRORHANDLER
; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░

     info:                              ; Como se usa el programa
        mov     ah,9
        mov     edx,offset letras
        int     21h
        jmp     final
     errores:                           ; Los errores del DOS
        mov     ah,9
        mov     edx,offset dos_error
        int     21h
        jmp     final
     no_mem:
        mov     ah,9
        mov     edx,offset sin_memoria
        int     21h
        jmp     final_close

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
     VAL32 PROC
        mov     cl,8
        xor     bl,bl
        xor     ch,ch
     cro:
        rol     eax,4
        mov     dl,al
        and     dl,00001111b
        cmp     bl,0
        jne     siguiendo
        cmp     dl,0
        setne   bl
     siguiendo:
        cmp     bl,1
        jne     loo
        cmp     dl,9
        ja      letra
        add     dl,48
        jmp     printer
     letra:
        add     dl,55
     printer:
        push    ax
        mov     ah,2
        int     21h
        pop     ax
     loo:
        loop    cro
        mov     dl,"h"
        mov     ah,2
        int     21h
        ret
     VAL32 ENDP

; ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░
     inicializa:
        mov     ax,3                    ; Modo texto
        int     10h
        push    ds                      ; Inicializa selectores
        pop     es
        call    Quita_cursor            ; Hace invisible el cursor
        call    cr                      ; Muestra informacion general
        mov     ah,9
        mov     edx,offset inicio
        int     21h
        call    cr
        mov     ax,0EE02h               ; Pilla los argumentos necesarios
        int     31h
        xor     ebx,ebx
        add     esi,80h
        mov     bl,[esi]
        add     esi,2
        push    esi
        add     esi,ebx
        dec     esi
        mov     al,0
        mov     [esi],al
        add     esi,1
        mov     al,"$"
        mov     [esi],al
        pop     esi
        push    esi
        call    cr
        mov     ah,9
        mov     edx,offset print_fich
        int     21h
        mov     edx,esi
        int     21h
        call    cr
        pop     esi
        mov     ax,3D02h                ; Abre el archivo
        xor     cx,cx
        mov     edx,esi
        int     21h
        jc      errores
        mov     word ptr fichero_SI,ax
        mov     ax,4202h                ; Coge el tamanyo del fichero fuente
        mov     edx,0
        mov     bx,word ptr fichero_SI
        int     21h
        jc      errores
        mov     dword ptr size_fichero,eax
        mov     edx,eax
        call    allocate                ; Pide memoria de acuerdo con el fich.
        mov     ax,0EE42h
        int     31h
        mov     dword ptr buffer_ptr,edx
        cmp     eax,0
        jz      no_mem
        push    eax
        mov     ah,9
        mov     edx,offset print_memo
        int     21h
        pop     eax
;        call    val32
        mov     edx,size_fichero        ; Tamanyo de la cabecera
        sub     edx,xmax*ymax
        mov     size_cab,edx
        mov     ax,4200h                ; Restaura al principio
        mov     edx,0
        mov     bx,word ptr fichero_SI
        int     21h
        jc      errores
        mov     ah,3Fh                  ; vuelca la cabecera hacia el buffer
        mov     bx,fichero_SI
        mov     ecx,size_cab
        mov     edx,dword ptr buffer_ptr
        int     21h
        jc      errores
        mov     ah,3Ch                 ; Crea el fichero de salida (y abre)
        mov     cx,0
        mov     edx,offset fic_sal
        int     21h
        jc      errores
        mov     ax,fichero_DI
        mov     ax,3D02h                ; Abre el archivo
        xor     cx,cx
        mov     edx,offset fic_sal
        int     21h
        jc      errores
        mov     word ptr fichero_DI,ax
        mov     ah,40h                  ; Salva la cabecera
        mov     bx,fichero_DI
        mov     ecx,size_cab
        mov     edx,buffer_ptr
        int     21h
        jc      errores
        mov     ah,3Fh                  ; vuelca los datos al buffer
        mov     bx,fichero_SI
        mov     ecx,size_fichero
        sub     ecx,size_cab
        mov     edx,dword ptr buffer_ptr
        int     21h
        jc      errores
        jmp     vuelve
END     comienzo

— |ncubuX / Setabia's Hell, 1.996. No copyright, naturally.