Potsdam Cyber Games CTF'24

Seit einigen Jahren veranstaltet mein Arbeitgeber - das Hasso-Plattner-Institut - die Potsdamer Konferenz für Nationale Cybersicherheit. Schon im letzten Jahr wurden dazu auch die Potsdam Cyber Games veranstaltet, ein CTF bei welchem Anreise, freier Eintritt bei der Konferenz und die Teilnahme an Workshops zu gewinnen ist. Ich habe dazu in diesem Jahr die drei Challenges Betty's Strandhütte, PartyPass und Entlassene Admins entworfen. Für diejenigen, die schon genug gerätselt haben, folgen die intendierten Lösungswege. Da alle Aufgaben personalisiert sind, lassen sich die Flags natürlich nicht direkt übertragen, trotzdem sind die Lösungswege analog anwendbar.

Betty's Strandhütte

Ziel dieser Aufgabe war es, eine verbreitete Fehlkonfiguration von autoritativen DNS Servern auszunutzen. Den ersten Hinweis auf ein DNS-Problem erhalten wir bereits in der Aufgabenstellung:

[...] Im Winter ist bis auf die Platypwnies eigentlich niemand auf HoneyPot Island. Betty schaltet daher - um Strom zu sparen - das WiFi ab. Dafür wurde ihr von den Admins extra ein geheimes Admin-Panel zur Verfügung gestellt. Heute möchte Betty ihren Laden wieder öffnen, aber sie hat die Domain für das Admin-Panel vergessen. [...] Kannst du für Betty das Admin-Panel wiederfinden?

Das Domain Name System ermöglicht nicht nur die Auflösung von Domainnamen in Records (z.B. IPv4-Adressen), sondern implementiert ein allgemeines Zugriffsprotokoll für die verteilte, cachebare Hierarchie von DNS-Zonen. Bei dieser Challenge interessiert uns nicht die Namensauflösung mithilfe eines Resolvers - denn weder Betty noch sonst irgendjemand anderes hat auf die Domain zugegriffen. Wir sehen uns folglich eine andere Funktion von autoritativen Domain-Servern an - also denjenigen, die für eine Zone direkt verantwortlich sind.

Dazu müssen wir den verantwortlichen Server und die dazugehörige Domain erst einmal identifizieren. Mit der Aufgabenstellung haben wir die Adressen eines DNS- und HTTP-Servers auf den Ports 53 und 80 erhalten. Wenn wir mit einem Webbrowser auf den HTTP-Server zugreifen, erhalten wir eine Website, welche uns über die Abschaltung des Captive Portals informiert.

Betrachten wir den Link auf der Seite näher, finden wir die gesuchte Domain - beach-bubble-tea.hpi. Diese lässt sich mit einem DNS-Server für das Internet natürlich nicht auflösen. Probieren wir es stattdessen mit dem zweiten Service, beispielsweise mit dig, erhalten wir folgende Antwort:

$ dig beach-bubble-tea.hpi @10.66.21.244

; <<>> DiG 9.10.6 <<>> beach-bubble-tea.hpi @10.66.21.244
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 33768
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;beach-bubble-tea.hpi.		IN	A

;; ANSWER SECTION:
beach-bubble-tea.hpi.	604800	IN	A	10.66.21.244

Jede Anfrage zu einer anderen Domain wird vom DNS-Server nicht beantwortet. Damit haben wir Zone und DNS-Server - aber wie finden wir die Subdomain? In diesem Fall über einen Zone Transfer. Ein Zone Transfer wird zur Synchronisation mehrerer authoritativer DNS-Server verwendet - auf diese Weise muss die Zone nur auf einem einzigen Server konfiguriert werden und kann dann von allen anderen kopiert werden. Üblicherweise sollte durch eine geeignete Zugriffskontrollmaßnahme sichergestellt werden, dass nur berechtige Server einen Zone Transfer initiieren können. In diesem Fall darf das aber jeder. Auch viele andere öffentliche DNS-Server sind auf diese Art und Weise falsch konfiguriert:

$ dig axfr beach-bubble-tea.hpi @10.66.21.244

