Notice
Recent Posts
Recent Comments
Link
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

song.3_3

하계 보안 세미나 5일차 - 시큐어코딩 (임베디드·자동차 코딩 표준) 본문

하계 보안 세미나

하계 보안 세미나 5일차 - 시큐어코딩 (임베디드·자동차 코딩 표준)

song.3_3 2026. 7. 10. 15:36

이번 5강에서는 시큐어 코딩과 임베디드·자동차 코딩 표준을 배웠다.

이전 강의에서는 암호가 어떤 원리로 깨지는지, 양자내성암호가 왜 필요한지, 자동차 안에는 어떤 장치와 통신망이 있는지를 살펴봤다. 이번 강의는 그보다 조금 더 코드에 가까운 내용이었다.

버퍼 오버플로우, 해제된 메모리 사용, 널 포인터 역참조, 정수 오버플로우처럼 C와 C++에서 자주 발생하는 문제를 살펴보고, 이런 코드를 MISRA, AUTOSAR, CERT C 같은 표준에서는 어떻게 설명하는지 배웠다.

이번 강의의 핵심은 단순히 다음과 같이 말하는 데 있지 않았다.

이 코드는 위험하다.
 

조금 더 구체적으로 다음과 같이 설명할 수 있어야 한다.

어떤 경계 검사가 빠졌는가?
어떤 표준 규칙을 위반했는가?
어떻게 수정해야 하는가?
수정됐다는 사실을 어떤 도구와 테스트로 증명할 것인가?
 

즉 시큐어 코딩은 단순히 코드를 조심해서 작성하는 것이 아니라, 위험한 코드를 공통된 규칙으로 설명하고 검증하는 과정이라고 볼 수 있었다.

시큐어 코딩이란 무엇인가

시큐어 코딩은 처음부터 취약점이 생기지 않도록 안전한 방식으로 코드를 작성하는 것을 말한다.

보안 문제가 발견된 후 패치하는 것도 중요하지만, 개발 단계에서부터 위험한 코드 패턴을 제한하면 취약점이 만들어질 가능성 자체를 줄일 수 있다.

특히 자동차와 임베디드 환경에서는 작은 실수가 단순한 프로그램 오류에서 끝나지 않을 수 있다. 센서값을 잘못 읽거나 ECU가 중단되면 차량 기능이나 실제 안전에까지 영향을 줄 수 있기 때문이다.

강의에서는 취약점이 주로 다음 네 가지 경계에서 시작한다고 설명했다.

입력 경계
메모리 수명
권한 경계
시간 예측성
 

입력 경계에서는 데이터의 길이와 타입을 확인해야 한다. 메모리 수명에서는 누가 메모리를 할당하고 해제하는지 분명해야 한다. 권한 경계에서는 어떤 사용자가 어떤 API나 기능을 호출할 수 있는지를 검사해야 한다.

자동차 코드에서는 시간도 중요한 경계다. 평균적으로 빠르게 동작하는 것보다, 가장 오래 걸리는 경우에도 정해진 시간 안에 끝나는지가 더 중요할 수 있다.

표준마다 하는 역할이 다르다

강의에는 MISRA, AUTOSAR C++14, SEI CERT C, ISO 26262, ISO/SAE 21434처럼 여러 표준이 등장했다.

처음에는 비슷한 문서들이 이름만 다르게 존재하는 것처럼 느껴졌다. 하지만 각각 보는 범위와 질문이 달랐다.

MISRA C/C++

MISRA는 임베디드 환경에서 C와 C++의 위험한 기능을 제한하기 위한 코딩 규칙이다.

C언어는 메모리와 포인터를 직접 제어할 수 있어 빠르고 자유롭지만, 개발자가 실수하면 정의되지 않은 동작이나 메모리 손상이 발생할 수 있다. MISRA는 이런 자유를 일부 제한해서 코드의 동작을 더 예측 가능하게 만든다.

쉽게 말하면 다음 질문을 다룬다.

C와 C++를 어떤 방식으로 작성해야 안전한가?
 

AUTOSAR C++14

AUTOSAR C++14는 자동차에서 현대적인 C++ 기능을 안전하게 사용하기 위한 지침이다.

스마트 포인터, RAII, 템플릿, 예외, 상속처럼 C++에서 제공하는 기능을 무조건 금지하는 것이 아니라, 안전성과 실행시간 예측 가능성을 유지할 수 있는 범위를 정한다.

자동차 환경에서 C++ 기능을 어디까지, 어떻게 사용할 것인가?
 

SEI CERT C

CERT C는 취약점으로 이어질 수 있는 C 코드 패턴을 설명하는 데 가깝다.

예를 들어 버퍼 오버플로우, 널 포인터 역참조, 해제 후 사용, 정수 오버플로우, 포맷 스트링 문제를 각각 규칙 번호와 함께 설명한다.

이 코드 패턴이 실제 보안 취약점으로 이어질 수 있는가?
 

ISO 26262와 ISO/SAE 21434

ISO 26262는 자동차 기능안전 표준이고, ISO/SAE 21434는 자동차 사이버보안 표준이다.

이 두 표준은 특정 함수나 문법을 어떻게 작성할지 알려주는 코딩 규칙이라기보다, 위험을 어떻게 분석하고 관리하며 검증 근거를 남길지를 다룬다.

정리하면 다음 세 층으로 구분할 수 있었다.

언어 규칙
MISRA, AUTOSAR

취약점 설명
CERT C

안전·보안 프로세스와 증거
ISO 26262, ISO/SAE 21434
 

Safety와 Security의 차이

ISO 26262와 ISO/SAE 21434를 구분하면서 Safety와 Security의 차이도 다시 정리할 수 있었다.

Safety는 시스템의 고장이나 오작동이 사람을 해치지 않도록 하는 것이다.
반면 Security는 외부 공격자가 시스템을 침해하지 못하도록 막는 것이다.

Safety
시스템 → 바깥세상
시스템이 사람을 해치지 않도록 관리

Security
바깥세상 → 시스템
공격자가 시스템을 뚫지 못하도록 관리
 

ISO 26262에서는 HARA를 통해 위험을 분석하고 ASIL 등급을 정한다. ISO/SAE 21434에서는 TARA를 통해 사이버보안 위협을 분석한다.

둘은 서로 다른 분야처럼 보이지만 자동차에서는 연결되어 있다. 보안 공격으로 ECU나 차량 기능이 오작동하면 결국 안전사고로 이어질 수 있기 때문이다.

TARA는 위협을 위험도로 바꾸는 과정이다

TARA는 Threat Analysis and Risk Assessment의 약자로, 위협 분석 및 위험 평가를 의미한다.

단순히 “이 시스템에 취약점이 있다”고 말하는 것이 아니라, 그 취약점이 얼마나 위험한지 평가하고 어떤 대응이 필요한지를 결정하는 과정이다.

강의에서는 TARA의 흐름을 다음과 같이 설명했다.

자산 식별
→ 위협 시나리오 식별
→ 영향 평가
→ 공격 경로 분석
→ 공격 실현 가능성 평가
→ 리스크 결정
→ 리스크 처리
 

먼저 보호해야 할 자산을 찾고, 어떤 공격이 가능한지 생각한다. 이후 공격이 실제로 일어났을 때 안전, 재정, 운영, 개인정보 측면에서 어떤 피해가 발생하는지 평가한다.

공격에 필요한 시간, 지식, 장비, 접근 기회도 함께 고려한다. 마지막에는 위험을 회피할지, 줄일지, 다른 곳으로 전가할지, 아니면 수용할지를 결정한다.

이 과정이 필요한 이유는 모든 취약점을 같은 수준으로 처리할 수 없기 때문이다. 단순한 화면 오류와 원격 차량 제어 가능성을 같은 우선순위로 볼 수는 없다.

Shift Left: 보안을 개발 초기에 적용하기

강의에서 기억에 남았던 개념 중 하나가 Shift Left였다.

일반적인 개발 흐름을 왼쪽에서 오른쪽으로 적으면 다음과 같다.

요구사항 → 설계 → 구현 → 분석 → 검증
 

Shift Left는 보안 검사와 테스트를 가능한 한 앞 단계로 옮기는 것을 말한다.

완성된 제품에서 취약점이 발견되면 설계나 구조까지 다시 바꿔야 할 수 있다. 반면 요구사항이나 설계 단계에서 발견하면 더 적은 비용으로 수정할 수 있다.

따라서 정적 분석이나 보안 테스트를 개발 마지막에 한 번 실행하는 것으로 끝내면 안 된다.

요구사항
보안 요구와 금지 사항 작성

설계
위협 모델과 권한 경계 정의

구현
코딩 표준과 코드 리뷰 적용

분석
정적 분석과 Sanitizer 실행

검증
테스트 결과와 수정 근거 기록
 

도구는 최종 검사관이 아니라 개발 과정에 계속 붙어 있어야 한다는 의미로 이해했다.

스택과 힙의 차이

메모리 취약점을 이해하려면 스택과 힙의 차이를 알아야 한다.

스택

스택은 함수가 호출될 때마다 만들어지는 임시 공간이다. 지역 변수, 함수의 반환 주소, 저장된 레지스터 등이 들어간다.

함수가 끝나면 해당 공간의 수명도 끝난다.

스택의 고정 길이 배열에 너무 긴 값을 복사하면 주변 메모리까지 덮어쓸 수 있다. 경우에 따라 반환 주소가 변조되어 프로그램의 실행 흐름이 바뀔 수도 있다.

힙은 malloc이나 new처럼 프로그램 실행 중에 동적으로 할당하는 공간이다.

스택보다 객체의 수명을 자유롭게 관리할 수 있지만, 개발자가 직접 할당과 해제를 관리해야 하므로 문제가 발생하기 쉽다.

스택에서 자주 발생하는 문제
스택 버퍼 오버플로우

힙에서 자주 발생하는 문제
힙 오버플로우
Use-After-Free
Double Free
메모리 누수
 

자동차와 임베디드 코드에서는 실행 중 동적 메모리 할당을 아예 금지하거나 초기화 단계에서만 허용하는 경우도 있다고 한다. 실행시간을 예측하기 어렵고 메모리 단편화가 발생할 수 있기 때문이다.

스택 버퍼 오버플로우

다음 코드는 강의에서 나온 대표적인 위험 패턴이다.

 
void handle_name(const char *input)
{
    char name[16];

    strcpy(name, input);

    printf("hello %s\n", name);
}
 

name은 16바이트 배열인데, input의 길이는 검사하지 않는다. 만약 16바이트보다 긴 문자열이 들어오면 남은 문자가 사라지는 것이 아니라 배열 옆의 메모리까지 덮어쓴다.

입력 길이 > 버퍼 크기
→ 인접 메모리 덮어쓰기
→ 다른 변수 또는 반환 주소 손상
→ 프로그램 중단이나 실행 흐름 변조
 

이 문제의 핵심은 strcpy()라는 함수 이름만이 아니다. 복사되는 데이터의 최대 크기를 코드가 보장하지 않는 것이 문제다.

조금 더 안전한 형태는 다음과 같이 대상 버퍼 크기를 지정하는 것이다.

 
void better(const char *input)
{
    char name[16];

    snprintf(name, sizeof(name), "%s", input);
    name[sizeof(name) - 1] = '\0';
}
 

하지만 snprintf()를 사용했다고 자동으로 모든 문제가 해결되는 것은 아니다. 문자열이 잘렸는지 반환값도 확인해야 한다.

결국 중요한 질문은 이것이었다.

이 복사의 최대 길이는 어디에서 보장되는가?
 

스택 카나리

스택 버퍼 오버플로우를 탐지하는 방어 기법 중 하나가 카나리다.

카나리는 지역 버퍼와 반환 주소 사이에 넣어 두는 특별한 값이다. 버퍼가 넘쳐 반환 주소까지 덮으려면 카나리 값이 먼저 바뀐다.

함수가 종료될 때 카나리 값이 원래 값과 같은지 확인하고, 달라졌다면 프로그램을 중단한다.

지역 버퍼
→ 카나리
→ 프레임 포인터
→ 반환 주소
 

카나리는 전형적인 연속 덮어쓰기 공격을 탐지하는 데 도움이 된다. 그러나 논리 버그, 힙 오염, 정보 유출까지 막지는 못한다.

공격자가 카나리 값을 먼저 알아낸다면 우회 가능성도 생길 수 있다.

따라서 카나리는 근본적인 해결책이 아니라 추가적인 안전장치다.

잘못된 생각
카나리가 있으므로 오버플로우가 있어도 괜찮다.

올바른 방향
오버플로우가 발생하지 않도록 경계를 검사하고,
카나리는 추가 방어로 사용한다.
 

Use-After-Free와 메모리 수명

힙에서는 포인터의 수명을 잘못 관리해서 문제가 발생할 수 있다.

 
char *make_token(void)
{
    char *p = malloc(32);

    if (p == NULL) {
        return NULL;
    }

    strcpy(p, "session");
    free(p);

    return p;
}
 

이 코드에서는 p가 가리키는 메모리를 free()로 해제한 뒤 그 포인터를 반환한다.

포인터 변수 자체에는 예전 주소가 남아 있지만, 그 메모리는 더 이상 이 코드가 사용할 수 있는 공간이 아니다. 이후 다른 데이터가 그 위치를 사용할 수도 있다.

이렇게 해제된 메모리에 다시 접근하는 문제를 Use-After-Free라고 한다.

malloc
→ 정상 사용
→ free
→ 포인터는 남아 있지만 메모리 수명 종료
→ 다시 접근하면 Use-After-Free
 

이를 막으려면 누가 메모리를 해제할 책임이 있는지 명확해야 한다. C++에서는 스마트 포인터나 컨테이너를 사용하여 소유권과 해제를 객체 수명에 묶는 RAII 방식이 권장된다.

널 포인터 역참조

포인터가 항상 정상적인 객체를 가리킨다고 가정하는 것도 위험하다.

 
int read_sensor(struct sensor *s)
{
    return s->value;
}
 

s가 NULL이라면 s->value에 접근하는 순간 프로그램이 비정상 종료될 수 있다.

자동차 시스템에서는 단순한 크래시도 서비스 거부나 안전 기능 중단으로 이어질 수 있다.

 
int safe_read_sensor(struct sensor *s, int *out)
{
    if ((s == NULL) || (out == NULL)) {
        return -1;
    }

    *out = s->value;
    return 0;
}
 

중요한 것은 정상적인 입력뿐만 아니라 실패 경로도 설계하고 테스트하는 것이다.

센서 포인터가 NULL이면 어떻게 할 것인가?
읽기 함수가 실패하면 받은 데이터는 사용할 수 있는가?
일부만 초기화된 상태에서는 어떻게 정리할 것인가?
 

정수 오버플로우와 값의 절단

정수 오버플로우는 계산 결과가 변수의 표현 범위를 넘어설 때 발생한다.

특히 메모리 크기를 계산할 때 정수 오버플로우가 발생하면 실제 필요한 크기보다 작은 메모리를 할당하고, 그보다 많은 데이터를 쓰는 문제가 생길 수 있다.

 
uint16_t count = get_count();
uint16_t size = count * sizeof(struct item);

struct item *buf = malloc(size);
read_items(buf, count);
 

count * sizeof(struct item)의 결과가 16비트 범위를 넘으면 size에는 잘린 값이 저장될 수 있다.

예를 들어 실제로는 70,000바이트가 필요하지만 변수에는 훨씬 작은 값이 들어갈 수 있다. 그러면 작은 공간만 할당한 뒤 많은 데이터를 써서 힙 오버플로우가 발생한다.

수정할 때는 계산 전 상한을 검사하고, 더 넓은 타입으로 계산해야 한다.

 
if (count > MAX_COUNT) {
    return ERR_RANGE;
}

size_t size = (size_t)count * sizeof(struct item);
 

여기서 연산 결과가 타입 범위를 넘어 다시 처음부터 시작하는 것을 래핑이라고 한다. 큰 타입의 값을 작은 타입에 넣으면서 상위 비트가 사라지는 것은 절단에 가깝다.

INT30-C
덧셈이나 곱셈 같은 연산 결과가 범위를 넘는 문제

INT31-C
큰 값을 작은 타입으로 변환하면서 값이 손실되는 문제
 

둘은 비슷해 보이지만 발생하는 지점이 다르기 때문에 구분해야 한다.

포맷 스트링 취약점

출력 함수도 입력 검증의 대상이 된다.

다음 코드에서는 사용자 입력을 그대로 printf()의 포맷 문자열로 사용한다.

 
void bad(char *user)
{
    printf(user);
}
 

사용자 입력에 %x, %s, %n 같은 형식 지정자가 들어 있으면 printf()는 이를 단순한 문자로 출력하지 않고 명령처럼 해석한다.

그 결과 메모리 내용이 출력되거나, 특정 조건에서는 메모리에 값이 기록될 수도 있다.

다음처럼 포맷 문자열을 상수로 고정해야 한다.

 
void better(char *user)
{
    printf("%s", user);
}
 

이 부분의 핵심은 데이터와 명령을 분리하는 것이었다.

printf(user)
사용자 데이터가 명령으로 해석됨

printf("%s", user)
포맷은 개발자가 고정하고 사용자 입력은 값으로만 처리
 

MISRA 규칙을 코드에 연결하기

MISRA 규칙을 모두 외우는 것보다, 규칙이 어떤 위험을 막는지 이해하는 것이 중요했다.

강의에서는 다음 규칙들이 대표적으로 소개됐다.

Rule 1.3
정의되지 않은 동작에 의존하지 않는다.

Rule 10.3
더 좁은 타입에 값을 그대로 대입하지 않는다.

Rule 17.7
void가 아닌 함수의 반환값을 무시하지 않는다.

Rule 18.1
포인터 산술은 같은 배열 범위 안에서만 수행한다.

Rule 21.3
malloc/free 계열의 동적 메모리 할당을 사용하지 않는다.
 

예를 들어 CAN 메시지 길이를 32비트 변수에서 8비트 변수로 옮기는 상황을 생각할 수 있다.

 
uint32_t len = get_frame_len();
uint8_t dlc = len;
 

len이 255보다 크면 값이 잘릴 수 있다. 단순히 명시적 형변환을 붙이는 것만으로는 부족하고, 먼저 허용 범위를 검사해야 한다.

 
if (len > 8u) {
    return ERR_BAD_LEN;
}

uint8_t dlc = (uint8_t)len;
 

자동차 코드에서는 CAN의 데이터 길이처럼 프로토콜이 허용하는 범위와 함께 검증해야 한다.

CERT C 규칙과 취약 코드 연결

CERT C는 취약 코드와 규칙을 직접 연결해서 설명하기 좋았다.

취약 패턴CERT C 규칙
스택 버퍼 오버플로우 STR31-C
해제 후 사용 MEM30-C
널 포인터 역참조 EXP34-C
정수 연산 래핑 INT30-C
정수 변환 값 손실 INT31-C
포맷 스트링 FIO30-C

보고서나 코드 리뷰에서는 “위험해 보인다”라고만 적는 것보다 다음과 같이 작성하는 편이 정확하다.

외부 입력의 길이를 확인하지 않고 고정 배열에 복사하여
버퍼 경계를 초과할 수 있다.

CERT C STR31-C 위반에 해당하며,
대상 배열의 널 종료 공간까지 포함한 최대 길이를 보장해야 한다.
 

규칙 번호는 단순히 외우기 위한 번호가 아니라, 개발자들이 같은 문제를 짧고 정확하게 이야기하기 위한 공통 언어라는 점을 알 수 있었다.

현대·기아차의 자체 코딩 표준

국제 표준을 실제 자동차 기업에서 그대로 사용하기도 하지만, 기업의 개발 환경에 맞게 자체 표준으로 정리하기도 한다.

강의에서는 현대·기아차의 HKMC Secure C/C++ Coding Standard도 소개됐다.

예를 들어 앞에서 본 취약점들은 다음과 같이 HKMC 규칙으로도 연결된다.

버퍼 오버플로우
C-ARR-001

Use-After-Free
C-MEM-001

널 포인터 역참조
C-EXP-009

정수 오버플로우
C-INT-002 / C-INT-005

포맷 스트링
C-FIO-001
 

기업이 별도의 규칙 번호를 사용하더라도 기본적인 구조는 CERT C와 MISRA의 영향을 받는다.

결국 중요한 것은 규칙 이름보다도 입력 경계, 메모리 수명, 정수 범위, 오류 처리를 일관된 방식으로 관리하는 것이다.

AUTOSAR C++14와 RAII

C++에서는 메모리를 직접 할당하고 해제하기보다 객체의 수명에 자원 관리를 묶는 RAII 방식이 중요하다.

다음처럼 직접 malloc()과 free()를 사용하면 중간에 예외나 조기 반환이 발생했을 때 메모리 해제를 놓칠 수 있다.

 
int *p = static_cast<int *>(std::malloc(sizeof(int)));
std::free(p);
 

AUTOSAR C++14에서는 스마트 포인터와 표준 컨테이너를 사용하는 방향을 권장한다.

 
auto value = std::make_unique<int>(42);
std::vector<int> values{1, 2, 3};
 

객체가 범위를 벗어나면 메모리가 자동으로 해제되므로 사람이 모든 실행 경로에서 직접 delete를 호출할 필요가 줄어든다.

배열 할당과 해제 방식도 반드시 일치해야 한다.

 
int *a = new int[10];
delete a;        // 잘못된 방식

int *b = new int[10];
delete[] b;      // 형태는 맞음
 

다만 더 안전한 방향은 직접 new[]와 delete[]를 관리하기보다 std::array나 std::vector를 사용하는 것이다.

실패 경로도 코드의 일부다

함수가 실패할 수 있는데 반환값을 무시하면, 실패한 데이터를 정상 데이터처럼 사용할 수 있다.

 
can_read(&msg);
use(&msg);
 

can_read()가 실패했다면 msg는 사용할 수 없는 상태일 수 있다.

 
if (can_read(&msg) != CAN_OK) {
    return;
}

use(&msg);
 

C++의 std::optional을 사용할 때도 값이 있는지 확인해야 한다.

 
std::optional<Frame> frame = rx.read();

if (!frame) {
    return Error::NoFrame;
}

process(frame->id);
 

오류 처리는 정상 흐름을 방해하는 부가적인 코드가 아니라 프로그램 설계의 일부다.

소멸자처럼 객체를 정리하는 과정에서는 예외가 밖으로 빠져나가지 않도록 해야 한다. 정리 중 또 다른 예외가 발생하면 프로그램이 종료될 수 있기 때문이다.

 
~Logger() noexcept
{
    try {
        flush();
    } catch (...) {
        // 소멸자 밖으로 예외를 내보내지 않음
    }
}
 

결국 중요한 질문은 다음과 같다.

이 오류를 누가 복구할 수 있는가?
복구할 수 없다면 안전하게 중단할 수 있는가?
실패 경로가 테스트되어 있는가?
 

Required, Advisory와 Deviation

코딩 표준에는 규칙의 강도도 존재한다.

RequiredMandatory 규칙은 원칙적으로 반드시 지켜야 한다.
Advisory는 품질과 안전을 위해 강하게 권고되는 규칙이다.

하지만 실제 산업 코드에서는 모든 규칙을 예외 없이 적용하기 어려운 경우도 있다. 이때 사용하는 개념이 Deviation이다.

Deviation은 규칙을 그냥 무시하는 것이 아니다.

왜 규칙을 지키지 못하는가?
어떤 위험이 남는가?
대체 방어 방법은 무엇인가?
누가 이를 검토하고 승인했는가?
 

이 내용을 문서로 남기는 절차다.

