Solana 핫월렛 개인키 탈취 취약점 (Ed25519 Shared-R) 분석

세인·2026년 2월 5일
post-thumbnail

Disclaimer: 이 글은 특정 거래소나 특정 사건을 지칭하거나 원인을 단정하기 위한 목적이 아닙니다. 공개된 암호 서명 구조에서 발생 가능한 논스 재사용 취약점 클래스를 기술적으로 설명하고, 방어 및 검증 관점의 시사점을 정리합니다. 본문에는 재현 가능성을 과도하게 높이는 세부정보(식별 가능한 계정/트랜잭션/원문 데이터/즉시 악용 가능한 스크립트 등)를 포함하지 않으며, 모든 실험은 통제된 테스트 환경에서만 수행했습니다.

이 글의 출발점이 된 분석 방향과, 작성 과정 전반에 도움을 주신 이수현 박사님께 감사드립니다.

1. 사건 개요

2025년, 거래소 A의 솔라나(Solana) 핫월렛에서 수백억 원 규모의 암호화폐가 외부로 유출되는 사건이 발생하였습니다. 다수의 솔라나 기반 토큰(백억대 이상 추정)이 피해 대상이었으며, 이후 서비스가 재개되었습니다. 관련하여 개인키 노출의 가능성이 언급되었습니다.