; <<>> DiG 9.10.6 <<>> axfr beach-bubble-tea.hpi @10.66.21.244
;; global options: +cmd
beach-bubble-tea.hpi.	604800	IN	SOA	ns.beach-bubble-tea.hpi. betty.beach-bubble-tea.hpi. 1 100 100 100 100
beach-bubble-tea.hpi.	604800	IN	NS	ns.beach-bubble-tea.hpi.
beach-bubble-tea.hpi.	604800	IN	A	10.66.21.244
ns.beach-bubble-tea.hpi. 604800	IN	A	10.66.21.244
UENHe2IzdHR5NS16ME4zLVRyYW5zZjNyOjpkR2x0S1BaaW9ub219.beach-bubble-tea.hpi. 604800 IN A 10.66.21.244
beach-bubble-tea.hpi.	604800	IN	SOA	ns.beach-bubble-tea.hpi. betty.beach-bubble-tea.hpi. 1 100 100 100 100

UENHe2IzdHR5NS16ME4zLVRyYW5zZjNyOjpkR2x0S1BaaW9ub219 sieht doch nach unserer gesuchten Subdomain aus, oder? Die Flag erhält man, indem man den Namen der Subdomain mit Base64 dekodiert:

$ base64 -d
UENHe2IzdHR5NS16ME4zLVRyYW5zZjNyOjpkR2x0S1BaaW9ub219^D
PCG{b3tty5-z0N3-Transf3r::dGltKPZionom}

PartyPass

Ziel dieser Aufgabe war es, die Flag aus den SIMD-Instruktionen in einer x86-64-ELF-Datei zurückzurechnen. Dabei ist die Flag das Party-Passwort:

Nachdem du von der geheimen Party in der geheimen Lounge gehört hast, stehst du nun davor. Von innen dröhnt die Musik. Aber hier kommst du nicht weiter: Dir fehlt das Passwort. Kannst du Bowser, den digitalen Bouncer vom Gegenteil überzeugen und dir Zugang zur Party und zum Partyfood verschaffen?

Führen wir das shuffler-Binary aus, werden wir nach einem Passwort gefragt:

$ chmod +x ./shuffler && ./shuffler
I am Bowser, the Bouncer. Show me the party password:
> password?
Nope, that's not right. You should go home. 😡

Wie prüft das Programm aber, welches Passwort gültig ist? Werfen wir einen Blick auf das Binary in einem Disassembler, beispielsweise objdump und fokussieren uns auf call-Instruktionen zu Funktionen der Standardbibliothek glibc:

$ objdump -d shuffler

[...]
Disassembly of section .text:

0000000000001080 <.text>:
    109b:	ff 15 1f 3f 00 00    	call   *0x3f1f(%rip)        # 4fc0 <__cxa_finalize@plt+0x3f50>
    1142:	e8 29 ff ff ff       	call   1070 <__cxa_finalize@plt>
    1147:	e8 64 ff ff ff       	call   10b0 <__cxa_finalize@plt+0x40>
    23cb:	e8 60 ec ff ff       	call   1030 <puts@plt>
    23da:	e8 81 ec ff ff       	call   1060 <aligned_alloc@plt>
    23fa:	e8 41 ec ff ff       	call   1040 <memset@plt>
    2415:	e8 36 ec ff ff       	call   1050 <fgets@plt>
    2429:	e8 02 ec ff ff       	call   1030 <puts@plt>
    2442:	e8 19 ec ff ff       	call   1060 <aligned_alloc@plt>
    2c28:	e8 03 e4 ff ff       	call   1030 <puts@plt>
    2c3e:	e8 ed e3 ff ff       	call   1030 <puts@plt>

Von Interesse ist hier die fgets-Instruktion, mit der wir unser Passwort eingeben:

  23ff:	48 8b 15 ea 2c 00 00 	mov    0x2cea(%rip),%rdx        # 50f0 <stdin@GLIBC_2.2.5>
  2406:	48 8b 85 08 ff ff ff 	mov    -0xf8(%rbp),%rax
  240d:	be 31 00 00 00       	mov    $0x31,%esi
  2412:	48 89 c7             	mov    %rax,%rdi
  2415:	e8 36 ec ff ff       	call   1050 <fgets@plt>

Wie wir sehen können, nimmt fgets(char *str, int n, FILE *stream) drei Parameter in %rax, %esi und %rdx entgegen, fgets liest also 48 Bytes in einen Buffer auf dem Stack an Position -0xf8 ein: fgets(-0xf8(%rbp), 49, stdin). Derselbe Buffer wurde vorher mit aligned_alloc alloziiert und mit memset genullt.

