빠르게 움직이는 블록체인과 밈 토큰 제작의 세계에서는 보안 위협을 주시하는 것이 매우 중요합니다. 어제인 2025년 9월 8일, JavaScript 커뮤니티는 Node Package Manager(NPM)에서 발생한 대규모 공급망 공격 중 하나로 충격을 받았습니다. 공격자들은 피싱을 통해 기여자 계정을 탈취하고 18개의 널리 사용되는 패키지에 악성 버전을 배포했으며, 지갑 트랜잭션을 탈취하도록 설계된 cryptostealer 맬웨어를 주입했습니다. 이는 단순한 웹 개발 이슈가 아니라 암호화폐 애호가들에게 직접적인 타격입니다. 해당 맬웨어는 Bitcoin, Ethereum, Solana 같은 암호화폐를 목표로 하여 자금을 공격자 제어 주소로 우회시킵니다.
공격 이해하기
이번 침해는 JS 생태계의 핵심 패키지들에 영향을 미쳤습니다. 색상 있는 콘솔 로그부터 ANSI 문자열 처리에 이르기까지 다양한 용도에 쓰이는 패키지들입니다. Aikido Security와 Semgrep의 보안 보고서에 따르면, 손상된 버전에는 fetch나 XMLHttpRequest 같은 브라우저 함수를 후킹하는 난독화된 JavaScript 코드가 포함되어 있었습니다. 이 코드는 지갑 상호작용을 탐지하고, 합법적인 주소를 유사 주소로 바꾸며, 서명 직전에 트랜잭션 파라미터를 변경하는 등 사용자 인터페이스는 정상으로 보이게 하면서 은밀하게 동작합니다.
다음은 영향을 받은 패키지와 손상된 버전 목록입니다:
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
- [email protected]
이들 패키지는 매주 수십억 건의 다운로드를 기록하며 수많은 프로젝트의 의존성 트리에 숨어 있을 가능성이 큽니다. 블록체인 실무자에게는 더욱 우려스러운 상황입니다. 밈 토큰 출시 과정은 종종 빠르게 구축된 프론트엔드, DEX 인터페이스, 또는 React나 Node.js 같은 도구로 만든 봇을 포함하는데, 이러한 유틸리티들이 공격에 취약한 패키지를 끌어오면 문제가 발생할 수 있습니다. 만약 여러분의 dApp이나 토큰 스나이핑 스크립트가 이들 중 하나에 의존하고 있다면, 사용자나 개발자 지갑이 무심코 자금 유출에 노출될 수 있습니다.
다행히 커뮤니티는 신속히 대응했습니다. 악성 버전은 많은 다운로드가 발생하기 전에 NPM에서 제거되어 피해는 제한적이었습니다. 하지만 최근에 패키지를 설치했다면 검사를 실행할 때입니다.
Edgar Pavlovsky의 시기적절한 스크립트
convexity 전문가이자 Dark Research AI, Paladin Solana 등 프로젝트 기여자인 Edgar Pavlovsky는 빠르게 확산된 트윗 스레드에서 package.json 의존성 트리를 스캔해 이 손상된 패키지들을 찾아내는 bash 스크립트를 공유했습니다. 이 도구는 알려진 악성 버전을 검사할 뿐만 아니라 node_modules 내의 난독화된 맬웨어 패턴도 탐지합니다.
Edgar는 곧 스크립트를 업데이트해 교묘한 패턴 탐지 기능을 강화했습니다. 그의 GitHub Gist에서 스크립트를 확인할 수 있습니다. 아래는 참조를 위한 전체 스크립트입니다:
bash
#!/usr/bin/env bash
check-deps-with-comp.sh
set -euo pipefail
[[ "${DEBUG:-0}" == "1" ]] && set -x
TOOL="${TOOL:-npm}"
Watchlist lines can be: "name" or "name<tab/space>compromised_version"
WATCHLIST="$(cat <<'EOF'
backslash 0.2.1
chalk-template 1.1.1
supports-hyperlinks 4.1.1
has-ansi 6.0.1
simple-swizzle 0.2.3
color-string 2.1.1
error-ex 1.3.3
color-name 2.0.1
is-arrayish 0.3.3
slice-ansi 7.1.1
color-convert 3.1.1
wrap-ansi 9.0.1
ansi-regex 6.2.1
supports-color 10.2.1
strip-ansi 7.1.1
chalk 5.6.1
debug 4.4.2
ansi-styles 6.2.2
EOF
)"
command -v jq >/dev/null || { echo "[x] jq not found"; exit 1; }
[[ -f package.json ]] || { echo "[x] No package.json here"; exit 1; }
TMPDIR="$(mktemp -d)"
DEPS_JSON="$TMPDIR/deps.json"
MAP_JSON="$TMPDIR/name_map.json"
echo "[] Collecting dependency tree."
if [[ "$TOOL" == "bun" ]]; then
bun pm ls --json > "$DEPS_JSON"
else
npm ls --all --json > "$DEPS_JSON" || true
fi
echo "[] Building name→{versions,roots} map…"
jq '
def walkdeps(root):
(.dependencies // {}) | to_entries[] as $e
| ($e.value | {name:$e.key, version:(.version // ""), root:(root // $e.key)})
, ($e.value | walkdeps(root // $e.key));
reduce (walkdeps(null)) as $n
({};
if ($n.version|type)=="string" and ($n.version|length)>0 then
.[$n.name] |= ( . // {versions:[], roots:[]} ) |
.[$n.name].versions += [$n.version] |
.[$n.name].roots += [$n.root]
else . end
)
| with_entries(
.value.versions |= (unique|sort) |
.value.roots |= (unique|sort)
)
' "$DEPS_JSON" > "$MAP_JSON"
if [[ "$(jq 'length' "$MAP_JSON")" -eq 0 ]]; then
echo "[!] Dependency map is empty. Did you run npm/bun install?"
fi
echo "[*] Checking watchlist (any version)…"
Collect all table data first to calculate column widths
declare -a table_data=()
declare -a col1_data=() col2_data=() col3_data=() col4_data=() col5_data=()
Add header row
col1_data+=("package")
col2_data+=("compromised version")
col3_data+=("present?")
col4_data+=("versions found")
col5_data+=("matches compromised?")
found_any=false
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name = first field, compromised = second field if present
name="$(awk '{print $1}' <<<"$line")"
compromised="$(awk 'NF>1{print $2}' <<<"$line")"
[[ -z "${compromised:-}" ]] && compromised="-"
has=$(jq -r --arg n "$name" 'has($n)' "$MAP_JSON")
if [[ "$has" == "true" ]]; then
versions_csv=$(jq -r --arg n "$name" '.[$n].versions | join(", ")' "$MAP_JSON")
match="-"
if [[ "$compromised" != "-" ]]; then
exact version match?
matched=$(jq -r --arg n "$name" --arg v "$compromised" '
(.[$n].versions // []) | index($v) | if .==null then "no" else "yes" end
' "$MAP_JSON")
match="$matched"
fi
if [[ "$match" == "yes" ]]; then found_any=true; fi
else
versions_csv="-"
match="-"
fi
col1_data+=("$name")
col2_data+=("$compromised")
col3_data+=("$has")
col4_data+=("$versions_csv")
col5_data+=("$match")
done <<<"$WATCHLIST"
Calculate max widths
max_c1=0 max_c2=0 max_c3=0 max_c4=0 max_c5=0
for ((i=0; i<${#col1_data[@]}; i++)); do
(( ${#col1_data[i]} > max_c1 )) && max_c1=${#col1_data[i]}
(( ${#col2_data[i]} > max_c2 )) && max_c2=${#col2_data[i]}
(( ${#col3_data[i]} > max_c3 )) && max_c3=${#col3_data[i]}
(( ${#col4_data[i]} > max_c4 )) && max_c4=${#col4_data[i]}
(( ${#col5_data[i]} > max_c5 )) && max_c5=${#col5_data[i]}
done
Print table
for ((i=0; i<${#col1_data[@]}; i++)); do
printf "%-*s | %-*s | %-*s | %-*s | %-s\n"
$max_c1 "${col1_data[i]}"
$max_c2 "${col2_data[i]}"
$max_c3 "${col3_data[i]}"
$max_c4 "${col4_data[i]}"
$max_c5 "${col5_data[i]}"
done
echo "[] Checking for malware patterns in node_modules..."
Pattern 1: obfuscated hex string
PATTERN1='0x[0-9a-f]{2000,}'
found_p1="$(grep -rEl "$PATTERN1" node_modules 2>/dev/null || true)"
Pattern 2: base64 string with specific length
PATTERN2='(aHR0cDovLzE5Mi4xNjguNTUuMjE2Ojg4ODgvY29tbWFuZC5waHA/Y29tbWFuZD0=)'
found_p2="$(grep -rEl "$PATTERN2" node_modules 2>/dev/null || true)"
if [[ -n "$found_p1" || -n "$found_p2" ]]; then
found_any=true
echo "[!] Malware patterns found:"
[[ -n "$found_p1" ]] && echo "Pattern 1 (long hex):" && echo "$found_p1"
[[ -n "$found_p2" ]] && echo "Pattern 2 (base64 cmd):" && echo "$found_p2"
else
echo "[] No malware patterns found."
fi
rm -rf "$TMPDIR"
if [[ "$found_any" == "true" ]]; then
echo "[!] Found compromised deps or malware patterns. Remove node_modules/, reinstall, or update to safe versions."
exit 1
else
echo "[] No issues found."
fi
사용 방법은 프로젝트 디렉터리로 이동해 스크립트를 저장(예: check-deps.sh), chmod +x check-deps.sh
로 실행 권한을 부여한 뒤 ./check-deps.sh
를 실행하면 됩니다. 이 스크립트는 jq가 설치되어 있어야 하며 npm 또는 bun과 함께 작동합니다. 만약 어떤 항목이 표시된다면, node_modules를 완전히 삭제하고 깨끗한 lockfile에서 다시 설치하세요.
밈 토큰 제작자에게 왜 중요한가
밈 토큰은 과열과 속도를 기반으로 번성하지만, 이는 종종 오픈소스 라이브러리에서 코드를 모아 빠르게 조립하는 것을 의미합니다. Solana의 SPL 토큰 생성기나 Ethereum 프론트엔드 같은 도구들은 UI 요소나 로깅을 위해 종종 이 NPM 패키지들에 의존합니다. 하나의 손상된 의존성이 여러분의 펌프를 러그풀 악몽으로 바꿔, 출시 중 개발자 지갑이나 사용자 자금을 고갈시킬 수 있습니다.
이번 사건은 공급망 보안의 위험을 다시금 강조합니다—이는 web3에서 뜨거운 주제이며, Ledger Connect Kit 해킹 같은 공격 사례는 하나의 약한 고리가 어떻게 연쇄적으로 영향을 미칠 수 있는지를 보여줍니다. 블록체인 실무자라면 일관된 설치를 위해 npm ci
를 사용하고, package-lock.json에 버전을 고정하며, Snyk 같은 도구나 본 스크립트로 정기적으로 스캔하는 등의 관행을 도입하는 것이 큰 차이를 만듭니다.
경계를 늦추지 마세요. 밈 토큰 게임에서 지식은 곰(bears)과 해커들로부터 자신을 지키는 최고의 방어입니다. 멋진 것을 만들고 있다면 Edgar의 스레드에 코멘트를 남기거나 커뮤니티에 보안 팁을 공유하세요.