ELE3021 · HANYANG UNIV · 2017 SPRING

운영체제

과제 제출물이 아니라 실습실 작업 디렉터리를 그대로 올린 것이다. 다섯 개 모듈 전부에서, 그 모듈이 증명하려던 바로 그 성질을 무력화하는 결함이 하나씩 발견된다.

추적 파일35
커밋12
실질 커밋5
기간2017.03–06
스택C / C++ / Linux 4.1
D+종합
소견6 치명적9 중대7 경미합계 22

총평

한 학기(16주) 과목의 저장소에 코드를 담은 커밋이 다섯 개다. Week2(시스템 콜), Week6(메시지 큐), Week9(뮤텍스·세마포어·식사하는 철학자), Week10(메모리 레이아웃), 그리고 주차 표기조차 없는 Update driver(문자 디바이스 드라이버). 나머지 커밋 일곱 개는 README 수정과 2021년의 GitHub 계정 이름 변경이다. 3·4·5·7·8주차와 11주차 이후는 저장소에 존재하지 않는다.

README가 스스로 정한 커밋 규칙 — Week(n) init으로 원본을, Week(n) done으로 과제분을 커밋한다 — 은 2주차 한 번만 지켜졌다. 6주차는 init 없이 done만, 9·10주차는 Week9·Week10으로 접미사 없이, 드라이버는 아예 주차 없이 커밋됐다. 규칙이 무너진 것 자체보다, 그 규칙 덕분에 2주차 과제의 제출 누락이 증명 가능해진다는 점이 중요하다. 아래 A1 항목을 보라.

코드 자체는 대부분 실습 스켈레톤을 채운 것이다. mutex/dining.cpp:126에는 /* TODO: Thread create & destroy */라는 제공 스켈레톤의 주석이 채워 넣은 뒤에도 그대로 남아 있고, 같은 파일의 minCount/maxCount/args는 선언만 되고 한 번도 쓰이지 않는다 — 스켈레톤이 의도했던 통계 출력을 구현하지 않았다는 뜻이다. 다섯 모듈 중 저자 자신의 설계가 드러나는 것은 문자 디바이스 드라이버 하나뿐이고, 실제로 이 저장소에서 가장 잘 쓴 코드이기도 하다.

가장 치명적인 것은 드라이버의 mydrv_init실패 경로에서 0(성공)을 반환한다는 점이다(driver/device_driver_test.c:78). 가장 잘한 것은 그 같은 파일에서 벌크 전송에 copy_to_user/copy_from_user를 정확히 쓰고 반환값을 -EFAULT로 처리한 것이다 — 이 수준의 실습 코드에서 흔히 빠지는 부분이다.

주차주제핵심 판정등급
Week2시스템 콜 추가ABI 컬럼 오타로 x86-64 등록 실패, long 함수에 return 없음, kernel/Makefile 미커밋D
Week6메시지 큐 IPC큐를 만들기만 하고 지우지 않음, msgrcv 크기 인자가 버퍼보다 8바이트 큼C-
Week9뮤텍스 / 세마포어 / 철학자식사를 전역 락 안에서 수행 — 병렬성이 0이 되어 문제 자체가 사라짐C
Week10프로세스 메모리 레이아웃과제의 대상인 주소 6개를 전부 32비트로 잘라 출력D+
(무표기)문자 디바이스 드라이버전송 경로는 정확하나 초기화 실패 시 성공을 반환, device_create 부재C-

Week 2 — 시스템 콜 get_task_info 추가

linux-4.1.38에 인자 없는 시스템 콜을 추가하고 current 태스크의 정보를 printk하는 과제다. 커밋 70f709b(Week2 init)가 원본 3개 파일을, 5c98e8c(Week2 done)가 총 19줄의 변경을 담았다.

치명적

syscall 테이블의 ABI 컬럼이 comoon으로 오타 나 있다

x86 syscall 테이블의 두 번째 컬럼은 common, 64, x32 중 하나여야 한다. comoon은 그 어느 것도 아니다. arch/x86/syscalls/syscalltbl.sh는 이 컬럼을 대문자로 바꿔 __SYSCALL_<ABI>(...) 매크로 이름을 그대로 조립하므로, 생성되는 syscalls_64.h에는 정의된 적 없는 __SYSCALL_COMOON(323, sys_get_task_info, sys_get_task_info)이 들어간다. 즉 64비트 시스템 콜은 등록되지 않는다.