Verfolgen wir die Verwendung des Buffers im Binary, so lässt sich erkennen, dass der Buffer in drei 16-Byte Blöcke geteilt wird, die jeweils in ein 128-Bit SSE-Vektorregister geladen werden. Diese Blöcke werden mit einer Vielzahl an umkehrbaren mathematischen SSE-Instruktionen und jeweils einer vergleichenden Operation transformiert und letztendlich in einen zweiten alloziierten Buffer von 48 Byte Länge geschrieben. Auf diesem wird in einer Schleife popcnt aggregiert, also alle gesetzten Bits summiert. Wenn dieser Wert 384 entspricht - also alle Bits gesetzt sind wird uns die korrekte Nachricht >That's right. Let the party begin!< ausgegeben.

Um das korrekte Passwort zu bestimmen müssen wir also einen genaueren Blick auf die SIMD-Operationen für jeden der Teilblöcke werfen und erreichen, dass die in den Output-Buffer geschriebenen Bits alle gesetzt sind. Um den Datenfluss zu verfolgen, bietet es sich an, das Binary in z.B. Ghidra zu laden und die Speicheradressen für die Load- und Store-Operationen zu den jeweiligen %xmm-Registern vor relevanten Operationen zu benennen.

Block I

Die SSE-Instruktionen im ersten Block entsprechen den folgenden Intrinsics:

// 24de: punpcklbw %xmm0,%xmm1
// 2514: psubb %xmm1,%xmm0
// 254a: pxor %xmm1,%xmm0
// 2583: pcmpeqw %xmm1,%xmm0

__m128i op1 = _mm_unpacklo_epi8(c2, c3);
__m128i op2 = _mm_sub_epi8(op1, c1);
__m128i op3 = _mm_xor_si128(op2, c4);
__m128i test1 = _mm_cmpeq_epi16(in1, op3);

_mm_storeh_pd((double *)out, (__m128d)test1);
_mm_storel_pd((double *)(out + 8), (__m128d)test1);

Im ersten Block sind die ersten 2x8 Bytes des Ausgabe-Buffers das Ergebnis eines 8x16-Bit-Vergleiches zwischen den ersten 16 Bytes der Eingabe und dem Ergebnis einer Sequenz an SIMD-Operationen. Wir müssen also die ersten 16 Bytes genau auf diesen Wert setzen. Der Wert bestimmt sich aus vier Konstanten C1-C4, die in dem Ausdruck IN1 = (((C2 unpacklo8 C3) sub8 C1) xor C4) miteinander verbunden sind.

Wenn wir die relevanten Konstanten aus der Prelude der Funktion extrahieren (Byte-Order beachten!), können wir also beispielsweise mit Python und numpy die korrekte Eingabe berechnen:

import numpy as np

c1 = np.array([0xe9,0x3e,0x44,0xbc,0x2d,0x4e,0x6e,0xb9,0x3a,0xe,0x17,0x26,0xec,0x1f,0x56,0xe], dtype=np.uint8)
c2 = np.array([0xf7,0xf1,0x96,0xa0,0xe3,0xe,0x48,0x6b,0x95,0xcc,0x9c,0x94,0x3,0xc3,0x56,0xff], dtype=np.uint8)
c3 = np.array([0xeb,0x89,0x8c,0xbe,0xc0,0xb5,0x4f,0x6,0x8e,0x46,0x49,0xfa,0x2f,0x25,0x10,0x83], dtype=np.uint8)
c4 = np.array([0x5e,0xee,0xea,0xb6,0x19,0xa,0x40,0x71,0xd0,0x9f,0x91,0xbf,0x6c,0x54,0x38,0x96], dtype=np.uint8)

def unpacklo8(vec1, vec2):
  return np.ravel(np.column_stack(((vec1[0:8], vec2[0:8]))))

in1 = (unpacklo8(c2, c3) - c1) ^ c4

Block II

__m128i all_bits = _mm_set1_epi8(0xff);

