광고
~ / posts / developer
smith@lab:~/posts$ cat developer/wordexp-tilde-expansion-command-injection-guide.md

wordexp 쓰면 안 되는 이유, 물결표 경로 안전하게 펼치는 법

설정 파일에 log_path = ~/logs/app.log라고 적혀 있으면 C 프로그램은 물결표(~)를 홈 디렉터리로 바꿔야 해요. 검색하면 자주 나오는 답이 wordexp(3)인데, 물결표만 필요하다면 wordexp는 쓰지 말고 HOME 환경 변수(없으면 getpwuid)로 직접 펼치는 게 맞아요. wordexp는 물결표 하나를 위해 셸의 단어 확장 전체를 돌리고, 그 안에는 명령 실행까지 들어 있어요.

"WRDE_NOCMD를 주면 되지 않나?" 싶지만, macOS 매뉴얼은 BUGS 절에 "이 플래그와 상관없이 신뢰할 수 없는 데이터를 넘기지 말라"고 적어 두었어요. 이 글은 그 이유와, 직접 컴파일해 확인한 동작표·속도, 상황별 대체 코드를 정리해요.

광고

wordexp(3)가 실제로 하는 일, 셸 확장 7단계

POSIX의 wordexp 명세는 이 함수를 "셸이 명령 인자에 하는 단어 확장을 똑같이 해 주는 함수"로 정의해요. 리눅스 매뉴얼이 적은 단계는 이래요.

  1. 물결표 확장: ~, ~user를 홈 디렉터리로
  2. 변수 치환: $FOO, ${FOO}
  3. 명령 치환: $(command), ` command `의 출력으로 바꾸기
  4. 산술 확장: $((1+2)) → 3
  5. 단어 나누기: IFS 기준(기본은 공백·탭·줄바꿈)
  6. 와일드카드 확장: *.c를 실제 파일 목록으로
  7. 따옴표 제거

3번은 보안 문제이고, 5번과 6번은 경로 코드에 조용한 버그를 만들어요. 매뉴얼의 예제가 wordexp("${EDITOR:-vi} *.c /etc/motd", ...)로 결과를 execvp에 바로 넘기는 것만 봐도, 이 함수는 원래 "셸 명령줄 흉내"용이지 경로 정리용이 아니에요.

방법별로 실제 수행하는 확장 단계 수 (셸 확장 7단계 기준)
wordexp 플래그 0wordexp 플래그 0 · 7단계7단계wordexp WRDE_NOCMDwordexp WRDE_NOCMD · 6단계6단계glob GLOB_TILDEglob GLOB_TILDE · 3단계3단계직접 펼치기직접 펼치기 · 1단계1단계Python expanduserPython expanduser · 1단계1단계

물결표만 필요하면 1단계짜리 방법이면 충분해요

단위: 단계 · 자료: POSIX·리눅스 매뉴얼의 단계 정의로 분류 (glob은 물결표·패턴 매칭·백슬래시 이스케이프)
표로 보기
구분단계 수
wordexp 플래그 07
wordexp WRDE_NOCMD6
glob GLOB_TILDE3
직접 펼치기1
Python expanduser1

직접 돌려 본 결과, 경로 하나가 둘로 쪼개져요

macOS 26.2(Apple M1)에서 플래그 0과 WRDE_NOCMD | WRDE_UNDEF로 같은 입력을 넣어 봤어요. 반환값 1은 WRDE_BADCHAR, 2는 WRDE_BADVAL, 3은 WRDE_CMDSUB예요(macOS wordexp.h 기준).

입력플래그 0WRDE_NOCMD \WRDE_UNDEF
~/notes.txt/Users/me/notes.txt같음
$(echo hi)hi (명령 실행)실패 3
` echo hi `hi (명령 실행)실패 3
my file.txtmy, file.txt 두 단어같음
*.c폴더의 .c 파일 3개같음
$NOPE/a/a실패 2
a;b실패 1실패 1
$((1+2))33
'~/x'~/x같음

눈여겨볼 줄이 세 개 있어요.

단순한 $(...)와 백틱은 막혔어요. 문제는 검사가 모든 경우를 잡느냐예요.

WRDE_NOCMD가 뚫리는 이유, 파서가 두 개

입력 문자열이 libc의 간이 검사기를 '명령 치환 없음'으로 통과한 뒤 /bin/sh가 같은 문자열을 다르게 해석해 명령이 실행되는 네 단계 흐름과, musl·macOS와 glibc의 구조 차이를 비교한 그림
검사기와 실제 해석기가 다르면, 둘이 다르게 읽는 입력이 곧 우회 경로예요

구현마다 확장을 처리하는 방식이 달라요.

구현확장 방식WRDE_NOCMD 처리
musl/bin/sh -c "eval ..."로 셸에 위임위임 전에 따옴표·괄호 짝을 세는 간이 검사
macOS/usr/lib/system/wordexp-helper에 위임매뉴얼 BUGS 절이 "같은 파서가 아니라 속을 수 있다"고 명시
glibc자체 파서로 직접 확장, 명령 치환만 셸 사용2014년 수정 뒤 명령을 실행하는 지점에서 거부
OpenBSDwordexp 자체를 제공하지 않음해당 없음

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 하나를 펼치는 시간을 반복 측정했어요(세 번 돌려 평균이 거의 같았어요).