바로 윗줄 322 64 execveat과 비교하면 오타가 눈에 보인다. 32비트 테이블(syscall_32.tbl:368)의 i386은 정확히 적혀 있으므로, 이 과제는 32비트에서만 동작하고 64비트에서는 동작하지 않는 상태로 제출됐다. 참고로 두 줄 모두 번호 뒤가 탭이 아니라 공백인데, 이쪽은 스크립트가 공백 구분으로 읽으므로 무해하다.

미검증: 4.1 커널을 이 환경에서 빌드할 수 없어, 실패 형태가 컴파일 에러인지 조용한 누락인지는 확인하지 못했다. 어느 쪽이든 64비트 등록이 안 되는 결론은 같다.

322	64	execveat		stub_execveat
323 comoon	get_task_info	sys_get_task_info   <- common 이어야 함
치명적

long을 반환한다고 선언해 놓고 아무것도 반환하지 않는다

sys_get_task_infoasmlinkage long인데 함수 본문에 return이 없다. 시스템 콜의 반환값은 유저 공간이 성공/실패를 판정하는 유일한 채널이고, 여기서는 레지스터에 남아 있던 쓰레기값이 그대로 errno 해석 대상이 된다. 함수 전체가 9줄이고 그중 빠진 것이 return 0; 한 줄이다.

덤으로 task->state는 4.1에서 volatile long인데 %x로 출력한다 — 상위 32비트가 잘린다. task->flags(unsigned int)와 nr_dirtied(int)는 맞다.

linux/kernel/get_task_info.c:6–15
asmlinkage long sys_get_task_info(void)
{
	struct task_struct *task = current;
	printk("PID: %d\n", task->pid);
	...
	printk("NR_DIRTIED: %d\n", task->nr_dirtied);
}   <- return 문 없음
중대

kernel/Makefile 변경이 커밋되지 않았다

linux/kernel/get_task_info.c를 새로 만들었지만, 이 파일을 빌드에 넣어 주는 kernel/Makefileobj-y 추가가 저장소에 없다. 저장소에 있는 Makefile은 driver/Makefile 하나뿐이다. README가 선언한 "Week(n) done: 과제로 변경한 파일을 커밋한다"는 규칙이 이 커밋에서 이미 지켜지지 않았다는 뜻이고, 선택지는 둘뿐이다 — 로컬에서 고쳤지만 커밋을 빠뜨렸거나(규칙 위반), 애초에 안 고쳐서 get_task_info.o가 링크되지 않았거나(빌드 실패). 어느 쪽이든 이 커밋만으로는 과제가 재현되지 않는다.

경미

README가 없는 커널 트리를 가리킨다

README.md:6"directory linux: linux-4.1.38 kernel"이라고 적었지만 linux/에 있는 것은 수정한 파일 4개뿐이다. 변경분만 커밋한 판단은 옳다 — 잘못된 것은 README의 문장이다.

Week 6 — System V 메시지 큐

sender/psender가 키 1234의 큐로 메시지를 보내고 receiver가 받아 우선순위 순으로 출력한다. 세 파일 모두 macOS에서는 <linux/unistd.h> 부재로 컴파일되지 않으나(fatal error: 'linux/unistd.h' file not found), 이 헤더는 세 파일 어디에서도 실제로 쓰이지 않는 불필요한 include다. receiver.cpp는 그 include가 없어 -Wall -Wextra로 깨끗이 컴파일된다.

중대

큐를 만들기만 하고 아무도 지우지 않는다

세 프로그램 전부 msgget((key_t)1234, IPC_CREAT | 0666)으로 큐를 생성하는데, 저장소 전체에 IPC_RMID가 한 번도 등장하지 않는다. msgctl 호출은 receiver.cpp:36IPC_STAT 하나뿐이다. SysV 메시지 큐는 프로세스가 아니라 커널에 매달린 자원이므로, 프로그램이 끝나도 키 1234의 큐는 남는다. 실습을 반복 실행하면 이전 회차의 잔여 메시지가 다음 회차 receiver로 들어간다 — 종료 조건이 seq == 1000 센티넬 2개를 세는 방식(receiver.cpp:55–60)이라, 잔여 센티넬 하나만 남아 있어도 결과가 뒤틀린다.

