설정 파일에 log_path = ~/logs/app.log라고 적혀 있으면 C 프로그램은 물결표(~)를 홈 디렉터리로 바꿔야 해요. 검색하면 자주 나오는 답이 wordexp(3)인데, 물결표만 필요하다면 wordexp는 쓰지 말고 HOME 환경 변수(없으면 getpwuid)로 직접 펼치는 게 맞아요. wordexp는 물결표 하나를 위해 셸의 단어 확장 전체를 돌리고, 그 안에는 명령 실행까지 들어 있어요.
"WRDE_NOCMD를 주면 되지 않나?" 싶지만, macOS 매뉴얼은 BUGS 절에 "이 플래그와 상관없이 신뢰할 수 없는 데이터를 넘기지 말라"고 적어 두었어요. 이 글은 그 이유와, 직접 컴파일해 확인한 동작표·속도, 상황별 대체 코드를 정리해요.
wordexp(3)가 실제로 하는 일, 셸 확장 7단계
POSIX의 wordexp 명세는 이 함수를 "셸이 명령 인자에 하는 단어 확장을 똑같이 해 주는 함수"로 정의해요. 리눅스 매뉴얼이 적은 단계는 이래요.
- 물결표 확장:
~,~user를 홈 디렉터리로 - 변수 치환:
$FOO,${FOO} - 명령 치환:
$(command), `command`의 출력으로 바꾸기 - 산술 확장:
$((1+2))→3 - 단어 나누기:
IFS기준(기본은 공백·탭·줄바꿈) - 와일드카드 확장:
*.c를 실제 파일 목록으로 - 따옴표 제거
3번은 보안 문제이고, 5번과 6번은 경로 코드에 조용한 버그를 만들어요. 매뉴얼의 예제가 wordexp("${EDITOR:-vi} *.c /etc/motd", ...)로 결과를 execvp에 바로 넘기는 것만 봐도, 이 함수는 원래 "셸 명령줄 흉내"용이지 경로 정리용이 아니에요.
직접 돌려 본 결과, 경로 하나가 둘로 쪼개져요
macOS 26.2(Apple M1)에서 플래그 0과 WRDE_NOCMD | WRDE_UNDEF로 같은 입력을 넣어 봤어요. 반환값 1은 WRDE_BADCHAR, 2는 WRDE_BADVAL, 3은 WRDE_CMDSUB예요(macOS wordexp.h 기준).
| 입력 | 플래그 0 | WRDE_NOCMD \ | WRDE_UNDEF |
|---|---|---|---|
~/notes.txt | /Users/me/notes.txt | 같음 | |
$(echo hi) | hi (명령 실행) | 실패 3 | |
` echo hi ` | hi (명령 실행) | 실패 3 | |
my file.txt | my, file.txt 두 단어 | 같음 | |
*.c | 폴더의 .c 파일 3개 | 같음 | |
$NOPE/a | /a | 실패 2 | |
a;b | 실패 1 | 실패 1 | |
$((1+2)) | 3 | 3 | |
'~/x' | ~/x | 같음 |
눈여겨볼 줄이 세 개 있어요.
my file.txt가 두 단어가 돼요.we_wordv[0]만 쓰는 코드라면 경로가my로 잘려요. 공백이 든 폴더 이름(Application Support같은)에서 그대로 터져요.*.c가 파일 목록으로 바뀌어요. 결과가 실행 시점의 폴더 내용에 따라 달라져요.$NOPE/a가 플래그 없이는/a가 돼요. 정의되지 않은 변수가 빈 문자열이 되면서 상대 경로가 루트 경로로 바뀌어요.WRDE_UNDEF를 빠뜨리면 엉뚱한 곳에 파일을 쓰게 돼요.
단순한 $(...)와 백틱은 막혔어요. 문제는 검사가 모든 경우를 잡느냐예요.
WRDE_NOCMD가 뚫리는 이유, 파서가 두 개
구현마다 확장을 처리하는 방식이 달라요.
| 구현 | 확장 방식 | WRDE_NOCMD 처리 |
|---|---|---|
| musl | /bin/sh -c "eval ..."로 셸에 위임 | 위임 전에 따옴표·괄호 짝을 세는 간이 검사 |
| macOS | /usr/lib/system/wordexp-helper에 위임 | 매뉴얼 BUGS 절이 "같은 파서가 아니라 속을 수 있다"고 명시 |
| glibc | 자체 파서로 직접 확장, 명령 치환만 셸 사용 | 2014년 수정 뒤 명령을 실행하는 지점에서 거부 |
| OpenBSD | wordexp 자체를 제공하지 않음 | 해당 없음 |
musl의 wordexp.c 소스를 열면 구조가 그대로 보여요. WRDE_NOCMD가 켜져 있으면 문자열을 한 글자씩 훑으며 따옴표 상태와 괄호 깊이를 세고, $(나 백틱이 보이면 WRDE_CMDSUB를 돌려줘요. 그런데 $((로 시작하면 산술 확장으로 보고 통과시켜요. 그다음 fork로 /bin/sh를 띄워 같은 문자열을 eval해요.
여기서 틈이 생겨요. 셸 문법에서 $((는 산술 확장일 수도 있고, 명령 치환 안에 서브셸 괄호가 바로 붙은 것일 수도 있어요. 어느 쪽인지는 뒤쪽 괄호가 어떻게 닫히는지까지 봐야 정해지고, 셸마다 판단이 달라요. Julian Squires의 2023년 분석은 간이 검사기가 산술로 본 입력을 bash가 명령 치환으로 실행하는 경우를 보고했고, 같은 입력을 dash는 거부했다고 적었어요. yossarian.net의 TIL 글도 musl의 검사가 bash가 명령 치환으로 읽는 일부 패턴을 놓친다고 경고해요. 즉, 같은 musl이라도 /bin/sh가 무엇이냐에 따라 안전 여부가 달라져요.
POSIX 명세의 해설(Rationale)도 셸에 넘기는 구현은 서브셸을 띄우기 전에 단어를 미리 훑어야 한다고 적었어요. 그런데 셸 문법 전체를 다시 구현하지 않는 한, 미리 훑는 검사기는 셸과 어긋날 수밖에 없어요.
glibc는 반대로 갔어요. Squires에 따르면 2014년 수정 뒤로 명령 치환을 미리 맞히는 대신 실제로 명령을 실행하려는 지점에서 플래그를 확인해요. 대가는 약 2,500줄짜리 자체 파서이고, 그래도 Squires는 wordexp를 아예 쓰지 말라고 권해요.
보안을 빼도 느려요, 호출마다 프로세스를 띄워요
musl은 /bin/sh를, macOS는 헬퍼 프로세스를 띄워요. 경로 하나에 프로세스 생성 비용이 붙어요. 같은 Apple M1·macOS 26.2에서 ~/notes.txt 하나를 펼치는 시간을 반복 측정했어요(세 번 돌려 평균이 거의 같았어요).
21ms면 경로 50개를 펼치는 데 1초가 넘게 걸려요. 리눅스 매뉴얼은 wordexp를 MT-Unsafe로 분류하기도 해요.
물결표만 필요할 때, 직접 펼치는 C 코드
대부분의 경우는 "맨 앞의 ~나 ~/를 홈 디렉터리로"면 끝나요. 셸과 같은 순서로 HOME을 먼저 보고, 비어 있으면 비밀번호 데이터베이스(getpwuid)를 봐요. ~user도 받아야 하면 사용자 이름에 쓸 수 있는 글자만 허용한 뒤 getpwnam으로 찾아요.
#include <ctype.h>
#include <pwd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* 맨 앞의 "~" "~/..." "~user/..." 만 바꾼다. 나머지 글자는 손대지 않는다.
성공 0, 사용자 없음·버퍼 부족 -1 */
static int expand_home(const char *in, char *out, size_t n) {
if (in[0] != '~')
return snprintf(out, n, "%s", in) < (int)n ? 0 : -1;
const char *rest = strchr(in, '/');
size_t name_len = rest ? (size_t)(rest - in - 1) : strlen(in + 1);
if (rest == NULL) rest = "";
const char *home = NULL;
if (name_len == 0) { /* ~ 또는 ~/... */
home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
struct passwd *pw = getpwuid(getuid());
if (pw == NULL) return -1;
home = pw->pw_dir;
}
} else { /* ~user/... */
char name[64];
if (name_len >= sizeof name) return -1;
for (size_t i = 0; i < name_len; i++) {
unsigned char c = (unsigned char)in[1 + i];
if (!isalnum(c) && c != '_' && c != '-' && c != '.') return -1;
name[i] = (char)c;
}
name[name_len] = '\0';
struct passwd *pw = getpwnam(name);
if (pw == NULL) return -1;
home = pw->pw_dir;
}
return snprintf(out, n, "%s%s", home, rest) < (int)n ? 0 : -1;
}
cc -Wall -Wextra로 경고 없이 빌드되고, 입력별 결과는 이랬어요.
| 입력 | 결과 |
|---|---|
~ | /Users/me |
~/notes.txt | /Users/me/notes.txt |
~root/.ssh | /var/root/.ssh |
~/$(whoami) | /Users/me/$(whoami) (글자 그대로) |
~$(id)/x | 실패 (허용하지 않은 글자) |
my file.txt | my file.txt (한 덩어리 그대로) |
data/~/x | data/~/x (맨 앞이 아니면 손대지 않음) |
HOME이 비어 있어도 getpwuid로 같은 결과가 나왔어요. 핵심은 바꾸는 범위를 맨 앞 한 토큰으로 좁히고, 나머지 글자는 그대로 둔다는 점이에요. 경로 안의 $, 공백, *는 파일 이름의 일부일 뿐이에요.
다른 대안 고르기, glob·변수 허용 목록·언어별 함수
이미 있는 파일을 패턴으로 찾을 때, glob()
*.log 같은 패턴이 정말 필요하면 glob(3)을 써요. 셸을 띄우지 않고, 명령 치환·변수·단어 나누기를 하지 않아요. Squires도 GLOB_TILDE와 함께 쓰는 glob()을 대안으로 들었어요. 다만 두 가지를 알고 써야 해요.
GLOB_TILDE는 POSIX 표준 플래그가 아니라 glibc·BSD 계열의 확장이에요. POSIX glob 명세에는 없어요.- 일치하는 파일이 없으면 물결표도 펼쳐지지 않아요. macOS에서
GLOB_TILDE | GLOB_NOCHECK로 없는 경로~/my file.txt를 넣으면~/my file.txt가 그대로 돌아왔어요. 아직 만들지 않은 로그 파일 경로처럼 "앞으로 쓸 경로"에는 맞지 않으니, 물결표는 앞의 함수로 먼저 펼치고glob에는 패턴만 맡기는 편이 안전해요.
환경 변수를 펼쳐야 할 때, 허용 목록
${XDG_CONFIG_HOME}/app/config.toml처럼 변수를 지원해야 한다면, 정해진 이름만 치환하고 모르는 이름은 오류로 돌려요. 아무 변수나 펼치면 ${AWS_SECRET_ACCESS_KEY} 같은 값이 로그나 오류 메시지의 경로에 섞여 나갈 수 있어요. 파이썬으로 쓰면 이래요.
import os
import re
ALLOWED = {"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"}
VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
def expand_path(raw: str) -> str:
def repl(m: re.Match) -> str:
name = m.group(1)
if name not in ALLOWED:
raise ValueError(f"허용하지 않은 변수: {name}")
value = os.environ.get(name)
if not value:
raise ValueError(f"비어 있는 변수: {name}")
return value
return os.path.expanduser(VAR.sub(repl, raw))
~/$(whoami)/x는 글자 그대로 남고, ${AWS_SECRET_ACCESS_KEY}는 오류가 났어요. 변수 패턴을 바꿀 땐 정규식 테스터에 경로 몇 개를 넣어 먼저 확인해요.
언어별로 이미 있는 함수
| 언어 | 물결표 처리 | 셸을 쓰나 |
|---|---|---|
| Python | os.path.expanduser — HOME, 없으면 pwd 모듈 | 아니요 |
| Python | os.path.expandvars — $VAR만, 모르는 변수는 그대로 둠 | 아니요 |
| Go | os.UserHomeDir로 홈을 얻어 직접 붙이기 | 아니요 |
| Node.js | os.homedir()로 홈을 얻어 직접 붙이기 | 아니요 |
모두 셸 없이 문자열만 다뤄요. 반대로 파이썬의 subprocess.run(..., shell=True), C의 system()·popen()에 사용자 문자열을 이어 붙이는 코드는 wordexp와 같은 종류의 위험이에요. 명령을 실행해야 한다면 subprocess 문서가 권하듯 인자를 리스트로 넘겨 셸 해석을 건너뛰어요. 이런 유형은 CWE-78(OS 명령 주입)으로 분류돼요. 출력 쪽 이스케이프 원리는 HTML 엔티티 이스케이프와 XSS 방어와 같아요. "해석기가 읽기 전에, 데이터가 문법이 되지 않게" 하는 거예요.
코드베이스 점검 체크리스트
- 호출 위치 찾기:
grep -rn "wordexp" --include='*.c' --include='*.h' --include='*.cc' --include='*.rs' .로 찾고, Rust·Go·Python의 FFI 바인딩도 같이 봐요. - 입력 출처 따라가기: 인자가 설정 파일, 환경 변수, 네트워크, 명령줄 중 어디서 오는지 확인해요. setuid·데몬처럼 권한이 높은 프로그램이 최우선이에요.
- 플래그 확인: 당장 못 바꾸면 최소한
WRDE_NOCMD | WRDE_UNDEF를 켜고, 결과가 정확히 한 단어(we_wordc == 1)인지 검사해요. 이건 임시방편이지 보안 경계가 아니에요. - 빌드 대상 libc 확인: glibc, musl(Alpine), macOS 어느 쪽으로 배포되는지, 그 환경의
/bin/sh가 무엇인지 적어 둬요. - 교체: 물결표만이면 직접 펼치기, 패턴이면
glob, 변수면 허용 목록으로 바꿔요. - 회귀 테스트:
my file.txt,*,$(id),$UNDEFINED/x,~nouser/x를 넣어 결과가 글자 그대로이거나 명확한 오류인지 확인해요.
자주 묻는 질문
WRDE_NOCMD만 켜면 wordexp를 써도 되나요?
신뢰할 수 있는 입력(프로그램 안에 박힌 문자열)이라면 큰 문제는 없어요. 하지만 외부에서 온 문자열이라면 아니에요. musl·macOS는 간이 검사기와 실제 셸이 문법을 다르게 읽는 틈이 있고, 명령 치환이 막혀도 단어 나누기·글롭·변수 확장은 그대로 일어나요.
glibc는 안전하다던데 그냥 써도 되지 않나요?
glibc는 실행 지점에서 막아 더 단단한 편이에요. 그래도 musl 컨테이너나 macOS에서 빌드하면 보장이 사라지고, 공백 쪼개짐·글롭은 glibc에서도 같아요.
물결표 확장은 HOME과 getpwuid 중 무엇을 먼저 봐야 하나요?
셸과 같은 동작을 원하면 HOME을 먼저 보고, 비어 있을 때만 getpwuid(getuid())를 봐요. 파이썬 expanduser도 이 순서예요. 다만 setuid 프로그램처럼 환경 변수를 믿으면 안 되는 경우에는 getpwuid만 쓰는 게 맞아요.
경로 중간의 ~도 바꿔야 하나요?
아니요. 셸도 단어 맨 앞의 물결표만 확장해요. data/~/x의 ~는 그냥 폴더 이름이라, 바꾸면 그 경로가 깨져요.
wordexp가 느린 이유는 무엇인가요?
musl은 호출마다 /bin/sh를, macOS는 헬퍼 프로세스를 띄워요. Apple M1에서 호출당 약 21ms로, 직접 펼치기(0.42µs)보다 약 5만 배 느렸어요.
사용자에게 셸 문법 그대로 입력받아야 하는 기능이면요?
그 기능은 사실상 "사용자 명령 실행"이에요. 격리된 환경에서 사용자 권한으로 실행하도록 설계하거나, 인자를 배열(JSON 배열, 여러 CLI 인자)로 받도록 입력 형식을 바꾸는 편이 안전해요.
정리
wordexp(3)는 물결표 확장 함수가 아니라 셸 단어 확장 전체예요. 경로가 공백에서 쪼개지고,*가 파일 목록이 되고, 정의 안 된 변수가 빈 문자열이 돼요.WRDE_NOCMD는 musl·macOS에서 검사기와 셸이 따로라 우회될 수 있어요. glibc만 실행 지점에서 막고, 그마저 이식성은 보장하지 않아요.- 호출당 약 21ms(Apple M1)로 프로세스를 띄우는 비용도 커요.
- 물결표는
HOME→getpwuid순서로 직접 펼치고, 패턴은glob, 변수는 허용 목록으로 치환해요.
입력이 해석기를 멈추는 비슷한 문제는 정규식 탐욕적·게으른 매칭 차이와 ReDoS에서 다뤘어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 wordexp 위험·C++26 부결·LLM 가격 인하 — IT 뉴스에서 도식과 코드로 단계별로 풀었어요.