~/notes.txt 하나를 펼치는 데 걸린 시간 (호출 1회)
wordexp WRDE_NOCMDwordexp WRDE_NOCMD · 21,000µs21,000µsglob GLOB_TILDEglob GLOB_TILDE · 4.9µs4.9µs직접 펼치기직접 펼치기 · 0.42µs0.42µs

wordexp는 약 21ms로 glob의 약 4,300배, 직접 펼치기의 약 5만 배예요

단위: µs · 자료: Apple M1·macOS 26.2에서 직접 측정 (wordexp 300회, glob 3만 회, 직접 펼치기 30만 회 평균, -O2)
표로 보기
구분시간
wordexp WRDE_NOCMD21,000
glob GLOB_TILDE4.9
직접 펼치기0.42

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.txtmy file.txt (한 덩어리 그대로)
data/~/xdata/~/x (맨 앞이 아니면 손대지 않음)

HOME이 비어 있어도 getpwuid로 같은 결과가 나왔어요. 핵심은 바꾸는 범위를 맨 앞 한 토큰으로 좁히고, 나머지 글자는 그대로 둔다는 점이에요. 경로 안의 $, 공백, *는 파일 이름의 일부일 뿐이에요.

광고
광고

다른 대안 고르기, glob·변수 허용 목록·언어별 함수

물결표만 바꾸면 되는지, ~user가 필요한지, 이미 있는 파일을 패턴으로 찾는지, 정해진 환경 변수를 펼쳐야 하는지를 차례로 물어 직접 펼치기·getpwnam·glob·허용 목록 치환·설계 재검토로 나누는 판단 순서도
셸 문법 전체가 필요하다는 결론이 나오면, 대부분 입력 형식부터 다시 정할 때예요

이미 있는 파일을 패턴으로 찾을 때, glob()

*.log 같은 패턴이 정말 필요하면 glob(3)을 써요. 셸을 띄우지 않고, 명령 치환·변수·단어 나누기를 하지 않아요. Squires도 GLOB_TILDE와 함께 쓰는 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}는 오류가 났어요. 변수 패턴을 바꿀 땐 정규식 테스터에 경로 몇 개를 넣어 먼저 확인해요.

언어별로 이미 있는 함수

언어물결표 처리셸을 쓰나
Pythonos.path.expanduser — HOME, 없으면 pwd 모듈아니요
Pythonos.path.expandvars — $VAR만, 모르는 변수는 그대로 둠아니요
Goos.UserHomeDir로 홈을 얻어 직접 붙이기아니요
Node.jsos.homedir()로 홈을 얻어 직접 붙이기아니요

모두 셸 없이 문자열만 다뤄요. 반대로 파이썬의 subprocess.run(..., shell=True), C의 system()·popen()에 사용자 문자열을 이어 붙이는 코드는 wordexp와 같은 종류의 위험이에요. 명령을 실행해야 한다면 subprocess 문서가 권하듯 인자를 리스트로 넘겨 셸 해석을 건너뛰어요. 이런 유형은 CWE-78(OS 명령 주입)으로 분류돼요. 출력 쪽 이스케이프 원리는 HTML 엔티티 이스케이프와 XSS 방어와 같아요. "해석기가 읽기 전에, 데이터가 문법이 되지 않게" 하는 거예요.

코드베이스 점검 체크리스트

  1. 호출 위치 찾기: grep -rn "wordexp" --include='*.c' --include='*.h' --include='*.cc' --include='*.rs' .로 찾고, Rust·Go·Python의 FFI 바인딩도 같이 봐요.
  2. 입력 출처 따라가기: 인자가 설정 파일, 환경 변수, 네트워크, 명령줄 중 어디서 오는지 확인해요. setuid·데몬처럼 권한이 높은 프로그램이 최우선이에요.
  3. 플래그 확인: 당장 못 바꾸면 최소한 WRDE_NOCMD | WRDE_UNDEF를 켜고, 결과가 정확히 한 단어(we_wordc == 1)인지 검사해요. 이건 임시방편이지 보안 경계가 아니에요.
  4. 빌드 대상 libc 확인: glibc, musl(Alpine), macOS 어느 쪽으로 배포되는지, 그 환경의 /bin/sh가 무엇인지 적어 둬요.
  5. 교체: 물결표만이면 직접 펼치기, 패턴이면 glob, 변수면 허용 목록으로 바꿔요.
  6. 회귀 테스트: 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 인자)로 받도록 입력 형식을 바꾸는 편이 안전해요.

정리

입력이 해석기를 멈추는 비슷한 문제는 정규식 탐욕적·게으른 매칭 차이와 ReDoS에서 다뤘어요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 wordexp 위험·C++26 부결·LLM 가격 인하 — IT 뉴스에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#wordexp 보안#WRDE_NOCMD 우회#물결표 경로 확장#C 홈 디렉터리 경로#명령 주입 방지#glob GLOB_TILDE#expanduser#셸 확장 위험
← 이전 글벡터 인덱스 고르기, HNSW·IVF·양자화 메모리와 비용 비교다음 글 →백트래킹 시간 초과 판단법, n 크기별 경우의 수 표와 가지치기 점검
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →