SD58. 다중 스레드 프로그램에서 getlogin() 사용 — getlogin_r로 치환

원본 항목: Part 7. API 오용 — 5. 다중 스레드 프로그램에서 getlogin() 사용 CWE-558 repo 폴더: sd58_getloginsim/ (빌드·실행은 §3-1) 목표 시간: 1회차 12분 / 2회차 7분 / 3회차 5분 Part 7(API 오용)의 마지막이자 58개 전체의 마지막 드릴이다. 실제 getlogin()은 제어 터미널(tty)이 있어야 동작해 테스트 환경에서 실패하기 쉬우므로, 정적 버퍼를 반환한다는 계약 자체를 흉내낸 대역 함수로 “스레드 간 공유”가 실제로 무엇을 망가뜨리는지 재현한다.

이 드릴의 문서 구성 (SD01~SD58 공통)

① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나. ② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다. ③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다. ④ 자주 하는 실수 ⑤ 모범답안 — 맨 뒤에 접어 둔다.

변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.


0. 이 드릴로 체화할 것

- struct passwd *pwd = getpwnam(getlogin());       /* 반환 버퍼가 스레드 간 공유 */
- if (isTrustedGroup(pwd->pw_gid)) return 1;
+ char id[MAX];
+ if (getlogin_r(id, MAX) != 0) return 0;          /* 지역 버퍼 + 실패 처리 */
+ pwd = getpwnam(id);
+ return isTrustedGroup(pwd->pw_gid) ? 1 : 0;

멀티스레드 환경에서 “정적 버퍼를 반환하는 함수”는 전부 _r(reentrant) 버전으로 바꾼다. 권한 판정처럼 중요한 값은 반드시 호출자 소유의 지역 버퍼에 고정해야 한다.

_r 버전이 없는 경우: 스레드 safe 한 함수가 제공되지 않으면 lock(뮤텍스)으로 호출~복사 구간 전체를 직렬화해야 한다. 호출만 lock으로 감싸면 부족하고, 반환 값을 지역 버퍼에 복사한 뒤에 unlock 한다.

static pthread_mutex_t login_lock = PTHREAD_MUTEX_INITIALIZER;
 
char id[MAX];
pthread_mutex_lock(&login_lock);
char *p = getlogin();                            /* _r 버전 없음 → lock 안에서 호출 */
if (p == NULL) { pthread_mutex_unlock(&login_lock); return 0; }
strncpy(id, p, MAX - 1);  id[MAX - 1] = '\0';    /* 지역 버퍼로 복사 후 */
pthread_mutex_unlock(&login_lock);               /* unlock (모든 경로에서) */

회차별 진행

회차변형보는 것순서
1회차A 두 사용자의 연속 로그인 조회① 요구사항서
② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다.
2. Good pseudo 를 한 줄씩 코드로 바꾼다.
3. ③ 테스트를 실행한다.
2회차B 권한 판정 로직까지 연결① 요구사항서
② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다.
2. 번호만 보고 Good 을 쓴다.
3. ③ 테스트를 실행한다.
3회차C strtok/ctime 등 다른 정적 버퍼 함수로 확장① 요구사항서만1. ③ 테스트를 먼저 읽는다.
2. 백지에서 Bad 부터 Good 까지 재현한다.

1회차에 막히면 같은 변형을 한 번 더 친다. 3회차를 통과하면 A 를 백지로 다시 친다.


1. 요구사항서

1-A. 변형 A — 두 사용자의 연속 로그인 조회

로그인한 사용자 이름을 조회해 이후 로직에 사용한다.

  • 한 호출의 결과가 다른 호출에 의해 바뀌어서는 안 된다.

함수

const char *getlogin_like_bad(const char *name);
int getlogin_like_good_r(const char *name, char *buf, size_t buf_size);

신뢰 경계

값출처검증 없이 흘러가는 곳
getlogin()류 함수의 반환 포인터함수 내부 정적 버퍼그 포인터를 나중에까지 붙들고 있다가 다시 읽는 지점

공격 입력표

상황Bad 결과이유
"alice"로 한 번 호출해 포인터를 저장 → 나중에 "bob"으로 다시 호출처음 저장해둔 포인터도 "bob"을 가리키게 됨두 호출이 같은 정적 버퍼 주소를 반환하기 때문이다
같은 상황(Good, _r 버전)처음 결과("alice")가 그대로 유지됨각 호출이 호출자가 준 별도의 버퍼에 결과를 쓴다

1-B. 변형 B — 권한 판정 로직까지 연결

가이드 원문처럼 조회한 이름으로 isTrustedGroup 판정을 수행하는 함수로 확장해, 오염된 이름이 잘못된 권한 부여로 이어지는 흐름까지 재현한다

1-C. 변형 C — strtok/ctime 등 다른 정적 버퍼 함수로 확장