// 2875: pandn -0x4e0(%rbp),%xmm0
// 28b3: pmuludq %xmm1,%xmm0
// 28ed: psubb %xmm1,%xmm0
// 2921: psllw %xmm0,%xmm1
// 295e: pxor %xmm1,%xmm0
// 2997: pxor %xmm1,%xmm0
// 29d0: pxor %xmm1,%xmm0
// 2a09: pcmpeqb %xmm1,%xmm0

__m128i neg_in2 = _mm_andnot_si128(in2, all_bits);
__m128i op4 = _mm_mul_epu32(c5, c5);
__m128i op5 = _mm_sub_epi8(c6, c8);
__m128i op6 = _mm_slli_epi16(op5, 2);
__m128i op8 = _mm_xor_si128(op4, op6);
__m128i op10 = _mm_xor_si128(op8, c7);
__m128i op11 = _mm_xor_si128(op10, neg_in2);
__m128i test2 = _mm_cmpeq_epi8(op11, all_bits);

_mm_storeh_pd((double *)(out + 16), (__m128d)test2);
_mm_storel_pd((double *)(out + 24), (__m128d)test2);

Im zweiten Block sind die nächsten 2x8 Bytes des Ausgabe-Buffers das Ergebnis eines eines 16x8-Bit-Vergleichs zwischen einem Vektor in dem alle Bits gesetzt sind und dem Ergebnis der Sequenz an SIMD-Operationen. Folglich müssen wir den dahinterlegenden Ausdruck so umstellen, dass in ihm alle Bits gesetzt sind. Auch hier bestimmt sich der Ausdruck aus Konstanten, diesmal C5-C8: TRUE = ((!IN2 and TRUE) xor (((C5 mul32 C5) xor ((C6 sub8 C8) slli16 2)) xor C7))). Diesen Ausdruck können wir umstellen um die nächsten 16 Byte der Eingabe zu erhalten: IN2 = (C5 mul32 C5) xor ((C6 sub8 C8) slli16 2)) xor C7. Auch hier lässt sich die Lösung mit einem kleinen Skript bestimmen:

c5 = np.array([0x79,0x28,0xde,0x33,0xd0,0xa,0xd8,0xed,0x7f,0xf7,0xd6,0xb0,0x8c,0x9,0x13,0x1b], dtype=np.uint8)
c6 = np.array([0xf9,0x18,0xa4,0x0,0x99,0x38,0x1d,0xd2,0x70,0xd7,0x7,0x5d,0x5,0xc0,0xe5,0xb7], dtype=np.uint8)
c7 = np.array([0x6c,0x2e,0xe8,0x7a,0xf0,0x80,0xb9,0xdf,0xf,0x98,0x7c,0x4a,0xa7,0x1,0xe7,0x45], dtype=np.uint8)
c8 = np.array([0x1d,0x8,0x2e,0xb7,0xbc,0xcd,0xb,0x94,0xde,0xee,0xe2,0x11,0x41,0xb2,0xa6,0xb4], dtype=np.uint8)

def mul32(vec1, vec2):
  a = vec1.view(np.uint32)
  b = vec2.view(np.uint32)
  return np.array([(int(a[0]) * int(b[0])), (int(a[2]) * int(b[2]))], dtype=np.uint64).view(np.uint8)

def slli16(vec, shift):
  return np.left_shift(vec.view(np.uint16), shift).view(np.uint8)

in2 = mul32(c5, c5) ^ slli16(c6 - c8, 2) ^ c7

Block III

// 2ab3: pabsb %xmm0,%xmm0
// 2ae5: pandn -0x3a0(%rbp),%xmm0
// 2b23: paddb %xmm1,%xmm0
// 2b5c: paddb %xmm1,%xmm0
// 2b95: pcmpeqb %xmm1,%xmm0

__m128i op12 = _mm_abs_epi8(c9);
__m128i op13 = _mm_andnot_si128(op12, c10);
__m128i op14 = _mm_add_epi8(op13, in3);
__m128i op15 = _mm_add_epi8(op14, c11);
__m128i test3 = _mm_cmpeq_epi8(op15, all_bits);

_mm_store_si128((__m128i *)(out + 32), test3);

