![]()
"입력 한 글자"가 C를 깨는 순간: PyOS_StdioReadline NUL 바이트 OOB-read 패치를 실무에 연결하기
meta_description: CPython PR 하나가 보여준 건 거창한 기능이 아니라 입력 종단 규약이었다. PyOS_StdioReadline 경로에서 NUL 바이트로 발생할 수 있었던 out-of-bounds read 패치를 계기로, C 경계면 입력 처리와 회귀 테스트를 실무 관점으로 정리한다. meta_keywords: CPython, Python, PyOS_StdioReadline, readline, stdin, NUL byte, null character, OOB read, out of bounds, memory safety, C boundary, REPL, parser, fuzzing, regression test, input handling, buffer, terminator meta_robots: index,follow
REPL은 안전하다고 착각하기 쉬운 영역이다. 화면엔 프롬프트가 뜨고, 우리는 키보드로 한 줄을 치고, 인터프리터가 그걸 처리한다. 그래서 마음속에는 이런 전제가 깔린다.
“입력은 그냥 문자열이다.”
그런데 C 레벨에서 입력은 “문자열”이기 전에 “버퍼”이고, 버퍼는 “길이”와 “종단(끝)”을 어떻게 해석하느냐로 안전성이 갈린다. 이번에 머지된 CPython PR(gh-140594, PR #140910)은 그걸 아주 짧게 보여준다. PyOS_StdioReadline 경로에서 NUL 바이트(\0)가 들어올 때 out-of-bounds read로 이어질 수 있는 틈을 메웠다(참고자료).
여기서는 영향 범위를 단정하지 않는다. 대신 PR이 고친 “입력 경계면”을 기준으로, 왜 NUL 한 글자가 메모리 안전 문제로 이어질 수 있는지, 그리고 비슷한 경계면을 가진 우리 코드에 어떤 테스트를 추가하면 좋은지에 집중한다.
NUL 바이트는 ‘문자’가 아니라 ‘종단’으로 취급될 때가 있다
파이썬 레벨에서 우리는 NUL 바이트를 특별하게 느끼지 않는다. 바이트열에는 들어갈 수 있고, 유니코드 문자열에도 들어갈 수 있다.
하지만 C에서는 상황이 다르다. 많은 C API는 문자열을 “NUL로 끝나는 배열”로 다룬다. 즉, NUL은 데이터의 일부가 아니라 끝을 표시하는 신호가 된다.
이 차이가 문제를 만든다.
- 파이썬 쪽 호출자는 “길이 있는 데이터”를 넘겼다고 생각하는데
- C 쪽 구현은 “NUL에서 끝난 문자열”로 해석해 버리거나
- 반대로 길이 계산/복사 과정에서 종단 처리를 잘못하면
- 버퍼 경계 바깥을 읽는(out-of-bounds read) 틈이 생긴다
PR 제목이 굳이 “feeding NUL byte”를 강조하는 이유는, 바로 이 경계에서 사고가 나기 때문이다. 평소엔 잘 보이지 않다가, 입력이 비정상일 때(혹은 입력 경로가 비정상 데이터를 허용할 때)만 나온다.
파이썬에서 NUL이 어떤 느낌인지 감각을 잡으려면, 아래처럼 ‘같은 데이터’를 파이썬과 C 스타일로 해석했을 때 달라지는 점을 떠올리면 된다.
data = b"abc\x00def"
print(len(data)) # 7
print(data.split(b"\x00")) # [b'abc', b'def']
# C에서는 NUL(\0)이 종단으로 취급되는 문자열 API가 많다.
이 차이가 곧 “테스트에 없는 입력”이 된다. 그리고 테스트에 없는 입력은, 안전하지 않을 확률이 높다.
여기서 C를 깨는 순간이 나온다. 파이썬 쪽에선 이미 len(bytes)를 알고 있는데, C 쪽에서 습관처럼 strlen()을 다시 써버리는 경우다.
// 예: bytes에 NUL이 섞여 있을 수 있는데도 C 문자열 함수로 길이를 재는 실수
// raw는 (포인터, 길이)로 이미 전달받았다고 가정한다.
size_t n = strlen(raw); // NUL에서 끊겨서 짧아질 수 있다
memcpy(dst, raw, n); // dst는 '원래 길이' 기준으로 잡혀있을 수도 있다
// 길이 기반으로 처리해야 할 경계면에서, 종단 기반 함수가 끼면 사고가 난다.
stdio readline 경로는 어디서 튀어나오나(내가 만나는 케이스)
PyOS_StdioReadline이라는 이름 때문에, 사람들은 이걸 “REPL에서만 쓰는 코드”로 생각하기 쉽다. 하지만 실무에선 입력 경로가 REPL을 넘어 돌아다닌다.
- 임베디드 파이썬(앱 안에 파이썬 콘솔을 붙인 경우)
- 디버그 모드에서만 열리는 콘솔
- 테스트 러너가 stdin을 흉내 내는 환경
- 터미널이 아니라 파이프/리다이렉션으로 들어오는 입력
이런 곳에서는 “사람이 타이핑한 텍스트”라는 전제가 쉽게 깨진다. 데이터는 파일에서 오고, 네트워크에서 오고, 자동화된 스크립트에서 온다. 그러면 NUL 바이트처럼 “텍스트라고 믿기 어려운 바이트”가 섞일 가능성이 생긴다.
그래서 이런 PR을 읽으면서 내가 다시 확인하는 건 하나다.
입력 경계면에선 ‘정상 텍스트만 들어온다’는 가정을 최대한 늦게까지 미뤄야 한다.
운영에서 터지는 건 대개 “입력은 당연히 깨끗하겠지”라는 가정이 먼저다.
패치가 바꾼 핵심: 길이 계산과 종단 처리에서 ‘한 글자 틈’을 메운다
이 PR에서 내가 특히 눈여겨본 건 “한 글자 차이”가 어디서 생기느냐였다.
- 길이를 어떻게 계산했는지
- 종단(NUL)을 어디에, 어떤 조건으로 붙였는지
- NUL을 만났을 때 루프/인덱스가 어디로 움직였는지
이 조합이 어긋날 때, out-of-bounds read가 생길 수 있다.
실무에서는 C 코드를 정확히 외우기보다, 계약을 코드 모양으로 기억하는 게 낫다. 입력 버퍼를 다루는 함수는 보통 아래 계약을 만족해야 한다.
(의사코드) 입력을 버퍼로 받는 함수의 안전 계약
(1) 길이는 '측정'한 값만 믿는다.
(2) 종단 문자를 추가할 때는, 버퍼의 마지막 칸을 넘지 않는다.
(3) NUL을 데이터로 허용할지, 종단으로 해석할지 정책을 하나로 고정한다.
(4) 정책이 다르면, 경계면에서 즉시 에러로 끊는다(조용히 잘라먹지 않는다).
이번 PR을 실무적으로 해석하면 “NUL을 만나는 경로에서도 인덱스/종단 처리가 균형을 잃지 않게 했다”는 종류의 수정에 가깝다.
이런 수정은 보통 기능 테스트로는 잡기 어렵다. 입력을 NUL로 오염시키는 테스트가 없으면, 평생 안 만난 것처럼 지나간다.
내 코드에 옮길 것: NUL은 ‘있을 수 있는 입력’으로 테스트한다
여기서부터가 실무 얘기다.
대부분의 팀은 PyOS_StdioReadline을 직접 수정하지 않는다. 하지만 비슷한 경계면은 우리 코드에도 있다.
- C 확장 모듈에서 bytes/str을 받아 처리하는 부분
- 파일/파이프에서 읽어온 데이터를 C로 넘기는 부분
- “한 줄씩 읽기”를 구현한 유틸
이때 회귀 테스트의 모양을 바꾸면, 같은 종류의 사고를 줄일 수 있다.
나는 입력 테스트를 “정상값/이상값”으로 나누지 않고, “경계값”을 먼저 넣는다.
- 빈 입력
- 아주 긴 줄
- NUL 포함
- 멀티바이트/인코딩 경계(중간이 잘린 UTF-8)
특히 NUL은 이상한 입력이 아니라, ‘섞일 수 있는 입력’이다. 파일을 바이너리로 읽어왔다면 더 그렇다.
파이썬에서 간단히 이런 테스트 형태를 만들 수 있다. 여기서는 우리가 만든 입력 처리 함수가 bytes를 받는다고 가정한다.
import pytest
def parse_line(raw: bytes) -> str:
# 예시: 실무에선 C 확장이거나, bytes→str 변환이 걸리는 경계면일 수 있다.
return raw.decode("utf-8", errors="strict")
@pytest.mark.parametrize(
"raw",
[
b"",
b"hello\n",
b"abc\x00def\n", # NUL 포함
b"\xf0\x9f\x92\xa9\n", # valid UTF-8
],
)
def test_parse_line_boundaries(raw):
# 여기서 실패하면 그게 곧 요구사항이다: 허용할지, 거부할지.
try:
parse_line(raw)
except UnicodeDecodeError:
pass
이 테스트가 중요한 이유는, 성공/실패 자체보다 “정책을 고정”하기 때문이다. NUL을 허용할지, 허용한다면 어떤 의미로 허용할지(데이터로? 종단으로?)를 코드로 남길 수 있다.
CPython PR이 보여준 것도 결국 그 정책의 중요성이다. 입력 경계면에서 정책이 흐리면, 구현이 흔들리고, 흔들리면 안전성이 흔들린다.
마무리: “입력 한 줄”은 생각보다 바이너리에 가깝다
이 PR을 보면, REPL/stdio 같은 기본 경로에서도 NUL 한 글자가 경계 밖 읽기로 이어질 수 있는 틈이 생긴다. 그리고 그 틈은 대부분의 기능 테스트로는 잘 안 잡힌다.
내일 당장 할 수 있는 체크로 바꾸면 이렇다.
1) 입력 테스트에 NUL 한 번 넣기
- 예: b"abc\x00def\n" 같은 케이스를 경계면 테스트(혹은 E2E)에 추가한다.
2) 경계면에서 “길이 기반”인지 “종단 기반”인지 확인하기
- C 확장/FFI/내장 라이브러리 경계에서 strlen/strcpy/strcat 같은 종단 기반 API가 끼어 있지 않은지 한 번만 훑는다.
3) 정책을 한 문장으로 박아두기 - “NUL을 받으면 에러로 끊는다” / “바이너리로만 취급한다” / “텍스트 입력엔 NUL을 허용하지 않는다”처럼, 팀이 유지할 수 있는 한 문장을 테스트 옆에 붙인다.
이 세 가지를 해두면, 다음에 누가 ‘사소한 리팩터링’을 하더라도 같은 구멍으로 다시 떨어질 확률이 확 내려간다.
참고자료
- CPython PR — gh-140594: Fix an out of bounds read when feeding NUL byte to PyOS_StdioReadline
- https://github.com/python/cpython/pull/140910
- PR diff
- https://github.com/python/cpython/pull/140910.diff
- Merge commit
- https://github.com/python/cpython/commit/86a0756234df7ce42fa4731c91067cb7f2e244d5
- Relevant code (Parser/myreadline.c)
- https://github.com/python/cpython/blob/86a0756234df7ce42fa4731c91067cb7f2e244d5/Parser/myreadline.c
댓글
댓글 쓰기