같은 문제를 가진 다른 표준 함수(strtok, ctime, localtime)를 하나 골라, 그 함수의 _r/_s 버전으로 바꾸는 연습을 반복한다


2. pseudo code 템플릿 — 보고 쳐서 익힌다

사용법 변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다. 코드는 주석을 보고 내가 쓴다. 다 쓰면 [ ] 를 x 로 바꾼다. 막히면 §5 모범답안(A)의 해당 단계만 본다.

2-A. getlogin_like_good_r — Level 1 (1회차, 전부 본다)

먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다. 그다음 Good 을 쓴다.

const char *getlogin_like_bad(const char *name)
{
    /* 1. snprintf(g_shared_buf, sizeof(g_shared_buf), "%s", name)   ← 정적 버퍼
     * 2. return g_shared_buf   ← 스레드 간 공유 */
}
 
int getlogin_like_good_r(const char *name, char *buf, size_t buf_size)
{
    /* [ ] 1. 반환형을 포인터 → int(상태코드) 로 바꾼다
     *        결과는 **호출자 소유 buf** 에 쓴다 */
 
    /* [ ] 2. snprintf(buf, buf_size, "%s", name)
     *        잘렸으면 (>= buf_size) → return -1 */
 
    /* [ ] 3. return 0 */
}

2-B. getlogin_like_good_r — Level 2 (2회차, 번호와 제목만 본다)

int getlogin_like_good_r(const char *name, char *buf, size_t buf_size)
{
    /* [ ] 1. 반환형을 포인터 → int(상태코드) 로 바꾼다 */
 
    /* [ ] 2. snprintf(buf, buf_size, "%s", name) */
 
    /* [ ] 3. return 0 */
}

2-C. getlogin_like_good_r — Level 3 (3회차, 백지)

int getlogin_like_good_r(const char *name, char *buf, size_t buf_size)
{
    /* 아무것도 안 본다.
     * §1 요구사항서와 §0 의 치환 규칙만 보고 친다.
     *
     * 막히면 아래만 찾아본다.
     *   - `_r` 버전이 없으면 lock 으로 **호출~복사 구간 전체**를 직렬화
     *   - 모든 경로에서 unlock
     */
}

막혔을 때 찾는 순서

  1. 컴파일 에러가 나면 에러 문구를 본다. unknown type name / implicit declaration 이면 include 누락이다.
  2. man 3 <함수> 를 연다. SYNOPSIS 맨 위의 #include 를 확인한다.
  3. 로직이 막히면 §3 테스트의 기대값을 먼저 읽는다. 공격 입력표(§1)와 테스트가 답이다.
  4. 그래도 안 되면 모범답안의 해당 단계 하나만 본다.

3. 테스트 코드 — 이게 통과하면 끝

sd58_getloginsim/src/getloginsim.h
sd58_getloginsim/src/getloginsim.c
sd58_getloginsim/test/test.c

시험 구조는 고정이다 (SD01~SD58 공통)

  1. Bad 가 실제로 뚫린다. 이 시험이 실패하면 Bad 를 잘못 옮겨 적은 것이다.
  2. Bad 도 정상 입력은 처리한다.
  3. Good 이 같은 공격을 막는다.
  4. Good 이 정상 입력까지 막지 않는다. (과잉 차단 없음)

3-A. 변형 A 시험 — 이 형태를 고정한다

#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include "getloginsim.h"
 
static int g_fail = 0;
#define T_TRUE(cond, msg) \
    do { if (!(cond)) { g_fail++; \
        printf("  X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)
 
typedef struct { const char *name; const char *result_ptr; } bad_arg_t;
typedef struct { const char *name; char buf[64]; int rc; } good_arg_t;
 
static void *thread_bad(void *arg)
{
    bad_arg_t *a = (bad_arg_t *)arg;
    a->result_ptr = getlogin_like_bad(a->name);
    return NULL;
}
 
static void *thread_good(void *arg)
{
    good_arg_t *a = (good_arg_t *)arg;
    a->rc = getlogin_like_good_r(a->name, a->buf, sizeof(a->buf));
    return NULL;
}
 
static void test_bad_shares_buffer_across_threads(void)
{
    pthread_t t1, t2;
    bad_arg_t a1, a2;
    a1.name = "alice"; a1.result_ptr = NULL;
    a2.name = "bob";   a2.result_ptr = NULL;
 
    pthread_create(&t1, NULL, thread_bad, &a1);
    pthread_join(t1, NULL);
    pthread_create(&t2, NULL, thread_bad, &a2);
    pthread_join(t2, NULL);
 
    T_TRUE(a1.result_ptr == a2.result_ptr,
        "Bad는 두 호출이 같은 정적 버퍼 주소를 반환해야 한다(스레드 간 공유 재현)");
    T_TRUE(strcmp(a1.result_ptr, "bob") == 0,
        "정적 버퍼가 공유되므로, 나중 호출(bob)이 먼저 저장해둔 포인터(alice)가 가리키는 내용까지 덮어써야 한다(취약점 재현)");
}
 
static void test_good_isolates_each_caller(void)
{
    pthread_t t1, t2;
    good_arg_t a1, a2;
    a1.name = "alice";
    a2.name = "bob";
 
    pthread_create(&t1, NULL, thread_good, &a1);
    pthread_join(t1, NULL);
    pthread_create(&t2, NULL, thread_good, &a2);
    pthread_join(t2, NULL);
 
    T_TRUE(a1.rc == 0 && a2.rc == 0, "두 호출 모두 성공해야 한다");
    T_TRUE(strcmp(a1.buf, "alice") == 0,
        "Good은 호출자 각자의 버퍼에 쓰므로, 나중 호출(bob) 이후에도 alice의 결과가 그대로 남아야 한다");
    T_TRUE(strcmp(a2.buf, "bob") == 0, "bob의 결과도 정확해야 한다");
}
 
int main(void)
{
    test_bad_shares_buffer_across_threads();
    test_good_isolates_each_caller();
    printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail);
    return g_fail ? 1 : 0;
}

왜 두 스레드를 동시에 안 돌리고 join으로 순서를 고정했는가

진짜 레이스(두 스레드가 정확히 같은 순간에 겹쳐 실행)는 타이밍에 의존해 테스트가 실행할 때마다 다른 결과를 낼 수 있다. 이 드릴의 핵심은 “정확히 어느 순간에 겹치는가”가 아니라 **“결과를 담는 그릇(정적 버퍼)이 스레드 사이에 공유되는가”**이므로, 스레드를 순서대로 실행해도(join 후 다음 생성) 같은 결함이 100% 결정론적으로 드러난다 — 오히려 실전에서는 이보다 더 나쁜, 진짜 동시 접근으로 인한 반쯤 섞인 문자열까지 나올 수 있다.

3-B · 3-C. 변형 B / C 시험

3-A 와 같은 4종 구조에서 호출 함수와 입력만 바꾼다.

변형 B — 권한 판정 로직까지 연결

입력BadGood
조회한 이름으로 isTrustedGroup 판정다른 스레드가 이름을 바꿔 오판지역 버퍼에 고정된 이름으로 판정
단일 스레드정상정상

변형 C — strtok/ctime 등 다른 정적 버퍼 함수로 확장

입력BadGood
strtok / ctime / localtime정적 버퍼 공유strtok_r / ctime_r / localtime_r
단일 스레드정상정상

3-1. 빌드와 실행 (Makefile, gcc)

Makefile 은 템플릿 체화 드릴 README 와 같은 파일이다. recipe 줄은 반드시 탭으로 들여쓴다.

실행 명령이다.

make D=sd58_getloginsim V=main SRC="sd58_getloginsim/src/getloginsim.c sd58_getloginsim/test/test.c"
  • 마지막 줄이 PASS 이고 경고 0 이면 통과다.
  • -Werror 때문에 컴파일이 안 되면 테스트 이전에 코드를 고친다.

4. 자주 하는 실수

실수왜 문제인가
반환된 포인터를 곧바로 문자열로 복사해서 쓰면 괜찮다고 생각맞다 — 그런데 문제는 “그 순간 바로 복사하지 않고 포인터를 들고 있다가 나중에 쓰는” 코드가 실무에서 흔하다는 것이다.
_r 버전으로 바꾸면 애초에 그런 실수 자체가 코드 구조상 불가능해진다(버퍼를 호출자가 미리 준비해야 하므로)
_r 버전은 코드가 복잡해지니 성능이 중요하지 않은 곳에서는 원본 함수를 써도 된다고 생각이 항목은 성능이 아니라 정확성의 문제다.
싱글스레드로 시작한 코드가 나중에 멀티스레드로 확장되는 일은 매우 흔하고, 그때 이런 정적 버퍼 함수들이 조용히 새로운 버그의 원인이 된다
_r 버전이 없는 함수를 lock 없이 그대로 사용스레드 safe 한 대체 함수가 없으면 lock으로 제어해야 한다.
lock은 호출부터 지역 버퍼로 복사할 때까지 잡고, 같은 함수를 부르는 모든 경로가 같은 lock을 써야 하며, 에러 경로를 포함한 모든 반환 경로에서 unlock 해야 한다
getlogin_r의 실패(터미널 없음 등)를 처리 안 함가이드 원문의 Good 예시도 if (getlogin_r(id, MAX) != 0) return 0;로 실패를 명시적으로 처리한다.
_r 버전은 실패 가능성이 있는 함수라는 신호이기도 하다 — SD43의 반환값 검사 원칙이 여기도 그대로 적용된다

5. 모범답안 (변형 A)


관련 노트