생성/삭제 짝을 맞추는 것이 이 실습의 절반이다. 0666 권한까지 붙여 아무나 읽고 쓸 수 있는 큐를 남기는 것은 덤이다.

message_queue/ 전체grep -rn "IPC_RMID"
(결과 없음)
message_queue/receiver.cpp:36:	ret = msgctl(key_id, IPC_STAT, &buf);
message_queue/receiver.cpp:38:		perror("msgctl error : ");
중대

msgrcv에 버퍼보다 8바이트 큰 크기를 넘긴다

msgrcvmsgszmtype(long) 의 수신 가능 바이트 수다. 송신 측은 sizeof(struct msg_buf) - sizeof(long) = 264를 정확히 쓰는데(sender.cpp:42, psender.cpp:51), 수신 측만 sizeof(struct msg_buf) = 272를 넘긴다. 이 프로그램의 송신자들이 264바이트만 보내므로 지금 당장 터지지는 않지만, 264바이트를 넘는 메시지가 큐에 하나라도 있으면 커널이 mybuf 끝을 8바이트 넘겨 쓴다. 그리고 위 항목대로 이 큐는 지워지지 않고 권한이 0666이라, 다른 프로그램이 큐에 넣은 메시지가 들어올 수 있다.

if (msgrcv(key_id, (void *)&mybuf, sizeof(struct msg_buf), msgtype, 0) == -1) {
// sizeof = 272, 그러나 mybuf에서 mtype 뒤 여유는 264바이트
경미

"우선순위"가 IPC 메커니즘이 아니라 수신 후 정렬이다

receiver.cpp:48–65는 모든 메시지를 vector에 모아 두었다가 끝에서 prio 필드로 sort한다. SysV 메시지 큐가 우선순위를 표현하는 수단은 mtypemsgrcv의 음수 msgtyp 인자인데, 여기서는 mtype이 모든 메시지에서 4로 고정이고 우선순위는 페이로드 안의 그냥 정수다. 큐가 우선순위를 다루는 것을 보이는 실습에서, 큐는 우선순위와 아무 관련이 없고 std::sort가 전부를 한다.

같은 맥락에서 psender.cpp:31setpriority(PRIO_PROCESS, pid, 0)은 nice 값을 0으로 두는 것이라 아무 효과가 없고, 반환값 ret은 어디에서도 읽히지 않는다.

Week 9 — 뮤텍스 · 세마포어 · 식사하는 철학자

pthread 뮤텍스 예제, POSIX 세마포어 예제, 그리고 Tanenbaum의 식사하는 철학자 해법 세 개다. 앞의 둘은 강의 예제를 거의 그대로 옮긴 것이고, dining.cpp가 유일하게 판단이 필요한 코드다.

치명적

식사를 전역 락 안에서 수행해 철학자들이 완전히 직렬화된다

Tanenbaum 해법에서 test()가 하는 일은 상태를 EATING으로 바꾸고 세마포어를 post하는 것뿐이다. 실제 식사는 락을 놓은 뒤 각자의 스레드에서 일어난다. 이 구현의 test()eat(i)를 직접 호출하고, eat()usleep(EAT_TIME * 10000)으로 30ms를 잔다 — 그것도 lock을 쥔 채로. get_chopsem_wait(&lock)sem_post(&lock) 사이에서 test(i)를 부르므로, 한 철학자가 먹는 30ms 동안 나머지 넷은 전역 락에서 대기한다.

이 과제가 존재하는 이유는 "이웃하지 않은 철학자 둘이 동시에 먹을 수 있는가"이고, 이 구현에서 그 답은 아니오다. 데드락이 없는 것은 맞지만, 그것은 회피 전략이 작동해서가 아니라 동시성이 하나도 없기 때문이다. 전역 락 하나로 모든 것을 감싸면 식사하는 철학자 문제는 성립하지 않는다.

부작용도 있다. put_chop에서 test(LEFT(i))가 조건을 만족하면, 이웃의 식사가 이웃 스레드가 아니라 자기 스레드에서 실행된다(dining.cpp:94–95). 깨어난 이웃은 이미 밥을 다 먹은 상태로 sem_wait(&chopstick[i])를 통과해 곧바로 put_chop으로 간다. num_eat 집계는 맞지만 스레드와 행동의 대응이 깨져 있다.

mutex/dining.cpp:72–88
void test(int i) {
	if (phil[i].state == HUNGRY &&
		  phil[LEFT(i)].state != EATING &&
		  phil[RIGHT(i)].state != EATING) {
		eat(i);                 // usleep(30ms) 를 lock 안에서
		sem_post(&chopstick[i]);
	}
}

void get_chop(int i) {
	usleep(GET_TIME * 10000);
	sem_wait(&lock);
	hungry(i);
	test(i);                     // <- 여기서 30ms 점유
	sem_post(&lock);
	sem_wait(&chopstick[i]);
}
중대

도달 불가능한 break가 있고, 도달했다면 뮤텍스를 쥔 채 빠져나간다

for (i = 0; i < 10; i++) 안에서 i == 10을 검사한다. 루프 조건상 i는 10이 될 수 없으므로 이 분기는 영원히 죽은 코드다. 문제는 만약 조건이 성립하도록 고쳤을 때 — breakpthread_mutex_unlock 에 있어서, 스레드는 뮤텍스를 쥔 채로 루프를 나가고 종료한다. 상대 스레드는 do_ops2pthread_mutex_lock에서 영구 대기하고, mainpthread_join도 돌아오지 않는다. 대칭 함수인 do_ops2에는 이 분기가 아예 없어, 실수로 남은 코드임이 더 분명해진다.

mutex/mutex.cpp:10–18
for (i = 0; i < 10; i++) {
	pthread_mutex_lock(&mutex);
	printf("do ops1: %d\n", ncount++);
	if(i == 10) {
		break;            // 도달 불가. 도달했다면 락을 쥔 채 탈출
	}
	pthread_mutex_unlock(&mutex);
	sleep(1);
}
경미

스켈레톤의 잔해가 그대로 남아 있다

-Wall -Wextradining.cpp에서 미사용 변수를 다섯 개 잡아낸다: t(:116), args(:117), minCount·maxCount(:118), left·right(:101). minCount/maxCountUSHRT_MAX로 초기화까지 해 놓고 쓰지 않는데, 철학자별 식사 횟수의 최소/최대를 비교해 기아를 판정하라는 스켈레톤의 의도가 그대로 드러난다 — 그 판정은 구현되지 않았다. :126/* TODO: Thread create & destroy */ 주석도 남아 있다. sem_destroy는 어디에도 없다.

dining·do_ops1·do_ops2·thread_func 네 스레드 함수가 전부 void*를 선언하고 return하지 않는다(-Wreturn-type).

dining.cpp:101:17: warning: unused variable 'left' [-Wunused-variable]
dining.cpp:113:1:  warning: non-void function does not return a value [-Wreturn-type]
dining.cpp:118:17: warning: unused variable 'minCount' [-Wunused-variable]
dining.cpp:129:49: warning: cast to 'void *' from smaller integer type
                            'unsigned short' [-Wint-to-void-pointer-cast]
경미

실행 시간 카운트다운이 밀리초를 초로 잘라 뺀다

tick()은 밀리초를 돌려주는데, t -= tick()/1000 - start_time/1000;은 두 값을 각각 초 단위로 내림한 뒤 뺀다. 한 번의 get_chop+put_chop은 50ms 남짓이므로 대부분의 반복에서 이 차이는 0이고, t는 벽시계의 초 경계를 넘을 때만 줄어든다. 결과적으로 3초 근처에서 끝나기는 하지만 그것은 경과 시간을 잰 결과가 아니라 초 경계를 세 번 넘은 결과다. start_time을 루프 밖으로 빼고 밀리초로 비교했으면 될 일이다.

출력 쪽에도 같은 종류의 부주의가 있다. :142printf("%lld.%lld sec", (end-start)/1000, (end-start)%1000)은 하위 자리를 0으로 채우지 않아 1005ms를 1.5 sec로 찍는다.

검증 한계

dining.cpp는 macOS에서 빌드·실행되어 2.573초에 정상 종료했고 식사 횟수는 26/26/27/27/26으로 고르게 나왔다. 그러나 이 실행은 동기화를 검증하지 못한다 — macOS의 익명 POSIX 세마포어는 스텁이고, 같은 환경에서 sem_initrc=-1 errno=78 (Function not implemented)을 돌려준다. 즉 모든 sem_wait/sem_post가 실패한 상태의 실행 결과이므로, 위 치명적 결함은 실행이 아니라 코드 독해로 판정한 것이다.

Week 10 — 프로세스 메모리 레이아웃

24줄짜리 프로그램으로 text/data/bss/heap/library/stack 여섯 영역의 주소를 출력한다. 과제의 산출물이 곧 이 여섯 줄의 출력이다.

치명적

보여 주어야 할 주소 여섯 개를 전부 32비트로 잘라 출력한다

여섯 줄 모두 포인터를 %08x로 넘긴다. x86-64에서 %xunsigned int 변환이므로 상위 32비트가 사라진다. 커밋된 바이너리 memory/manage는 ELF 64-bit x86-64이니, 이 프로그램은 자기가 실행되는 바로 그 아키텍처에서 주소를 잘못 찍는다. 컴파일러가 여섯 줄 전부를 -Wformat으로 지적한다.

같은 코드를 macOS에서 빌드해 실행한 결과가 그 효과를 그대로 보여 준다. 실제 스택 주소가 0x16f651d7c일 때 %08x0x6f651d7c를 찍는다 — 최상위 니블이 통째로 사라져, 스택이 힙보다 낮은 주소에 있는 것처럼 보인다. 메모리 레이아웃을 관찰하는 것이 목적인 과제에서 레이아웃을 잘못 보여 주는 출력이다. %p 한 글자면 해결된다.

memory/manage.cc:16–21
printf("main: 0x%08x\n", &main);
printf("data: 0x%08x\n", &data);
printf("bss: 0x%08x\n", &bss);
printf("heap: 0x%08x\n", heap);
printf("library: 0x%08x\n", addr);
printf("stack: 0x%08x\n", &local);
동일 패턴 재현g++ / macOS arm64
stack  %08x -> 0x6f651d7c   |  %p -> 0x16f651d7c
heap   %08x -> 0x00eb5d10   |  %p -> 0x100eb5d10
중대

dlopen 실패를 확인하지 않아 라이브러리 주소가 0으로 찍힌다

handle = dlopen("/lib/libc.so.6", RTLD_LAZY)의 반환값을 검사하지 않고 곧바로 dlsym(handle, "printf")를 부른다. 경로가 하드코딩이라 멀티아치 배포판(/lib/x86_64-linux-gnu/libc.so.6)에서는 handle이 NULL이 된다. glibc에서 NULL 핸들은 RTLD_DEFAULT와 같은 값이라 우연히 동작할 수도 있는데, 그 "우연히 동작함"이야말로 검사를 넣어야 하는 이유다.

실제로 같은 코드를 macOS에서 실행하면 dlopen이 실패해 library: 0x00000000이 출력된다. 프로그램은 오류 없이 0을 정답인 양 찍고 종료 코드 0으로 끝난다. dlerror() 호출은 파일 어디에도 없다.

./managemacOS 실행 결과
main: 0x00800580
data: 0x00808000
bss: 0x00808004
heap: 0x01135c50
library: 0x00000000      <- dlopen 실패, 검사 없음
stack: 0x6f5fdd6c      <- 상위 니블 소실

문자 디바이스 드라이버 (주차 표기 없음, 2017-06-20)

4KB 링 아닌 선형 버퍼를 갖는 문자 디바이스를 alloc_chrdev_region + cdev로 등록하고 read/write를 구현한다. 이 저장소에서 스켈레톤 채우기가 아니라 저자의 설계가 드러나는 유일한 코드이며, 동시에 결함도 가장 많다.

치명적

초기화 실패 경로가 0(성공)을 반환하고, 그 전에 에러 포인터를 파괴한다

class_create가 실패하면 IS_ERR(myclass)가 참이 되어 두 가지 일이 연달아 일어난다. 첫째, class_destroy(myclass)에러 포인터를 실제 포인터처럼 역참조한다 — 실패한 객체를 해제하려는 시도이고, 커널 oops로 이어진다. 둘째, 반환값이 PTR_ERR(myclass)가 아니라 PTR_ERR(mydevice)다. mydevice:15에 선언만 되고 이 파일 어디에서도 대입되지 않는 전역이므로 값은 NULL이고, PTR_ERR(NULL)0이다.

insmod성공을 보고한다. 모듈은 클래스가 없고 cdev도 등록되지 않고 mydrv_data도 할당되지 않은 상태로 커널에 적재되며, 이후 rmmodmydrv_cleanup을 불러 NULL mycdevcdev_del을 시도한다. 실패를 성공으로 보고하는 것은 어떤 초기화 함수에서도 최악의 실패 모드이고, 여기서는 변수 이름 하나를 잘못 적어서 발생했다.

driver/device_driver_test.c:74–79
myclass = class_create(THIS_MODULE, "mycharclass");
if (IS_ERR(myclass)) {
	class_destroy(myclass);              // 에러 포인터를 해제
	unregister_chrdev_region(mydev, 1);
	return PTR_ERR(mydevice);            // myclass 여야 함. mydevice 는 NULL -> 0 반환
}
치명적

printk("%s", buf)로 유저 포인터를 직접 역참조한다

벌크 전송에서는 copy_to_user/copy_from_user를 정확히 쓴다. 그런데 두 함수 모두 맨 첫 줄에서 유저 공간 포인터 bufprintk%s에 그대로 넘긴다. 커널 문맥에서 유저 메모리를 직접 읽는 것이고, SMAP이 켜진 x86에서는 그 자리에서 폴트가 난다. SMAP이 없어도 문제다 — %scount를 모르므로 NUL을 만날 때까지 읽고, 유저가 보낸 데이터에 NUL이 없으면 매핑 경계를 넘어 커널 로그로 흘러 나간다.

mydrv_read의 경우는 한 겹 더 나쁘다. :32printk:33buf == NULL 검사보다 에 있어서, NULL을 걸러내려고 넣은 검사가 이미 NULL 역참조가 일어난 뒤에 실행된다. 게다가 readbuf는 아직 채워지지 않은 출력 버퍼라, 찍히는 내용은 의미 없는 쓰레기다.

덧붙여 count < 0 검사는 countsize_t(부호 없음)이므로 항상 거짓인 죽은 코드다.

driver/device_driver_test.c:31–35, 47–51
static ssize_t mydrv_read(struct file *file, char *buf, size_t count, loff_t *ppos) {
	printk("mydrv_read: %s\n", buf);   // 유저 포인터 직접 역참조
	if ((buf == NULL) || (count < 0)) {  // 검사가 역참조 뒤. size_t < 0 은 항상 거짓
		return -EINVAL;
	}
	...
static ssize_t mydrv_write(struct file *file, const char * buf, size_t count, loff_t *ppos) {
	printk("mydrv_write: %s\n", buf);  // NUL 없으면 count 를 넘겨 읽음
중대

device_create를 부르지 않으면서 device_destroy는 두 곳에서 부른다

grep device_create의 결과가 비어 있다. 클래스는 만들지만 디바이스 노드는 만들지 않으므로 /dev에 아무것도 생기지 않고, 사용자는 mknod로 직접 만들어야 한다. 그런데 정리 코드는 만든 적 없는 디바이스를 두 번 파괴하려 한다 — :84(cdev_add 실패 경로)와 :97(mydrv_cleanup). 인자로 넘기는 myclass는 유효하므로 device_destroy는 해당 devt를 찾지 못하고 조용히 돌아가지만, 자원 획득/해제 짝이 애초에 맞지 않는다는 신호다.

동봉된 유저 테스트는 이 불일치를 그대로 드러낸다. usertest.c:10/dev/device_driver_test를 여는데, 드라이버가 등록한 이름은 DEVICE_NAME "mydrv"(:9)다. 두 이름 중 어느 것도 자동으로 생성되지 않으므로, 이 테스트는 손으로 만든 노드 이름이 우연히 맞아떨어질 때만 동작한다.

중대

kmalloc 반환값을 검사하지 않고, 정리 순서가 use-after-free를 만든다

:89kmalloc(4096, GFP_KERNEL)은 검사 없이 mydrv_data에 대입된다. NULL이면 이후 첫 writecopy_from_user(mydrv_data + offset, ...)가 NULL 근처에 쓴다. cdev_alloc()(:80)의 반환값도 검사 없이 mycdev->ops로 역참조한다.

mydrv_cleanup은 순서가 거꾸로다. kfree(mydrv_data)cdev_del(mycdev)보다 먼저 실행되므로, 그 사이에 진행 중이던 read/write는 해제된 버퍼를 만진다. cdev_del을 먼저 불러 새 open을 차단한 뒤 버퍼를 놓아야 한다.

driver/device_driver_test.c:94–100
void mydrv_cleanup(void) {
	kfree(mydrv_data);     // 진행 중인 read/write 가 있으면 UAF
	cdev_del(mycdev);      // 이게 먼저여야 함
	device_destroy(myclass, mydev);
	class_destroy(myclass);
	unregister_chrdev_region(mydev, 1);
}
중대

동시 접근 보호가 전혀 없고, 오프셋이 파일이 아니라 모듈에 달려 있다

grep으로 mutex·spin_lock·atomic·down( 어느 것도 발견되지 않는다. mydrv_read_offsetmydrv_write_offset은 모듈 전역이고 두 핸들러가 잠금 없이 읽고 쓴다. 두 프로세스가 동시에 write하면 mydrv_write_offset += count가 서로를 덮어쓰고, 버퍼는 겹쳐 쓰이며, :52의 경계 검사도 무의미해진다.

구조적으로도 ppos 인자를 두 핸들러 모두 무시한다. 파일 오프셋이 struct file이 아니라 모듈에 달려 있으므로, 두 프로세스가 각자 open해도 커서를 공유한다 — 먼저 읽은 쪽이 전부 가져가고 두 번째는 0바이트를 받는다. 마지막으로 :53은 버퍼가 가득 찼을 때 0을 반환하는데, write()의 0 반환은 "진전 없음"이라 이를 재시도 루프로 처리하는 호출자를 무한 루프에 빠뜨린다. -ENOSPC여야 한다.

경미

유저 테스트가 배열 끝을 두 칸 넘겨 쓴다

buf_outchar[26]인데 인덱스 27에 대입한다 — 유효 범위는 0..25이므로 두 칸 밖이다. 전역 배열이라 실제로는 인접한 buf_in의 어딘가를 건드릴 뿐 눈에 띄지 않지만, 컴파일러가 경고 한 줄로 잡아 준다. 같은 파일은 <unistd.h>를 include하지 않아 read/write/close가 선언 없이 쓰인다.

usertest.c:18:2: warning: array index 27 is past the end of the
                 array (that has type 'char[26]') [-Warray-bounds]
   18 |  buf_out[27] = '0';
usertest.c:6:1: note: array 'buf_out' declared here
    6 |  char buf_out[MAX_BUFFER];
usertest.c:25:2: error: use of undeclared identifier 'write'
usertest.c:26:2: error: use of undeclared identifier 'read'
usertest.c:33:2: error: use of undeclared identifier 'close'
잘한 것

mydrv_readcount = MIN((mydrv_write_offset - mydrv_read_offset), count)(:39)는 남은 바이트로 요청을 정확히 클램프하고, 빈 버퍼에서 0을 반환해 EOF를 올바르게 표현한다(:36–38). copy_to_user/copy_from_user의 반환값을 -EFAULT로 처리하고, 성공했을 때만 오프셋을 전진시키는 순서도 맞다. 이 수준의 실습에서 자주 생략되는 부분이고, 저자가 유저/커널 경계를 이해하고 있었다는 증거다 — 같은 파일의 printk 두 줄이 그 이해를 스스로 무너뜨린 것이 더 아쉬운 이유이기도 하다.

저장소 위생

중대

커밋된 .o 파일은 오브젝트가 아니라 링크된 실행 파일이다

file로 확인하면 mutex/mutex.o, message_queue/receiver.o 등이 ELF 64-bit LSB executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2다. 재배치 가능한 오브젝트가 아니다. gcc -c foo.cpp 대신 gcc -o foo.o foo.cpp로 빌드해 실행 파일에 .o 확장자를 붙인 것이고, 여섯 개가 전부 그렇다. 빌드 산출물을 커밋한 것보다, 자기 툴체인이 무엇을 만들었는지 확인하지 않은 쪽이 문제다.

file mutex/mutex.o실제 출력
mutex/mutex.o: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
  dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
  for GNU/Linux 2.6.24, not stripped
경미

추적 파일 35개 중 16개가 빌드 산출물이고, 그 안에 빌드 머신 경로가 박혀 있다

.o 6개, .ko 1개, .mod.o, .cmd 3개, Module.symvers(0바이트), .tmp_versions/, modules.order, 그리고 링크된 실행 파일 memory/manage·driver/usertest — 합쳐서 534KB, 소스 전체보다 훨씬 크다. .gitignore는 없다. 커널 빌드가 생성한 .cmdmodules.order에는 빌드 머신의 절대 경로 /home/practice/Workspace/ele3021이 그대로 남아 있다. 시크릿은 아니지만, 아무도 열어 보지 않고 커밋했다는 증거다.

경미

2018 브랜치에는 명세 PDF만 있고 코드가 없다

origin/2018은 2017년 커밋 11개 위에 Rebase(d9cc95e, 2018-03-20) 하나를 얹은 브랜치다. 그 커밋이 하는 일은 2017년의 모든 소스 35개를 삭제하고 project1/simple unix shell.pdf 하나를 추가하는 것이다(3,596줄 삭제, 3줄 추가). README도 - [ ] Simple User-level Unix Shell 한 줄의 미체크 체크박스로 교체된다. 2018년에 같은 과목을 다시 들으면서 저장소를 비우고 시작했으나, 그 이후 커밋은 없다 — 체크박스는 8년째 비어 있다.

기본 브랜치가 아닌 곳에 더 완성된 구현이 있는지 확인했고, 없다. master가 이 저장소의 전부다.

반복되는 패턴

  1. 모듈마다 그 모듈의 존재 이유를 무력화하는 결함이 하나씩 있다. 메모리 레이아웃 과제는 주소를 잘라 찍고, 식사하는 철학자는 전역 락으로 병렬성을 0으로 만들고, 우선순위 메시지 큐는 우선순위를 큐가 아니라 std::sort로 처리하고, 시스템 콜은 ABI 이름 오타로 등록되지 않는다. 각각을 따로 보면 사소한 실수지만, 다섯 번 반복되면 "돌아가는 것처럼 보이는 지점에서 멈춘다"는 습관이다.
  2. 반환값을 검사하지 않는다. manage.cc:13dlopen, device_driver_test.c:80,89cdev_allockmalloc, psender.cpp:31setpriority, mutex.cpp:38,40pthread_create, 모든 sem_init. 실패가 조용히 0이나 NULL로 흘러 들어가고, managelibrary: 0x00000000처럼 오답을 정답인 양 출력한다.
  3. 검사를 넣긴 하는데 무의미한 자리에 넣는다. size_tcount < 0, 역참조 뒤에 오는 buf == NULL, i < 10 루프 안의 i == 10. 방어 코드의 형태는 익혔지만 그것이 실제로 무엇을 막는지 따지지 않았다.
  4. 자원 획득과 해제의 짝이 맞지 않는다. 만든 적 없는 디바이스를 파괴하고(device_destroy), 만든 큐를 지우지 않고(IPC_RMID 부재), sem_destroy가 없고, 정리 순서가 뒤집혀 있다. 반대로 mutex.cpp:45pthread_mutex_destroy는 제대로 부른다 — 강의 예제에 있었기 때문으로 보인다.
  5. 스스로 정한 규칙을 한 번 쓰고 버린다. README의 Week(n) init/Week(n) done 규칙은 2주차에만 지켜졌고, README의 # lab 목록은 Week 02에서 멈춘 채 코드만 Week 10까지 갔다. 2018년 브랜치의 체크박스도 같은 운명이다.

지금 손본다면

  1. return PTR_ERR(mydevice)return PTR_ERR(myclass)로 고치고 그 위의 class_destroy를 지운다. 두 줄이고, 실패를 성공으로 보고하는 최악의 실패 모드가 사라진다.
  2. 드라이버의 printk("%s", buf) 두 줄을 삭제한다. 디버그용이었을 그 두 줄이 파일의 나머지가 공들여 지킨 유저/커널 경계를 혼자서 무너뜨린다. 필요하면 copy_from_user 이후의 커널 버퍼를 찍으면 된다.
  3. syscall_64.tblcomooncommon으로, get_task_info.creturn 0;을 추가한다. 두 글자와 한 줄이면 2주차 과제가 실제로 동작한다.
  4. dining.cpptest()에서 eat(i)를 빼고 상태 설정과 sem_post만 남긴 뒤, eat()get_chopput_chop 사이(락 바깥)로 옮긴다. 그때 비로소 미사용으로 남아 있는 minCount/maxCount로 기아를 측정하는 것이 의미를 갖는다.
  5. manage.cc%08x%p로 바꾼다. 여섯 글자 수정으로 24줄짜리 과제가 실제로 무언가를 보여 주게 된다.
  6. .gitignore*.o *.ko *.mod.c .*.cmd Module.symvers .tmp_versions/를 넣고 추적 중인 16개를 제거한다. 저장소 크기의 대부분이 사라진다.