표준 준수의 목표는 무조건 위반 개수를 0으로 만드는 것만이 아니라, 남아 있는 위험을 알고 통제하고 있다는 사실을 증명하는 것이다.

정적 분석과 동적 분석

사람이 모든 코드 경로를 직접 확인하기는 어렵기 때문에 도구를 함께 사용한다.

강의에서는 정적 분석과 동적 분석의 차이를 설명했다.

정적 분석

정적 분석은 프로그램을 실행하지 않고 소스코드를 분석한다.

도구는 함수 이름, AST, 데이터 흐름, 제어 흐름 등을 살펴보며 잠재적인 문제를 찾는다.

찾을 수 있는 예
경계 밖 접근 가능성
널 포인터 역참조 가능성
미초기화 값
Use-After-Free
반환값 무시
MISRA/CERT 규칙 위반
 

장점은 프로그램을 실행하지 않아도 다양한 경로를 검사할 수 있다는 것이다. CI에 넣어 코드가 머지될 때마다 실행할 수도 있다.

단점은 실제 실행값을 모르기 때문에 오탐이 발생할 수 있다는 점이다. 도구가 위험하다고 판단했지만 실제로는 안전할 수도 있다.

하지만 오탐이라고 해서 그냥 무시해서는 안 된다. 왜 안전한지 근거를 남겨야 한다.

강의에서 사용한 대표적인 도구는 다음과 같았다.

cppcheck
C/C++ 정적 분석

clang-tidy
LLVM 기반 린터와 정적 검사

flawfinder
strcpy, printf 같은 위험 함수 탐지

gcc -fanalyzer
GCC의 코드 흐름 분석
 

동적 분석

동적 분석은 프로그램을 실제로 실행하면서 오류를 감시한다.

대표적인 도구가 AddressSanitizer와 UndefinedBehaviorSanitizer다.

ASan
버퍼 오버플로우
Use-After-Free
잘못된 메모리 접근

UBSan
정수 오버플로우
0으로 나누기
널 포인터 접근
잘못된 형변환
 

동적 분석의 장점은 실제로 오류가 발생한 줄과 원인을 정확하게 보여 준다는 것이다. 오탐도 비교적 적다.

단점은 실행한 경로만 검사한다는 점이다. 테스트에서 실행되지 않은 코드 경로의 문제는 찾지 못할 수 있다.

따라서 둘 중 하나만 사용하는 것이 아니라 함께 사용해야 한다.

정적 분석
실행하지 않고 넓게 검사

동적 분석
실제로 실행된 경로를 정확하게 검사

사람 리뷰
도구가 이해하지 못하는 설계와 의도를 판단
 

하나의 도구로 모든 문제를 찾을 수는 없다

강의 실습에서는 같은 취약 코드에 여러 도구를 실행했다.

위험 함수 이름을 찾는 flawfinder는 strcpy()나 위험한 printf() 사용은 잘 찾지만, 포인터의 수명을 추적해야 하는 Use-After-Free나 널 포인터 문제는 잡지 못할 수 있다.

cppcheck와 clang-tidy는 코드 흐름을 더 깊게 분석할 수 있지만, 모든 정수 오버플로우를 완벽하게 찾지는 못한다.

그래서 다음과 같은 흐름을 사용한다.

컴파일러 경고
→ 정적 분석
→ Sanitizer
→ 단위 테스트
→ 사람 코드 리뷰
→ 수정 후 재검사
 

도구별로 잡는 결함이 다르기 때문에 여러 단계를 통과시키는 것이 중요하다.

수정했다는 증거 남기기

취약점을 발견하고 코드를 수정했다고 말하는 것만으로는 충분하지 않다.

수정 전 코드에서 도구가 문제를 탐지하고, 수정 후 같은 도구를 실행했을 때 더 이상 문제가 나오지 않는지를 확인해야 한다.

강의에서는 실습 과정을 다음과 같이 정리했다.

취약 코드 확인
→ 정적 분석 실행
→ Sanitizer로 실제 오류 관찰
→ 표준 규칙에 매핑
→ 코드 수정
→ 같은 테스트와 분석을 다시 실행
 