Im dritten Block ergibt sich die Berechnung der nächsten 16 Byte des Ausgabe-Buffers erneut aus einem 16x8-Bit-Vergleich zwischen dem Vektor mit allen gesetzten Bits und einer Sequenz an SIMD-Operationen. Diesmal entspricht der Ausdruck TRUE = (((!abs8(C9) and C10) add8 IN3) add8 C11). Auch dieser Ausdruck lässt sich zur nötigen Eingabe umstellen: IN3 = ZERO sub8 ((!abs8(C9) and C10) add8 C11 sub8 TRUE).

c9 = np.array([0x37,0x71,0x71,0x87,0x1c,0x9d,0xe,0x12,0x6d,0xac,0xc3,0xcc,0x30,0xf6,0x5e,0x36], dtype=np.uint8)
c10 = np.array([0xc9,0x18,0x18,0xf6,0xdd,0xa8,0x6a,0x50,0x25,0x12,0xcc,0xeb,0x8d,0x39,0x74,0x28], dtype=np.uint8)
c11 = np.array([0xfd,0xbd,0xaa,0x1f,0xb,0x45,0x4f,0x60,0x8a,0x94,0xcb,0xcd,0x40,0x74,0x62,0xd7], dtype=np.uint8)

all_bits = np.array([0xff for x in range(0, 16], dtype=np.uint8)
in3 = -((np.invert(np.abs(c9)) & c10) + c11 - all_bits)
Letzendlich können wir mit diesen Berechnungen die Flagge PCG{p4rty-f00d-n-dr1nks-For-fr33::Mp3P_uY(2} bestimmen:
flag = ''
for chunk in [in1, in2, in3]:
  for c in chunk:
    flag += chr(int(c))
print(flag)

Entlassene Admins

Ziel dieser Aufgabe war es, eine versteckte Funktion in einer minimalen x86-64-ELF-Datei mit Anti-Debugging-Eigenschaften zu finden und auszuführen, sowie die nach der ELF-Datei angefügte Keepass-Datei zu öffnen. Einen kleinen Hinweis darauf erhalten wir auch schon in der Beschreibung:

[...] Da geht es schon los! Passwörter sind nun natürlich auch mit dem ganzen Know-How verschwunden. Gefunden wurde aber eine unbekannte, ausführbare Datei. Sieht auf den ersten Blick nicht wirklich nützlich aus, oder?

Flag I: Anti-Debug Binary

Führt man das Binary auf einem x86-64 Linux-System aus, gibt dieses eine Nachricht aus und beendet sich anschließend selbst:

$ ./passwords
No passwords here... ;)

Um mehr Informationen zu erhalten, könnten wir versuchen das Programm disassemblieren:

$ objdump -d ./passwords
objdump: ./passwords: file format not recognized
Auch mit gdb lässt sich das Executable nicht debuggen, stattdessen beendet sich der Debugger mit not in executable format: file format not recognized. In Ghidra lässt sich das Binary öffnen, aber nur im Layout anzeigen. Der Decompiler stolpert über einige Instruktionen mit WARNING: Bad instruction - Truncating control flow here. Werfen wir also einen genaueren Blick auf die Ausgabe von Ghidra:

// Loadable segment  [0x28000 - 0x281a8]
// ram:00028000-ram:000281a8
[...]
00028000 7f              db        7Fh                     e_ident_magi
00028001 45 4c 46        ds        "ELF"                   e_ident_magi
00028004 02              db        2h                      e_ident_class
00028005 01              db        1h                      e_ident_data
00028006 01              db        1h                      e_ident_vers
00028007 c7              db        C7h                     e_ident_osabi
00028008 45              db        45h                     e_ident_abiv
00028009 fc 2b ef 32 95  db[7]                             e_ident_pad
         eb 18
00028010 02 00           dw        2h                      e_type
00028012 3e 00           dw        3Eh                     e_machine
00028014 01 00 00 00     ddw       1h                      e_version
00028018 82 80 02 00 00  dq        entry                   e_entry
         00 00 00
00028020 3a 00 00 00 00  dq        Elf64_Ehdr_00028000.e_  e_phoff
         00 00 00
00028028 c7 45 f8 0c fc  dq        EB21FFFC0CF845C7h       e_shoff
         ff 21 eb
00028030 63 90 90 90     ddw       90909063h               e_flags
00028034 40 00           dw        40h                     e_ehsize
00028036 38 00           dw        38h                     e_phentsize
00028038 01 00           dw        1h                      e_phnum
0002803a 01 00           dw        1h                      e_shentsize
0002803c 00 00           dw        0h                      e_shnum
0002803e 05 00           dw        5h                      e_shstrndx
[...]

Zunächst informiert uns Ghidra, dass nur ein Teil der Datei - die ersten 424 Bytes - als einziges Segment in den Speicher an der Adresse 0x28000 geladen wird. Wir werden uns den Rest später noch einmal im Detail ansehen.

Anschließend folgt der Header der ELF-Datei. Während die meisten Felder für die Architektur passend gefüllt sind, sind (1) die OS-ABI, die ABI-Version und die Padding-Bytes auf ungültige Werte gesetzt und (2) zeigt der Section Table Offset, die Flags und alle Section Table-bezogenen Felder auf ungültige Werte. Sehen wir uns also die nächsten Bytes an:

FUN_00028052():
00028052 0f 05           SYSCALL
00028054 b0 3c           MOV        AL,0x3c
00028056 31 ff           XOR        EDI,EDI
00028058 0f 05           SYSCALL
[...]

entry():
00028082 31 c0           XOR        EAX,EAX
00028084 ff c0           INC        EAX
00028086 89 c7           MOV        EDI,EAX
00028088 be 6a 80        MOV        ESI,0x2806a
         02 00
0002808d 31 d2           XOR        EDX,EDX
0002808f b2 18           MOV        DL,0x18
00028091 eb bf           JMP        FUN_00028052

Folgt man dem Programmablauf, so sehen wir, dass im Entrypoint %eax = 1, %edi = 1, %esi = 0x2806a, %edx = 0 und %dl = 0x18 gesetzt werden und im Anschluss am Offset 00028052 mit einem SYSCALL fortgefahren wird. 00028052 gehört eigentlich noch zum Padding nach dem ELF-Header. Entsprechend der x86-64 Calling Conventions für Linux wird die Syscall-Nummer in %rax und die Syscall-Parameter in %rdi, %rsi und %rdx gesetzt werden.

In der x86-64-Architektur werden dieselben Register mit unterschiedlichen Größen unterschiedlich benannt. Die Zuweisungen an die Register aus dem Entrypoint entsprechen also Zuweisungen der geringerwertigen Bytes entsprechend der Register-Mappings für 32-Bit-Namen: %eax = %eax, %edi = %rdi, %esi = %rsi, %edx = rdx. Das 8-Bit %dl-Register entspricht dem unteren Byte von %rdx. Mithilfe einer Syscall-Tabelle für Linux lässt sich daraus schließen, dass hier der Syscall sys_write(1, 0x2806a, 0x18) ausgeführt wird. An der Adresse 0x2806a finden wir den String, mit welchem wir vom Binary begrüßt wurden. Das Handle 1 ist dabei nach Linux-Konvention die Standardausgabe stdout. Der darauf folgende Syscall 60 (sys_exit) beendet das Program. Was ist also mit dem Rest der Speicherregion?

Beim Blick durch die dekompilierbaren Abschnitte, dürfte dieser Abschnitt ins Auge fallen, welcher zwar nicht als Funktion erkannt wird, aber scheinbar Wörter in ein Stack Frame lädt, in einer entrollten Schleife byteweise ge-xored und in einen Buffer schreibt. Anschließend setzt der Code - analog zum Entrypoint - die Parameter für den sys_write-Syscall und springt in dieselbe Funktion um den Buffer auszugeben:

000280e1 c7              ??         C7h

LAB_000280e2:
000280e2 45 c8 0b        ENTER      0x30b,0x16
         03 d6
000280e7 ed              IN         EAX,DX
000280e8 c7 45 c4        MOV        dword ptr [RBP + -0x3c],0xc17f2747
         47 27 7f c1
000280ef c7 45 c0        MOV        dword ptr [RBP + -0x40],0xaa952214
         14 22 95 aa
000280f6 eb 0d           JMP        LAB_00028105

[...]

LAB_00028105:
00028105 48 c7 45        MOV        qword ptr [RBP + -0x70],0x0
         90 00 00
         00 00
0002810d 48 c7 45        MOV        qword ptr [RBP + -0x68],0x0
         98 00 00
         00 00

[... usw ...]

00028125 c6 45 b0 0a     MOV        byte ptr [RBP + -0x50],0xa
00028129 8b 45 fc        MOV        EAX,dword ptr [RBP + -0x4]
0002812c 89 c2           MOV        EDX,EAX
0002812e 8b 45 dc        MOV        EAX,dword ptr [RBP + -0x24]
00028131 31 d0           XOR        EAX,EDX
00028133 89 45 90        MOV        dword ptr [RBP + -0x70],EAX
00028136 8b 45 f8        MOV        EAX,dword ptr [RBP + -0x8]
00028139 89 c2           MOV        EDX,EAX
0002813b 8b 45 d8        MOV        EAX,dword ptr [RBP + -0x28]
0002813e 31 d0           XOR        EAX,EDX

[... usw ...]

00028191 48 8d 75 90     LEA        RSI,[RBP + -0x70]
00028195 ba 21 00        MOV        EDX,0x21
         00 00
0002819a b8 01 00        MOV        EAX,0x1
         00 00
0002819f bf 01 00        MOV        EDI,0x1
         00 00
000281a4 e9 a9 fe        JMP        FUN_00028052
         ff ff

Der Code bildet also eine Art von Entschlüsselungsfunktion für einen geheimen String - aber offensichtlich wird nur ein kleiner Teil der Bytes auf dem Stack geschrieben. Alle MOV dword ptr [RBP + X], Y-Instruktionen haben allerdings eine ähnliche Kodierung, bei der sich die Addresse relativ zum RBP in der Bytefolge c7 45 manifestiert. Diese findet sich an vielen Stellen im Programm, unter anderem direkt vor der falsch erkannten ENTER-Instruktion und an verschiedenen Stellen im Header.

Entfernt man die automatischen Analyseergebnisse von Ghidra (C) und disassembliert (D) den Abschnitt mit einem Byte Offset nach dem Jump manuell entdeckt Ghidra die tatsächlichen Instruktionen. Der tatsächliche Entrypoint findet sich dann durch die Operationen zur Stack-Allokation:

000280f9 48 89 e5        MOV        RBP,RSP
000280fc 48 83 ec 70     SUB        RSP,0x70
00028100 e9 02 ff        JMP        Elf64_Ehdr_00028000.field5_0x7
         ff ff

Wenn wir also den Entrypoint auf unsere Adresse (0x0280f9) patchen, wird - mit einigen Jumps quer durch das Binary und den ELF-Header - der vollständige Schlüssel geladen und die Flag ausgegeben:

$ cp passwords passwords_patched
$ printf "\xf9\x80\x02" > patch
$ dd if=patch of=passwords_patched obs=1 seek=24 conv=notrunc
$ chmod +x passwords_patched && ./passwords_patched
PCG{E-l-f-he4d3r::fAn1NJHpvRX3}

Flag II: Keepass

Uns ist am Anfang aufgefallen, dass das Memory Segment der ELF-Datei nur eine Länge von 424 Bytes hat, die Datei aber deutlich größer ist. Schneiden wir die ersten 425 Bytes von der Datei ab erhalten wir eine neue Datei:

$ tail -c +425 passwords > passwords2
$ file passwords2
passwords2: dBase III DBT, version number 0, next free block index 255
$ hexdump passwords2
0000000 00ff 0000 0000 0000 0000 0000 0000 0000
0000010 0000 0000 0000 0000 0000 0000 0000 0000
*
0000050 0000 0000 0000 0000 d903 9aa2 fb67 b54b
[...]
Bei dieser scheinen allerdings die ersten 89 Bytes aus Padding zu bestehen. Entfernen wir auch dieses, so erhalten wir ein interessanteres Ergebnis:
$ tail -c +89 passwords2 > passwords3
$ file passwords3
passwords3: Keepass password database 2.x KDBX
Öffnen wir die Datei mit z.B. KeepassXC und verwenden die Flagge aus dem Binary als Passwort, so finden wir einen einzelnen Eintrag für den Nutzer root des server-1337 im Keystore. Dessen Passwort - hier PCG{h1dd3n-In-Pl4in-5ight::eWJw7iciD9Yi} ist die zweite Flagge der Challenge.

Danksagung

Vielen Dank an Felix Gohla für die Aufgabenbeschreibungen und die Keepass-Erweiterung.