String.to_atom이 왜 위험한가요?
atom은 GC되지 않아, 외부 입력으로 만들면 한도 1,048,576에 닿는 순간 VM 전체가 죽습니다.
왜 VM 전체가 죽나요?
BEAM에서 atom은 가비지 컬렉션되지 않습니다. atom 테이블 한도는 기본 1,048,576개이고(+t 플래그로 변경), 한도에 닿으면 프로세스 하나가 아니라 VM 전체가 크래시합니다.
요청 하나당 atom 하나만 만들어도, 공격자가 서로 다른 문자열을 계속 보내면 노드 전체가 내려갑니다. 프로세스 격리나 supervisor 재시작으로는 막을 수 없습니다.
| 항목 | 값 |
|---|---|
| 패턴 ID | EXS-ATOM-001 (동적 atom 생성), EXS-ATOM-002 (JSON atom 키) |
| 기본 심각도 | High |
| atom 테이블 기본 한도 | 1,048,576 (+t로 변경) |
| 위험 함수 | String.to_atom/1, List.to_atom/1, :erlang.binary_to_atom/2, atom 보간 :"user_#{id}", Module.concat/1,2 |
| Sobelow 검사 | DOS.StringToAtom, DOS.ListToAtom, DOS.BinToAtom |
취약한 코드와 안전한 코드
취약: 쿼리 파라미터를 그대로 atom으로 변환
def index(conn, %{"sort" => sort}) do
posts = Blog.list_posts(order_by: String.to_atom(sort))
render(conn, :index, posts: posts)
end
수정 (권장): 허용 목록 맵
@sort_fields %{"title" => :title, "inserted_at" => :inserted_at}
def index(conn, params) do
sort = Map.get(@sort_fields, params["sort"], :inserted_at)
posts = Blog.list_posts(order_by: sort)
render(conn, :index, posts: posts)
end
수정 (차선): String.to_existing_atom/1 + 허용 목록 검사
def parse_status(value) when is_binary(value) do
atom = String.to_existing_atom(value)
if atom in [:draft, :published, :archived], do: {:ok, atom}, else: :error
rescue
ArgumentError -> :error
end
to_existing_atom은 고갈은 막지만 이미 존재하는 모든 atom(모듈명, :__struct__ 등)을 허용하므로 허용 목록 검사를 함께 해야 합니다. Ecto 스키마라면 Ecto.Enum 필드로 캐스팅하는 것도 안전한 대안입니다.
JSON 디코딩도 같은 문제입니다 (EXS-ATOM-002)
Jason.decode(data, keys: :atoms)는 요청 body의 모든 키를 새 atom으로 만듭니다. JSON 키는 공격자가 마음대로 만들 수 있으므로 요청 한 번으로 수천 개 atom을 만들 수 있습니다. keys: :atoms!는 String.to_existing_atom/1을 쓰므로 해당하지 않습니다.
취약
{:ok, payload} = Jason.decode(body, keys: :atoms)
handle_event(payload.type, payload)
수정
{:ok, payload} = Jason.decode(body) # 문자열 키 (기본값)
handle_event(payload["type"], payload)
# atom 키가 꼭 필요하고 키 집합이 코드에 이미 존재할 때
case Jason.decode(body, keys: :atoms!) do
{:ok, payload} -> handle_event(payload.type, payload)
{:error, _} -> {:error, :invalid_payload}
end
테스트 앱에서 이 패턴(lib/vuln_app/importer.ex:6)은 Sobelow가 잡지 못했고 grep/Opengrep만 잡았습니다. 자세한 비교는 Sobelow만으로 충분한가요?에 있습니다.
오탐 신호와 테스트
- 인자가 컴파일 타임 리터럴, 모듈 속성(
@kinds),~w(...)a목록에서만 온다. - 컴파일 타임(매크로,
for로 함수 생성,config/*.exs)에서만 실행된다. - 입력 집합이 구조적으로 유한하다. 단, 사용자가 그 컬럼에 임의 값을 쓸 수 있으면 실제 취약점입니다.
- mix task, 개발 스크립트, 테스트 코드.
테스트 앱의 @default_sort String.to_atom("name")(catalog.ex:11)는 컴파일 타임 리터럴이라 오탐으로 판정해야 하는 예입니다. 수정 후에는 atom 수가 늘지 않는지 테스트로 확인합니다.
# async: false — 다른 테스트가 만드는 atom 과 섞이지 않게
test "unknown sort values do not create atoms", %{conn: conn} do
before = :erlang.system_info(:atom_count)
for i <- 1..500, do: get(conn, ~p"/posts?sort=unknown_#{i}_#{System.unique_integer()}")
assert :erlang.system_info(:atom_count) - before < 50
end
자주 묻는 질문
String.to_existing_atom으로 바꾸면 안전한가요?
고갈은 막지만 이미 존재하는 모든 atom(모듈명, :__struct__ 등)을 허용하므로 허용 목록 검사를 함께 해야 합니다. 모르는 값이면 ArgumentError가 나므로 rescue해서 처리합니다.
keys: :atoms!는 괜찮나요?
keys: :atoms!는 String.to_existing_atom/1을 써서 새 atom을 만들지 않으므로 EXS-ATOM-002에 해당하지 않습니다. 다만 모르는 키가 오면 예외가 날 수 있어, 문자열 키와 Ecto changeset 검증을 우선 권합니다.
supervisor가 재시작하면 되지 않나요?
아니요. atom 테이블은 VM 전체가 공유하므로 한도에 닿으면 VM 전체가 크래시합니다. 프로세스 격리나 supervisor 재시작으로는 막을 수 없습니다.