왜 이 가드가 있나
특정 명령을 자동으로 실행하지 못하게 막는 사전 검사가 있다. 릴리스 빌드처럼 결과물이 그대로 운용에 쓰이는 명령은 사람이 직접 실행해야 하고, 자동 실행을 막아둔 것이다.
검사는 실행 직전에 명령 문자열을 받아 금지 패턴과 맞춰본다. 처음 구현은 정규식 하나였다. 아래 예시에서 금지 대상 명령을 deploy --prod로 적는다.
re.compile(r"deploy\s+.*--prod")
이 형태가 실제로 뭘 하는지 겪은 순서대로 적는다. 같은 검사를 만들 사람이 같은 세 함정을 그대로 밟게 되기 때문이다.
함정 1. 문자열 리터럴 속 명령을 실행으로 읽는다
먼저 오탐이 났다. 아래 명령이 차단됐다.
grep -n "deploy --prod" Makefile
이건 파일에서 그 문자열을 찾는 명령이다. 아무것도 배포하지 않는다. 그런데 원문 전체를 훑는 정규식에는 실행과 구별할 방법이 없다.
여기서 판단을 하나 했다. "인용을 인식하게 만들면 된다." 따옴표 안쪽은 문자열이니 검사에서 빼면 오탐이 사라진다. 맞는 방향처럼 보였고, 실제로 오탐은 사라졌다.
함정 2. 이스케이프 홀짝을 세지 않으면 더 느슨해진다
인용 인식을 손으로 짰다. 따옴표를 만나면 상태를 토글하고, 앞에 백슬래시가 있으면 이스케이프된 따옴표로 보고 토글하지 않는 방식이다.
이 구현이 통과시킨 입력이 있다.
echo "a\\" && deploy --prod
닫는 따옴표 앞에 백슬래시가 있으니 내 파서는 이스케이프된 따옴표로 판정하고 문자열이 계속 열려 있다고 봤다. 그래서 뒤의 명령이 문자열 안쪽으로 분류되어 검사에서 제외됐다.
실제 셸은 다르게 읽는다. 백슬래시 두 개는 백슬래시 한 글자이고, 따옴표는 이스케이프되지 않았으므로 문자열은 거기서 닫힌다. 뒤의 명령은 실행된다.
핵심은 이것이다. 원래의 무식한 정규식은 이 입력을 막았다. 인용을 이해하지 못하니 원문 전체를 훑어서 그냥 잡았다. 정교하게 만든 결과가 구버전보다 느슨해진 것이다.
함정 3. 중첩과 heredoc
홀짝을 세도록 고쳤다. 그러자 다음이 뚫렸다.
bash -c "bash -c \"deploy --prod\""
인터프리터 우회는 다음 토큰을 페이로드로 보고 재귀 검사해야 하는데, 재귀할 때 페이로드의 이스케이프를 한 겹 벗기지 않아서 두 번째 겹부터 패턴이 안 맞았다.
그것도 고쳤다. 그러자 이번엔 오차단이 났다. 파일을 쓰는 heredoc 본문에 그 명령 텍스트가 평문으로 들어 있었는데, 개행을 명령 구분자로 취급하다 보니 본문 각 줄이 독립 명령으로 재판정된 것이다.
세 번 고쳤고 세 번 새 결함이 났다. 방향이 문제였다.
💡 셸 명령 문자열을 직접 파싱하는 검사는 정교해질수록 느슨해질 여지가 늘어난다. 무식한 검사는 과차단만 하고 과통과는 거의 안 한다. 반면 인용, 이스케이프, 중첩, heredoc을 이해하려는 검사는 이해 범위가 늘어나는 만큼 "이건 문자열이니 검사 제외"라는 판단을 많이 하고, 그 판단이 하나만 틀리면 통과 경로가 생긴다. 보안 성격의 검사에서 오탐 수정은 통과 경로를 추가하는 변경이라는 점을 먼저 인식해야 한다.
해결 1. 파싱을 표준 라이브러리에 넘긴다
같은 방법으로 두 번 실패했으면 접근을 바꾸는 게 규칙이었다. 세 번째에 바꿨다.
인용과 이스케이프 파싱을 직접 하지 않고 shlex에 위임했다.
import shlex
try:
tokens = shlex.split(command) # POSIX 모드
except ValueError:
tokens = None # 인용 불일치 등
부수 효과가 하나 있는데 이게 중첩 문제를 통째로 없앤다. shlex는 인터프리터 옵션 뒤의 페이로드를 이미 한 겹 벗겨서 토큰으로 준다. 그 토큰을 그대로 재귀에 넘기면 몇 겹으로 감싸도 항상 현재 겹의 진짜 명령 문자열이 되므로, 깊이별 이스케이프 처리가 필요 없어진다.
해결 2. 파싱 실패는 통과가 아니라 구버전으로 폴백한다
shlex도 실패한다. 인용이 안 맞는 문자열을 주면 예외를 던진다. 이때 무엇을 하느냐가 이 가드의 성격을 결정한다.
통과시키면 예외를 유발하는 입력이 곧 우회 수단이 된다. 차단하면 정상 명령이 막힌다.
택한 것은 세 번째다. 파싱 실패 시 인용을 이해하지 못하던 구버전 정규식으로 되돌아가 원문 전체를 훑는다.
if tokens is None:
return scan_legacy(command) # 인용 비인식, 원문 전체 스캔
return scan_segments(tokens)
이 구조의 성질이 중요하다. 구버전 정규식은 항상 같거나 더 넓게 잡으므로, 어떤 입력에서도 신버전이 구버전보다 느슨해질 수 없다. 함정 2에서 겪은 일이 구조적으로 불가능해진다. 이걸 주의력으로 보장하려 하면 검사 항목을 늘릴 때마다 다시 확인해야 하는데, 폴백으로 두면 확인할 필요가 없다.
해결 3. 개행을 명령 구분자로 쓰지 않는다
세 번째 함정은 트레이드오프로 정리했다. 토큰을 세미콜론, 앰퍼샌드, 파이프 기준으로 세그먼트로 나누고, 각 세그먼트의 첫 토큰이 금지 대상 도구일 때만 그 세그먼트를 판정한다. 이러면 함정 1의 오탐이 자연히 사라진다. 첫 토큰이 grep이니까 애초에 판정 대상이 아니다.
개행은 구분자로 쓰지 않고 공백으로 취급한다. heredoc 본문을 명령과 구별할 방법이 없기 때문이다. 개행 뒤에 별도 명령을 붙이는 우회가 이론적으로 가능하지만, 그건 첫 토큰 검사에 걸린다.
검증은 양방향으로 해야 한다
이 계열을 고칠 때 한 방향 테스트만 하면 함정 2를 그대로 반복한다.
- 막아야 하는 것을 막는가 (과통과 검사)
- 구버전이 막던 것을 신버전도 막는가 (느슨해짐 검사)
- 정상 명령을 통과시키는가 (과차단 검사)
두 번째가 빠지기 쉽다. 오탐을 고치는 라운드에서는 "이제 통과한다"가 목표라서 통과가 늘어난 것을 성과로 읽는다. 구버전에서 차단됐던 입력 목록을 따로 보관하고 그 전부가 신버전에서도 차단되는지 확인하는 절차를 넣었다.
실제 셸 동작과 대조하는 것도 필요하다. 함정 2의 입력은 내 파서와 실제 셸이 다르게 읽은 사례였고, 그건 파서 테스트만으로는 드러나지 않는다. 의심되는 입력은 안전한 명령으로 바꿔 직접 실행해보고 셸이 어디까지를 문자열로 보는지 확인했다.
이 글을 쓰는 동안 함정 3이 다시 발생했다
이 글의 초고를 파일로 저장하는 순간 그 가드가 저장을 차단했다. 본문 예시에 실제 금지 명령 문자열이 평문으로 들어 있었고, 앰퍼샌드로 시작하는 세그먼트의 첫 토큰이 금지 도구로 잡혔기 때문이다.
가드는 옳게 동작했다. 개행을 구분자로 삼지 않기로 한 트레이드오프의 반대편 비용이 그대로 나타난 것이다. 문서 본문에 명령 예시를 적는 일이 드물지 않으니, 이 가드를 쓰는 환경에서는 예시를 가상 명령으로 적는 편이 낫다. 이 글의 예시가 전부 deploy --prod인 이유다.
정리
명령 검사 가드를 만든다면 세 가지를 먼저 정하는 게 좋다.
- 파싱은 표준 라이브러리에 위임한다. 손으로 짠 인용 파서는 실제 셸과 반드시 어긋나고, 어긋나는 지점이 곧 우회 경로다
- 파싱 실패 시 무엇을 할지 먼저 정한다. 통과는 우회 수단을 만들고, 구버전 폴백은 느슨해질 수 없음을 구조로 보장한다
- 오탐 수정은 통과 경로 추가로 취급한다. 정교화가 아니라 위험한 변경으로 다루고 양방향 테스트를 붙인다
가장 도움이 된 판단은 세 번째 실패에서 기능을 더 넣지 않기로 한 것이다. 최종 형태는 앞의 두 버전보다 코드가 줄었다. 함정 3개를 다 이해하려는 대신, 이해하지 못하는 상태에서 안전한 쪽으로 떨어지게 만든 결과다.