최신 연구(Jacquot and Donnet, ""Short Paper: Oops...I Did It Again. I Reused my Nonce." will appear in Financial Cryptography and Data Security 26 [link]) 에 따르면, 비트코인과 이더리움을 포함한 6개의 체인에서 논스 재사용으로만 3620개의 개인키가 노출이 되었으며, 101 M 유로에 해당하는 자산이 위험에 노출이 되었다고 분석하였습니다. 이 글에서는 해당 연구의 Section 5.2 에 해당하는 서로 다른 키로 같은 논스를 재사용했을때 개인키가 노출이 가능한 논스 재사용 취약점의 기술적 원리를 솔라나의 맥락에서 설명하고, Solana devnet에서 이를 재현한 결과를 공유합니다.


2. 배경 지식

2.1 타원곡선 전자서명

블록체인에서 "이 트랜잭션을 보낸 사람이 정말 해당 지갑의 소유자인가"를 증명하는 데 전자서명이 사용됩니다. 현재 주요 블록체인들은 타원곡선 암호(ECC, Elliptic Curve Cryptography)라는 수학적 구조에 기반한 전자서명을 사용하고 있습니다.

타원곡선 암호의 핵심 원리는 간단합니다. 타원곡선 위의 정해진 점 G에 비밀키 숫자 a를 곱하면 공개키 A가 나옵니다.

A=aGA = a \cdot G

a를 알면 A를 구하는 것은 쉽지만, A만 보고 a를 거꾸로 알아내는 것은 현재 컴퓨터로는 사실상 불가능합니다. 이 한쪽 방향으로만 계산이 쉬운 성질(일방향성)이 전자서명 보안의 핵심입니다.


타원곡선을 사용한 전자서명의 목표는 비밀키 a를 공개하지 않으면서, 내가 a를 알고 있다는 것을 증명하는 것입니다. 이를 위해 서명할 때마다 임시 난수 r를 새로 생성하여 비밀키 a와 함께 방정식에 넣습니다.

이 난수값 r을 통해 서명값이 공개되더라도 하나의 방정식에 미지수가 a와 r 두 개이므로, 하나의 서명만으로는 a를 역산할 수 없게 됩니다.

타원곡선 전자서명의 일반적인 구조는 다음과 같습니다.

서명 (Sign)

  1. 임시 난수 rr를 생성한다.

  2. R=rGR = r \cdot G 를 계산한다. (난수에 대응하는 타원곡선 위의 점)

  3. 메시지 MM의 해시값, 비밀키 aa, 난수 rr를 하나의 방정식에 넣어 SS를 계산한다.

    예: S=r+H(R,A,M)aS = r + H(R, A, M) \cdot a

  4. (R,S)(R, S) 쌍을 서명값으로 공개한다.

검증 (Verify)

서명 과정에서 S=r+H(R,A,M)aS = r + H(R, A, M) \cdot a 로 계산되었으므로, 양변에 G를 곱하면:

SG=rG+H(R,A,M)aG=R+H(R,A,M)AS \cdot G = r \cdot G + H(R, A, M) \cdot a \cdot G = R + H(R, A, M) \cdot A

검증자는 SGS \cdot GR+H(R,A,M)AR + H(R, A, M) \cdot A가 일치하는지 확인합니다.

이 식의 우변은 서명값 RR, 공개키 AA, 메시지 MM만으로 계산할 수 있으므로, 비밀키 aa를 모르더라도 "aa를 아는 사람이 만든 서명인지" 판별할 수 있습니다.

이렇게 타원곡선 전자서명은 서명/검증 구조를 공유하지만, 난수를 생성하는 방식에 따라 서명 방식이 나뉩니다.


ECDSA (비트코인, 이더리움 등)

r ← 외부 난수 생성기 (OS의 /dev/urandom 등)
R = r * G
S = (H(M) + R.x * a) / r   (mod n)

난수 r을 서명할 때마다 외부에서 새로 뽑아옵니다. 장점으로는 매번 다른 난수가 나온다는 것이고, 단점은 난수 생성기가 고장나거나 품질이 나빠서 같은 r이 두 번 나오면 두 서명의 S값 차이로 비밀키 a를 역산할 수 있다는 것입니다. 즉 서명의 보안이 난수 생성기의 품질에 의존한다는 점이 핵심입니다.


Ed25519 (솔라나, SSH, TLS 등)

nonce_prefix ← SHA-512(seed)의 뒤 32바이트   (비밀키에서 파생된 고정값)
r = SHA-512(nonce_prefix || M)               (고정값 + 메시지를 해시하여 난수 생성)
R = r * G
S = r + H(R, A, M) * a   (mod L)

난수 r을 외부에서 뽑지 않고, 비밀키의 일부인 nonce_prefix와 메시지 M을 합쳐서 해시 연산을 한 결과를 난수로 사용합니다. nonce_prefix는 비밀키에서 한 번 만들어지면 바뀌지 않는 고정값이고, 메시지가 달라지면 해시 결과도 달라지므로 매번 다른 난수가 나옵니다. 외부 난수 생성기에 의존하지 않으니 생성기 품질 문제는 없지만, 대신 nonce_prefix가 서명자마다 고유해야 합니다. 만약 두 서명자가 같은 nonce_prefix를 갖고 있으면, 같은 메시지를 서명할 때 같은 난수 r이 나오게 됩니다.

2.2 Ed25519의 비밀키 구조

Ed25519에서는 하나의 비밀키(seed, 32바이트)를 SHA-512 해시 함수에 넣어 64바이트를 만들고, 이를 반으로 나누어 사용합니다.

SHA-512(seed) → 64바이트

앞 32바이트 → scalar (a)       : 실제 비밀키 숫자. 서명 계산에 사용됩니다.
뒤 32바이트 → nonce prefix     : 서명할 때 난수를 만드는 씨앗값입니다.

scalar a는 서명의 수학적 연산에 직접 쓰이는 비밀 숫자이고, nonce prefix는 ECDSA에서 운영체제 난수 생성기가 하던 역할을 대신합니다. 다시 말해, 난수를 비밀키에서 만들어내는 것이 Ed25519의 핵심 설계입니다.

2.3 서명 (R, S)이 만들어지는 과정

Ed25519에서 메시지 M(예: 트랜잭션 데이터)을 서명하면, (R, S) 두 값의 쌍이 만들어집니다.

r = SHA-512(nonce_prefix || M)   ... 난수(nonce) 생성
R = r * G                        ... 난수에 대응하는 타원곡선 위의 점
S = r + H(R, A, M) * a           ... 비밀키 a가 포함된 계산 결과

각 값의 의미를 정리하면:

기호설명공개 여부
r난수(nonce). nonce_prefix와 메시지 M을 합쳐 해시한 결과비공개 (계산 중간값)
R난수 r에 대응하는 타원곡선 위의 점공개 (서명에 포함)
a비밀키 scalar비공개 (보호 대상)
S비밀키 a가 포함된 계산 결과공개 (서명에 포함)
H(R, A, M)R, 공개키 A, 메시지 M을 합쳐 해시한 값누구나 계산 가능
G모든 사람이 알고 있는 고정된 기준점(base point)공개 (상수)

여기서 중요한 점은, nonce_prefix가 서명자마다 다르면 같은 메시지를 서명하더라도 r이 달라지고, 따라서 R(=rG)R(=r*G)도 달라진다는 것입니다. 서명자마다 고유한 nonce_prefix를 갖는 것이 Ed25519의 보안을 지탱하는 핵심 속성입니다.

솔라나에서는 R(32바이트)과 S(32바이트)를 이어 붙인 64바이트 값을 서명값으로 공개합니다.

2.4 Shared-R 이 위험한 이유

만약 두 서명자가 동일한 nonce_prefix를 가지고 있다면 어떻게 될까요?

두 서명자가 같은 트랜잭션(같은 메시지 MM)을 서명하는 상황을 생각해 봅시다.

signer A:rA=SHA-512(prefixAM)RA=rAGsigner B:rB=SHA-512(prefixBM)RB=rBG\text{signer A} : r_A = \text{SHA-512}(\text{prefix}_A || M)\quad R_A = r_A * G \\ \text{signer B} : r_B = \text{SHA-512}(\text{prefix}_B || M) \quad R_B = r_B * G

nonce_prefix가 서명자마다 다르면 rArBr_A ≠ r_B이므로 서명에 포함되는 공개키 값인 RRRRARBR_A ≠ R_B가 됩니다.

그런데 prefixA=prefixB\text{prefix}_A = \text{prefix}_B이면, 같은 메시지 MM에 대해 rA=rBr_A = r_B가 되고, 결과적으로 RA=RBR_A = R_B가 됩니다.

이처럼 서로 다른 두 서명자의 RR이 동일해지는 현상을 Shared-R이라고 합니다. RRSS는 블록체인에 공개되어 있으므로, 이 현상이 발생하면 두 서명의 SS값 차이를 이용해 비밀키를 역산할 수 있는 연립방정식이 성립하게 됩니다(구체적인 수식은 4장에서 다룹니다).

2.5 Shared-R의 원인: 키 파생 구현 차이

솔라나 지갑들은 보통 하나의 마스터 시드(master seed)에서 여러 개의 자식 키를 파생합니다. 이를 HD(Hierarchical Deterministic) 키 파생이라 하며, 하나의 시드만 백업하면 모든 자식 키를 복원할 수 있습니다. 솔라나에서는 이 과정에 SLIP-0010이라는 표준이 사용됩니다.

SLIP-0010 스펙은 Ed25519 자식 키 파생에 대해 다음과 같이 정의하고 있습니다.

"the returned child key kik_i is ILI_L"
: SLIP-0010, Private parent key → private child key

여기서 kik_i는 파생된 자식 키이고, ILI_L은 SHA512 연산 결과 64바이트 중 왼쪽 32바이트입니다.즉, 자식 키 32바이트를 어떻게 만드는지만 정의되어 있습니다.

그런데 2.2절에서 본 것처럼 Ed25519 서명에는 scalar(32바이트)과 nonce_prefix(32바이트), 총 64바이트가 필요합니다. 따라서 자식 키 32바이트를 다시 SHA-512에 넣어 64바이트로 확장하는 과정이 필요합니다. 이 과정을 재확장 이라고 합니다.


정상적인 구현에서는 재확장을 수행하여 자식마다 고유한 nonce_prefix를 만듭니다.

정상:  child_key → SHA-512(child_key) → [새 scalar | 새 prefix]  (자식마다 고유)

그런데 만약 재확장을 생략하고, 마스터 시드에서 만든 prefix를 모든 자식 키에 그대로 쓴다면:

비정상: master_seed → SHA-512 → [master_scalar | master_prefix]
        child_0: [child_scalar_0 | master_prefix]    ← 같은 prefix
        child_1: [child_scalar_1 | master_prefix]    ← 같은 prefix

모든 자식이 같은 prefix를 공유하게 되고, 같은 메시지를 서명하면 R이 동일해집니다.


이러한 재확장은 스펙에서 별도로 요구하는 절차가 아니기 때문에, 커스텀 구현에서 키 파생 과정을 최적화하거나 단순화하는 과정에서 이 단계를 생략하면 Shared-R 취약점에 노출될 수 있습니다.

2.6 관련 취약점

이와 비슷한 취약점 패턴은 이미 여러 곳에서 보고 및 등록된 바 있습니다.

출처내용
CVE-2022-50237 / RUSTSEC-2022-0093ed25519-dalek 라이브러리(Rust)에서 scalar과 nonce_prefix를 분리 조작할 수 있는 문제. 두 번의 서명으로 비밀키 추출 가능
orlp/ed25519 Issue #3ed25519_add_scalar 함수가 nonce prefix를 갱신하지 않아 R이 재사용되는 문제
MystenLabs/ed25519-unsafe-libs40개 이상의 Ed25519 라이브러리에서 동일한 취약점 클래스를 카탈로그화

3. 비밀키 추출 방법

Shared-R이 존재하는 두 건의 트랜잭션이 있으면, 다음과 같은 과정으로 비밀키를 추출할 수 있습니다.

3.1 서명 방정식

2장에서 설명한 서명 공식 S=r+H(R,A,M)aS = r + H(R, A, M) \cdot a를 두 서명자(signer 0, signer 1), 두 트랜잭션(TX1, TX2)에 적용하면:

TX1:s01=r1+h01a0(modL)(1)TX1:s11=r1+h11a1(modL)(2)TX2:s02=r2+h02a0(modL)(3)TX2:s12=r2+h12a1(modL)(4)\text{TX}1: \quad s_{01} = r_1 + h_{01} \cdot a_0 \pmod{L} \quad \cdots (1) \\ \phantom{\text{TX}1:} \quad s_{11} = r_1 + h_{11} \cdot a_1 \pmod{L} \quad \cdots (2) \\ \text{TX}2: \quad s_{02} = r_2 + h_{02} \cdot a_0 \pmod{L} \quad \cdots (3) \\ \phantom{\text{TX}2:} \quad s_{12} = r_2 + h_{12} \cdot a_1 \pmod{L} \quad \cdots (4)
  • sijs_{ij} : 서명의 S값 (온체인에 공개)
  • rjr_j : nonce (Shared-R이므로 같은 TX 내 두 서명자가 동일)
  • hij=SHA-512(RAiMj)modLh_{ij} = \text{SHA-512}(R \mathbin\| A_i \mathbin\| M_j) \mod L : challenge hash (온체인 데이터로 계산 가능)
  • aia_i : 비밀키 scalar (추출 대상)
  • LL : Ed25519 군(group)의 차수 (고정 상수)

3.2 nonce 소거

(1)-(2), (3)-(4)로 nonce rr을 소거하면:

d1=s01s11=h01a0h11a1(modL)d2=s02s12=h02a0h12a1(modL)d_1 = s_{01} - s_{11} = h_{01} \cdot a_0 - h_{11} \cdot a_1 \pmod{L} \\ d_2 = s_{02} - s_{12} = h_{02} \cdot a_0 - h_{12} \cdot a_1 \pmod{L}

미지수가 a0a_0, a1a_1 두 개이고 방정식이 두 개이므로, 연립방정식으로 풀 수 있습니다.

3.3 Cramer's Rule 적용

행렬로 정리하면:

(h01h11h02h12)(a0a1)=(d1d2)det=h01(h12)(h11)h02(modL)\begin{pmatrix} h_{01} & -h_{11} \\ h_{02} & -h_{12} \end{pmatrix} \begin{pmatrix} a_0 \\ a_1 \end{pmatrix} = \begin{pmatrix} d_1 \\ d_2 \end{pmatrix} \det = h_{01} \cdot (-h_{12}) - (-h_{11}) \cdot h_{02} \pmod{L} \\
a0=d1(h12)(h11)d2det(modL)a1=h01d2d1h02det(modL)a_0 = \frac{d_1 \cdot (-h_{12}) - (-h_{11}) \cdot d_2}{\det} \pmod{L} \quad a_1 = \frac{h_{01} \cdot d_2 - d_1 \cdot h_{02}}{\det} \pmod{L}

모든 입력값 (ss, hh, dd)은 온체인에 공개된 데이터로부터 계산할 수 있습니다.

따라서 비밀키 scalar a0a_0, a1a_1이 완전히 추출됩니다.

3.4 검증

추출된 scalar로 공개키를 재계산하여 원본과 비교합니다.

aG=A(Ed25519 base point multiplication)a \cdot G = A \quad \text{(Ed25519 base point multiplication)}

일치하면 비밀키 추출이 성공한 것입니다.


4. Devnet 재현

위 취약점이 실제로 동작하는지 확인하기 위해 Solana devnet에서 동일한 조건을 재현하였습니다.

4.1 재현 절차

  1. 랜덤 마스터 시드에서 SLIP-0010으로 2개의 자식 키를 파생
  2. 자식 키의 scalar는 정상 파생하되, nonce prefix는 마스터의 것을 재사용 (취약 구현 재현)
  3. 두 서명자가 같은 트랜잭션 메시지를 서명하여 R이 동일해지는 것을 확인
  4. 2건의 트랜잭션을 devnet에 실제 제출
  5. 온체인에 공개된 데이터만으로 Cramer's Rule을 적용하여 비밀키 추출
  6. 추출된 비밀키로 공개키를 재계산하여 원본과 일치 여부 확인

4.2 실행 결과

========================================================================
  Shared-R PoC — Ed25519 nonce reuse 취약점 재현 (DEVNET)
========================================================================

[Phase 1] 취약 키쌍 생성 (SLIP-0010 + master prefix 재사용)
  Signer 0: GxKGiE5sGmgAs3QrLFEkrmwYtMV8crxqq5Zo2ZaqAe4J
  Signer 1: EXrP8x4UWSvWZJ9GjhumL21bodu2qz69D6ZC6nr34bQ1
  Shared prefix (hex): 51338c9a28c92c134555dbfc913a634a...
  키 생성 검증: OK

[Phase 3] 트랜잭션 1 생성 및 제출
  Sig 0 R: affb089cfd802e72b1d4d04373a8cefd...
  Sig 1 R: affb089cfd802e72b1d4d04373a8cefd...
  Shared-R: YES
  TX1 제출 완료

[Phase 4] 트랜잭션 2 생성 및 제출
  Sig 0 R: bee13c44cbb4b9f044cc990080ce375b...
  Sig 1 R: bee13c44cbb4b9f044cc990080ce375b...
  Shared-R: YES
  TX2 제출 완료

[Phase 5] 온체인 데이터 조회
  TX1 Shared-R 온체인 확인: YES
  TX2 Shared-R 온체인 확인: YES

[Phase 6] 비밀키 추출 (Cramer's rule — 온체인 데이터만 사용)
  입력: TX1/TX2의 서명(R, S), 메시지, 공개키 (모두 온체인 공개)
  추출된 Scalar 0 (hex LE): a0cf256a02afea8cce5ad165f20502ab...
  추출된 Scalar 1 (hex LE): c0003bf2194765209bf14844564c25b1...

4.3 검증 결과 요약

검증 항목결과
TX1 내 Signer 0의 R이 Signer 1의 R (Shared-R)과 일치하는가?YES
TX2 내 Signer 0의 R이 Signer 1의 R (Shared-R)과 일치하는가?YES
TX1 Signer 0의 R이 TX2 Signer 0의 R과 일치하는가?NO (다른 메세지)
추출된 scalar * G 연산 결과는 Signer 0의 공개키와 일치하는가?YES
추출된 scalar * G 연산 결과는 Signer 1의 공개키와 일치하는가?YES
TX1 nonce 복원 시 두 서명자에서 동일한 r이 계산되는가?YES
TX2 nonce 복원 시 두 서명자에서 동일한 r이 계산되는가?YES
추출된 scalar과 원래 scalar를 직접 비교하였을 때 일치하는가?YES

4.4 온체인 증거

다음 Solana devnet 트랜잭션에서 Shared-R을 직접 확인할 수 있습니다.

서명자 주소:

역할주소
Signer 0GxKGiE5sGmgAs3QrLFEkrmwYtMV8crxqq5Zo2ZaqAe4J
Signer 1EXrP8x4UWSvWZJ9GjhumL21bodu2qz69D6ZC6nr34bQ1

트랜잭션:

서명의 raw 바이트를 디코딩하면 앞 32바이트에서 R값을 추출할 수 있습니다.

solscan에서 첫번째 TX의 raw 바이트에서 signatures 부분을 보면 트랜잭션에 쓰인 두 서명의 앞부분(R값을 base58로 암호화한 부분)이 공통된 모습을 확인할 수 있습니다.

우리는 다음과 같이 두 트랜잭션의 공개 데이터만으로 두 서명자의 비밀키를 추출할 수 있었습니다.


5. 참고 자료

자료링크
RFC 8032 (Ed25519 스펙)https://datatracker.ietf.org/doc/html/rfc8032#section-5.1
SLIP-0010 (HD 키 파생 표준)https://github.com/satoshilabs/slips/blob/master/slip-0010.md
CVE-2022-50237 (NVD)https://nvd.nist.gov/vuln/detail/CVE-2022-50237
RUSTSEC-2022-0093 (Rust 보안 권고)https://rustsec.org/advisories/RUSTSEC-2022-0093
ed25519-unsafe-libs (취약 라이브러리 카탈로그)https://github.com/MystenLabs/ed25519-unsafe-libs
orlp/ed25519 Issue #3https://github.com/orlp/ed25519/issues/3
Double Public Key Signing Attack 논문https://arxiv.org/abs/2308.1500
Jacquot and Donnet, "Short Paper: Oops...I Did It Again. I Reused my Nonce." (FC 2026)https://fc26.ifca.ai/preproceedings/55.pdf
profile
세종과학기지 세인지부

0개의 댓글