Built a JavaScript file that plays Tetris, but turns into Snake when you format it
포맷하면 스네이크로 바뀌는 테트리스 JavaScript 파일
JavaScript 포맷터와 난독화 해제 도구가 코드를 읽기 좋게 바꾸는 과정에서 프로그램 동작까지 바꿀 수 있다는 분석입니다. 작성자는 37개 이상의 도구에서 186가지 기법을 찾아, 원본은 테트리스를 실행하고 처리된 코드는 스네이크를 실행하는 시연을 만들었습니다.
- 주제
에디터 노트
포맷된 코드가 정상적으로 실행된다는 사실만으로 원본과 같은 프로그램이라고 판단할 수 없다는 점이 이 분석의 설득력 있는 부분입니다. `+ +m`이 `++m`으로 붙는 사례부터 부작용이 있는 함수 호출을 한 번으로 줄이는 변환까지, 도구가 코드를 읽기 좋게 만드는 과정에서 의미를 보존하지 못하는 구체적인 경로를 보여줍니다. 다만 186가지 기법이 모든 도구 설정에서 언제나 발동한다는 뜻은 아닙니다. 글도 도구별 모드와 설정에 따라 결과가 달라진다고 적습니다. Reddit 댓글은 자동 포맷을 신뢰하는 습관이 위험하다는 반응을 보였지만, 실제 공격 사례나 피해를 확인한 자료를 제시하지는 않습니다. 이 글이 입증하는 범위는 처리 도구가 잘못된 코드로 바꿀 수 있다는 점과 그 사례들입니다.
AI 요약
JavaScript를 분석할 때는 브라우저 개발자 도구(DevTools)나 Prettier 같은 포맷터, 난독화 해제 도구로 코드를 먼저 정리하는 경우가 많습니다. 글은 이 처리 과정이 단순히 코드를 보기 좋게 만드는 데 그치지 않고, 작성자가 의도한 다른 프로그램으로 바꿀 수 있다고 설명합니다. 런타임 검사나 소스맵 조작이 아니라 JavaScript 처리 도구의 분석·변환 오류를 이용합니다. 작성자는 이 기법을 'divergence'라고 부릅니다.
처리 도구가 프로그램을 바꾸는 방식
작성자는 포맷터, AST를 JavaScript로 출력하는 도구, 압축기(minifier), 난독화 해제 도구 등 37개 이상의 처리 도구에서 186가지 기법을 찾았다고 밝힙니다. 대상에는 Chromium과 Firefox DevTools, Prettier, Babel, esbuild, Terser, WebCrack 등이 포함됩니다. 취약한 동작은 도구의 모드와 설정에 따라 달라지므로, 특정 도구에서 발견한 기법이 모든 실행에서 작동하는 것은 아닙니다. 공통 라이브러리의 변환 오류는 그 라이브러리를 쓰는 여러 도구에 이어질 수도 있습니다.
차이는 코드가 바뀌는 범위에 따라 여섯 종류로 나눕니다. 'Rerouted'는 값이나 실행 경로를 바꾸고, 'Rewritten'은 같은 논리처럼 보이는 코드를 다른 구문으로 고칩니다. 'Folded'는 표현식을 잘못된 상수로 계산하며, 'Stripped'는 부작용이 있는 문장이나 호출을 지웁니다. 'Pruned'는 조건 분기 하나를 제거하고, 'Erased'는 함수나 함수 본문을 통째로 비웁니다. 작성자는 바뀌는 코드의 범위가 클수록 심각도가 높다고 분류합니다.
사례: 공백 추가도 동작을 바꿀 수 있습니다
Chromium DevTools는 토큰을 그대로 복사하고 토큰 사이에 공백이나 줄바꿈을 넣는 방식으로 포맷합니다. 하지만 단항 연산자를 처리하는 규칙이 없어 + +m 사이의 공백을 보존하지 않습니다. 결과적으로 코드는 ++m이 되고, 원래 아무 변화도 만들지 않던 표현식이 변수 값을 증가시킵니다. 작성자는 한 줄이 긴 주석을 넣으면 DevTools가 자동 포맷을 시작할 수 있다는 조건도 설명합니다.
WebCrack 사례에서는 getConfig().ready || (getConfig().ready = true)를 getConfig().ready ||= true로 바꿉니다. 변환기는 두 코드의 AST 모양을 비교하지만, getConfig() 호출이 부작용을 일으키는지는 확인하지 않습니다. 원래 코드는 함수를 두 번 호출하고 변환된 코드는 한 번만 호출합니다. 따라서 AST 형태가 비슷하다는 사실만으로 두 코드가 같은 동작을 한다고 판단하면 잘못된 결과가 나옵니다.
Restringer와 WebCrack은 코드에서 atob를 다른 함수로 덮어써도, 내장 함수가 호출된다고 보고 문자열을 미리 계산하는 사례가 나옵니다. new-datadome-deobfuscator는 쉼표 표현식 안의 new Tick()을 부작용 없는 코드로 잘못 판단해 제거합니다. obfuscator-io-deobfuscator는 null을 참으로 분류해 if (null)의 잘못된 분기를 남기고 실제 분기를 삭제합니다. Terser는 0 in 1이 실행 중 TypeError를 발생시키는 상황을 부작용 없는 표현식으로 취급해 제거합니다. 원래는 예외 처리로 넘어가던 코드가 압축 후에는 정상 경로로 진행합니다.
분석과 재구현에 미치는 영향
이 기법은 분석을 막기보다 분석자가 다른 프로그램을 분석하게 만드는 미끼를 만듭니다. 포맷된 코드가 여전히 정상 작동하면 분석자는 처리가 코드를 바꿨다는 사실을 알아차리기 어렵습니다. 작성자는 사람이든 LLM 에이전트든 복잡한 JavaScript를 분석할 때 포맷이나 디옵퍼스케이션을 먼저 하는 경우가 많다고 지적합니다. 또 클라이언트 코드 동작을 바탕으로 서버가 검증하는 서비스에서는 분석한 코드와 실제 코드가 다른 입력을 만들 수 있고, 서버가 그 차이를 감지할 가능성도 제시합니다.
시연 파일은 여러 기법을 조합해 작은 자체 가상 머신에 전달하는 바이트코드를 바꿉니다. 원본은 테트리스를 실행하지만 JavaScript 처리 도구를 거치면 스네이크 바이트코드가 남고 테트리스 코드는 사라집니다. 작성자는 CSS에서도 비슷한 개념이 작동한다고 덧붙입니다.
Reddit 반응
- @u/QuanTradin — 그러니까 포맷터가 공격 수단이라는 거군요. 저장할 때마다 자동으로 실행하는 도구에서 이런 일이 벌어진다니 끔찍하게 배웠습니다. 잘 만들었네요.
- @u/Ill_Leader_7104 — 사람들이 별생각 없이 자동으로 실행하는 도구라는 점이 정말 섬뜩하네요.
- @u/Tarantino133 — 포맷한 뒤 테트리스가 스네이크로 바뀌는 시연이 정말 좋네요. 포맷된 버전을 검토하고 믿게 된다면 조금 무섭기도 합니다.
원문: TrustSig / 번역·요약: Trawling