String.to_atom이 왜 위험한가요?

atom은 GC되지 않아, 외부 입력으로 만들면 한도 1,048,576에 닿는 순간 VM 전체가 죽습니다.

Baryon Labs · 2026-10-23 작성·검증 · 근거: elixir-security-audit 0.1.0 패턴 카탈로그와 테스트 앱

왜 VM 전체가 죽나요?

BEAM에서 atom은 가비지 컬렉션되지 않습니다. atom 테이블 한도는 기본 1,048,576개이고(+t 플래그로 변경), 한도에 닿으면 프로세스 하나가 아니라 VM 전체가 크래시합니다.

요청 하나당 atom 하나만 만들어도, 공격자가 서로 다른 문자열을 계속 보내면 노드 전체가 내려갑니다. 프로세스 격리나 supervisor 재시작으로는 막을 수 없습니다.

항목값
패턴 IDEXS-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 재시작으로는 막을 수 없습니다.

더 보기

elixir-security-audit 설치하기 · EXS 패턴 15종 전체 · GitHub 저장소