예를 들어 다음과 같은 변경을 할 수 있다.

취약 코드보완 방향
strcpy(id, input) 크기를 제한한 복사
free(token) 후 다시 사용 해제 후 포인터를 NULL로 변경하고 재사용 금지
NULL 포인터 바로 역참조 사용 전 NULL 확인
작은 타입에서 정수 계산 더 넓은 타입과 범위 검사 사용
printf(user_msg) printf("%s", user_msg)

그리고 한 번 고친 문제는 재발 방지 테스트로 남겨야 한다. 이후 코드가 변경되더라도 같은 취약점이 다시 생기지 않는지 자동으로 확인할 수 있기 때문이다.

코드 리뷰 보고서 작성 방식

이번 강의를 통해 취약 코드를 분석할 때 다음 구조로 작성하면 된다는 것을 알 수 있었다.

1. 취약 코드
어떤 코드에서 문제가 발생하는지 제시

2. 위험
버퍼 경계 초과, 크래시, 정보 유출 등 영향 설명

3. 원인
길이 검사 누락, 포인터 수명 오류, 정수 범위 오류 등

4. 표준 매핑
MISRA, AUTOSAR, CERT C, HKMC 규칙 연결

5. 수정 방향
안전한 코드와 설계 방식 제시

6. 검증 증거
정적 분석, Sanitizer, 단위 테스트 결과 기록
 

예를 들어 버퍼 오버플로우는 다음과 같이 정리할 수 있다.

원인:
외부 입력 길이를 검사하지 않고 고정 길이 배열에 복사함.

위험:
배열 밖의 메모리를 덮어써 인접 변수 또는 반환 주소가 손상될 수 있음.

표준:
CERT C STR31-C, MISRA Rule 18.1 등과 연결됨.

수정:
입력 길이를 검사하고 대상 버퍼 크기를 기준으로 제한하여 복사함.

검증:
경계값 테스트와 ASan을 실행하고 수정 전후 결과를 비교함.
 

이렇게 작성하면 단순한 코드 설명이 아니라, 실제 산업 코드 리뷰와 비슷한 형태가 된다.

이번 강의에서 기억할 핵심

이번 강의의 내용은 다음과 같이 정리할 수 있었다.

시큐어 코딩은 위험한 코드를 공통 규칙으로 설명하는 과정이다.

많은 취약점은 입력 경계와 메모리 수명에서 시작한다.

MISRA와 AUTOSAR는 안전한 작성 방법을 정하고,
CERT C는 취약 코드 패턴을 설명한다.

ISO 26262와 ISO/SAE 21434는
위험 관리와 검증 근거를 요구한다.

정적 분석, 동적 분석, 테스트와 사람 리뷰를 함께 사용해야 한다.

취약점 수정은 코드 변경에서 끝나는 것이 아니라,
수정 근거와 재검사 결과까지 남겨야 한다.
 

마무리

이번 강의를 듣기 전에는 코딩 표준을 단순히 회사에서 정해 놓은 까다로운 문법 규칙 정도로 생각했다.

하지만 자동차나 임베디드 환경에서는 작은 포인터 실수나 정수 변환 하나가 ECU 중단이나 차량 기능 오작동으로 이어질 수 있다. 이런 환경에서 코딩 표준은 개발자를 불편하게 만들기 위한 문서가 아니라, 과거에 반복됐던 사고를 줄이기 위한 공통 약속에 가깝다.

특히 기억에 남은 것은 다음 문장이었다.

시큐어 코딩의 목표는
취약한 코드를 보고 “위험하다”고 말하는 데서 끝나지 않는다.

왜 위험한지 설명하고,
어떤 규칙을 위반했는지 연결하고,
수정한 뒤 안전해졌다는 증거를 남기는 것까지가 한 과정이다.
 

결국 안전한 코드는 특별한 기술 하나로 만들어지는 것이 아니라, 입력 크기를 확인하고, 포인터 수명을 관리하고, 반환값을 검사하고, 위험한 함수 사용을 제한하는 기본적인 습관에서 시작된다는 점을 알 수 있었다.