주말 프로젝트: C 언어로 즐기는 유쾌한 도전
(lcamtuf.substack.com)- C는 단순하고 표현력이 높지만, switch 문과 레이블 규칙만으로도 숙련 개발자를 당황하게 만드는 코드를 만들 수 있음
- IOCCC식 난독화는 매크로와 포맷팅으로 코드를 숨기지만, C 자체 문법만으로도 읽을 수 있으면서 낯선 코드가 가능함
switch (...)는 중괄호가 필수가 아니며, 일치하는 case 레이블로 점프하기 때문에 일반적인 블록 초기화 흐름과 다르게 동작할 수 있음case레이블은switch블록 최상위에만 있을 필요가 없어if (0) case...와else if체인을 결합한 중괄호 없는 switch도 컴파일됨- GNU C의
&&레이블 주소 확장은 직접 만든 switch나 레이블 기반 루프까지 가능하게 하지만, 일부 예시는 GCC 전용이고 정의되지 않은 동작에 안전하지 않을 수 있음
C 문법이 만드는 낯선 코드
- C는 결점이 많지만, 단순한 문법과 표현력 덕분에 운영체제 같은 큰 소프트웨어를 작성할 수 있는 언어로 쓰임
- 간결한 문법은 Java부터 Go까지 많은 주류 후속 언어의 코드 구조에도 영향을 줌
- 코드 난독화의 대표 사례는 IOCCC임
- IOCCC 수상작은 전처리기 매크로, 비정상적인 포맷팅, 도움이 되지 않는 변수명, 난해한 산술식으로 구성되는 경우가 많음
- 이런 코드는 감탄할 만하지만, 정상 코드로 역공학해야 해서 학습하기에는 재미가 덜할 수 있음
switch, case, goto로 드러나는 C의 깊은 구석
switch (...)는if (...)나for (...)처럼 중괄호 없이 쓸 수 있음switch (i) case 1: puts("i = 1");는 컴파일 가능함- 중괄호가 없으면 하나의 문장만
switch에 붙기 때문에, 이어지는case 2:는 더 이상switch안에 있지 않아 오류가 됨
switch는 본질적으로 일치하는case레이블로 이동하는 goto에 가까운 구조임switch블록 안에int a = 123;와puts(...)가 있어도,default:로 점프하면 그 앞의 초기화 코드는 실행되지 않음- 이 경우
a는 123으로 초기화되지 않으며, 기술적으로 정의되지 않은 동작이 됨 - 예시는 Godbolt에서 확인 가능함
case레이블은 연결된switch블록의 최상위에만 있을 필요가 없음if (0) case 0: puts("i = 0");같은 형태도 동작함switch가 해당case로 직접 점프하므로 앞의if (0)은 건너뜀- 해당
puts(...)실행 뒤의 다른 출력문은 계속if (0)조건에 막히므로break없이도 fallthrough를 피할 수 있음 - 예시는 Godbolt에서 확인 가능함
if ... else체인은 문법적으로 하나의 최상위 문장처럼 동작하므로, 이를switch와 결합해 중괄호 없는 괴상한 switch를 만들 수 있음- 예시는
case 1 ... 10범위 case와default까지 포함함 - 예시는 Godbolt에서 확인 가능함
- 예시는
- GNU 확장인
&&연산자는 레이블의 주소를 얻고, 그 주소로goto할 수 있게 함- 이를 이용해
goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];같은 방식으로 직접 만든 switch를 구현할 수 있음 - 예시는 Godbolt에서 확인 가능함
- 이를 이용해
- 같은 GNU 확장을 이용하면
for (...)없이도 변수 선언 안에서 레이블 기반 루프를 구현할 수 있음- 예시는
i = 0부터i = 5까지 출력하는 루프를 레이블과goto *&&_로 구성함 - 예시는 Godbolt에서 확인 가능함
- 이 마지막 스니펫은 GCC 전용이며, 정의되지 않은 동작에 안전하지 않을 가능성이 있음
- 예시는
- C에서는 코드를 의도적으로 난독화하지 않아도 완전히 낯설고 혼란스러운 형태를 만들 수 있음
댓글과 토론
Hacker News 의견들
-
위 예시는
a값을 출력하지만 123으로 초기화되지는 않는다고 했는데, C에서는 실제로 그렇게 될 수도 있음
초기화되지 않은 변수를 쓰는 것은 “그 메모리에 우연히 남아 있던 값”을 읽는다는 뜻이 아니라 정의되지 않은 동작이라서, 컴파일러가 마음대로 해도 됨
예를 들어 해당 메모리를 무조건 123으로 초기화할 수도 있고, 전체 조각이 정의되지 않은 동작이라고 보고 명령을 전부 없애 아무것도 출력하지 않게 만들 수도 있음. 더 나아가 뒤따르는return이나 앞선 명령까지 최적화로 없앨 수 있어서, 정의되지 않은 동작이 “시간을 거슬러” 영향을 주는 것처럼 보일 수 있음- C에서 정의되지 않은 동작이 실제로 시간을 거슬러 가는 것은 아님
이전 명령에 영향을 줄 수는 있지만, 코드 재배치나 복잡한 변환은 정의되지 않은 동작이 없어도 일어나는 일임 - C에서 초기화되지 않은 자동 저장 기간 객체에 접근할 때만 정의되지 않은 동작임
정적 객체는 항상 초기화되므로 이런 상황이 생기지 않음
남는 것은malloc으로 할당한 구조체의 초기화되지 않은 멤버 같은 동적 객체인데, 초기화되지 않은 동적 메모리를 읽는 것은 C에서 정의되지 않은 동작이 아니고 초기화되지 않은 비트가 의미하는 값을 얻게 됨. 해당 타입에 트랩 표현이 없다면 실패할 수 없음
- C에서 정의되지 않은 동작이 실제로 시간을 거슬러 가는 것은 아님
-
이런 식의 구문도 가능함
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }
각 절 끝마다break를 쓰지 않아도 되는switch처럼 만들 수 있고,#define brkcase if (0) case같은 매크로도 가능함. 컴파일러가 제어 흐름을 좋아하진 않겠지만 대체로 잘 지워낼 것 같음#define brkcase break;case도 비슷하게 동작하지 않나 싶지만, 그러면 매크로의 목적이 약간 사라짐- 이 방식이 어떻게 동작하는지 매우 불명확해서, Duff's device식 switch 남용보다는 같은 제어 흐름을
goto로 쓰는 편이 낫겠음 - 이 방식은
case라벨 본문이 한 줄이거나 중괄호로 감싸져 있을 때만 동작함
예전에 “다음case라벨의 첫 줄만 건너뛰고 나머지는 그대로 폴스루”라는 뜻으로 이 구조를 쓴 적이 있음
case라벨을 문장 사이의 구분자가 아니라 단순한 라벨로 보면 전부 말이 됨
-
C에서 코루틴을 구현하는 데 이 기법을 쓸 수 있음: https://stackoverflow.com/questions/24202890/switch-based-co...
- uIP, 즉 작은 마이크로컨트롤러용 TCP/IP 스택도 이런 류의 코루틴을 실제로 쓰는 재미있는 예시임: https://github.com/adamdunkels/uip/blob/master/uip/lc-switch...
-
case 1 ... 10:이 유효한 C라는 걸 왜 몰랐는지 모르겠음
C를 몇 년이나 썼는데, 이게 어느 표준에서 나온 건지 궁금함- 최근에 표준화된 게 아니라면 유효한 C가 아니라 GNU 확장임
- GNU C 확장으로 보임: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
확장의 역사는 찾지 못했고, 표준 C에는 없는 것으로 알고 있음.clang은 확실하지 않음
-
예전에 재미로 10부터 1까지 카운트다운하는 이상한 C 코드를 쓴 적이 있음
C, Python, 셸 버전 모두&&와 재귀 호출을 이용해 한 줄짜리로 만들 수 있음- 사소한 지적이지만
sys.stdout.write(f"{n}\n")는print(n)으로 바꿀 수 있음
지금 코드는f-string만 빼면print가 문장이던 Python 2 스타일처럼 보임. Python 3에서는print가 일반 함수이고None을 반환해 거짓으로 평가되므로, 첫 번째and도or로 바꿔야 함
- 사소한 지적이지만
-
또 다른 놀라운 점은
4[arr]가arr[4]와 같다는 것임- 배열이 포인터로 변환되기 때문에 기본적으로
*(array_label+offset)이고, 여기서는*(offset+array_label)가 됨
즉*(arr+4)와*(4+arr)가 같다는 얘기임 - 같은 원리로
arr[i][j]와j[i[arr]]도 정확히 같음
a[x][y]가(a[x])[y]와 같고,a[x]가x[a]와 같다는 것만 알면 됨
arr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]
- 배열이 포인터로 변환되기 때문에 기본적으로
-
글의 마지막 난독화 코드 조각은 또 다른 GCC 확장을 보여줌: https://stackoverflow.com/questions/34559705/ternary-conditi...
-
이 블로그 작성자의 이런 장난스러운 기법들을 Twitter에서 먼저 봤음
switch문으로 루프도 만들 수 있음: https://twitter.com/lcamtuf/status/1807129116980007037- 실제 소셜 네트워크에도 올라와 있음: https://infosec.exchange/@lcamtuf/112701486085621844
-
이런
switch장난이 Duff's device에서 중요한 부분 아닌가 싶음- Duff's device는 K&R C 문법에서
switch블록과 루프를 섞을 수 있다는 점, 명시적으로break하지 않으면case가 폴스루되는 선택, 그리고 C에서 루프가 다시switch안쪽으로 점프할 수 있다는 점에 기대고 있음
Duff는 메모리 매핑 입출력(MMIO)을 최적화하려던 것이고, 오늘날 C에서도 이런 식으로 하지는 않을 것임. 이제 MMIO는 CPU 명령 속도와 비슷하게 빠르지 않고, 데이터가 조금만 많아도 DMA를 쓸 수 있음
현대 언어라면 MMIO를 단순한 포인터 간접 참조로 다루지도 않을 텐데, C에서는 이를 유지하려고 타입 시스템에 우회 장치를 계속 추가해 왔음
개인적으로 Tom Duff의 “Device”를 잇는 것은 WUFFS의 iterate loops 메커니즘이라고 봄. 루프의 N단계를 부분적으로 펼치는 방법을 지정하면서, 주 루프 본문을 N번 실행한 것과 같은 결과지만 더 빠를 수 있다고 약속하는 방식임. 벡터화가 의도를 파악하기 쉬워지고,M % N != 0같은 귀찮은 경계 사례는 사람이 아니라 도구가 올바르게 처리하게 됨
- Duff's device는 K&R C 문법에서