TeamViewer 처럼 ID 와 비밀번호로 붙고, 화면을 받아 보고, 키보드와 마우스를 보내는 프로그램을 만든다. 이름은 GenealogyRemoteControl 이다. 소프트웨어 버전 v1.0.0.0 에 해당하는 기록이며, 이 회차에서는 화면 전송과 입력 주입을 아직 다루지 않는다.
목적
원격제어 프로그램을 처음부터 만든다. 코드를 쓰기 전에 되돌릴 수 없는 결정부터 확정하는 것이 이번 회차의 목적이다. 화면 전송 속도는 나중에 올릴 수 있지만, 암호화 계층과 운영체제 추상화는 나중에 넣을 수 없다.
계획
- 요구사항 네 줄을 조건 표로 정리
- 언어와 런타임 선정
- 폴더 구조와 판올림 규칙 확정
- 인증 방식 설계
- 암호 설계
- 운영체제 호환성 경계 설계
- 통신 포트 방침 결정
- grc-core 구현과 시험
체크리스트
| 항목 | 상태 | 결과 | 본문 |
|---|---|---|---|
| 요구사항 정리 | 완료 | 조건 4개 확정. 1순위 보안, 2순위 운영체제 호환성 | 1절 |
| 언어 선정 | 완료 | 후보 4개 비교 후 Rust 채택 | 2절 |
| 판올림 규칙 | 완료 | 네 자리 버전, 폴더 통째 복사로 상속 | 3절 |
| 인증 방식 | 완료 | SPAKE2. 비밀번호가 회선에 실리지 않음 | 4절 |
| 암호 설계 | 완료 | ChaCha20-Poly1305, 방향별 키 분리, 시퀀스 nonce | 4절 |
| 호환성 경계 | 완료 | 트레이트 2개로 격리. 호스트는 Windows 고정 | 5절 |
| 포트 방침 | 완료 | 443 기본, 80 폴백. 요구사항의 80번 단독은 철회 | 6절 |
| grc-core 구현 | 완료 | 6파일 645줄 | 7절 |
| 시험 | 완료 | 9건 통과. 정상 경로 1개, 공격 경로 4개 포함 | 8절 |
| Windows 실기 빌드 | 미착수 | 이번 회차 코드는 리눅스에서만 컴파일 | 9절 |
| 화면 캡처와 입력 주입 | 미착수 | 2편 범위 | 9절 |
1. 요구사항을 먼저 확정한다
만들 물건의 조건은 넷이다.
| 조건 | 내용 |
|---|---|
| 기능 | 윈도우 원격 데스크톱 연결이 하는 일. 원격 화면 표시와 키보드 및 마우스 전달 |
| 접속 방식 | TeamViewer 처럼 미리 약속된 ID 와 비밀번호로 연결 |
| 통신 경로 | Chrome 원격 데스크톱처럼 방화벽을 넘는 표준 포트 사용 |
| 최우선 관심사 | 첫째 보안, 둘째 운영체제 호환성 |
마지막 줄이 이 프로젝트의 성격을 결정한다. 화면 전송 속도는 나중에 올릴 수 있지만, 암호화 계층은 프로토콜을 다시 설계해야 들어가고 운영체제 추상화는 플랫폼 코드가 전체에 흩어진 뒤에는 걷어낼 수 없다. 그래서 되돌릴 수 없는 것부터 정한다.
2. 결정 1. 언어는 Rust
직전 프로젝트인 GenealogyImage 는 Python 과 PySide6 로 만들었다. 같은 조합을 쓰면 출발이 가장 빠르다. 그런데도 바꿨다. 네 후보를 놓고 비교한 결과는 이렇다.
| 후보 | 판단 |
|---|---|
| Python 과 PySide6 | 초당 30장 화면을 잡아 압축하고 암호화하는 경로에서 GIL 이 병목이 된다. 배포본에 소스가 그대로 들어간다 |
| C# 과 .NET | 윈도우 캡처와 입력 접근은 가장 편하다. 뷰어를 macOS 와 리눅스로 옮길 때 캡처 계층은 어차피 네이티브 호출로 다시 써야 한다 |
| Go | 서버와 네트워크 코드는 가장 빠르게 쓴다. 화면 캡처와 코덱 바인딩 생태계가 얕고 cgo 의존이 늘어난다 |
| Rust | 채택 |
이유는 세 가지다.
- 메모리 안전성이 그대로 보안이 된다. 원격제어 호스트는 신뢰할 수 없는 바이트를 계속 받아 해석한다. 이 자리에서 버퍼 오버플로가 나면 곧바로 원격 코드 실행으로 이어진다. 경계 검사와 소유권 검사를 컴파일 시점에 강제하는 언어를 쓰는 편이 낫다.
- 런타임이 필요 없는 단일 실행 파일이다. 호스트 쪽에 인터프리터나 프레임워크를 설치하지 않는다. 배포 표면이 작을수록 공격 표면도 작다.
- 크로스 컴파일이 언어 차원에서 지원된다. 세 운영체제 타깃이 모두 들어 있다.
$ rustup target list | grep windows
x86_64-pc-windows-gnu
x86_64-pc-windows-gnullvm
x86_64-pc-windows-msvc
맨 아래 셋이 이번 목표 타깃이다. 별도 크로스 컴파일러를 설치하지 않고 툴체인 명령 하나로 확인된다.
Rust 는 이 프로젝트에서 처음 쓰는 언어다. 그래서 크레이트 경계를 좁게 잘랐다. 프로토콜과 암호만 담는 grc-core 에는 운영체제 API 가 한 줄도 들어가지 않는다. 그래서 리눅스에서 프로토콜 시험을 끝낸 뒤 플랫폼 코드로 넘어갈 수 있다.
3. 결정 2. 폴더가 그대로 버전 관리가 된다
버전 문자열은 네 자리이고 각 자리는 0 부터 9 까지다. 초기 버전은 GenealogyRemoteControl_v1.0.0.0 이다.
| 자리 | 올리는 시점 |
|---|---|
| 빌드, 네 번째 | 작업 한 묶음이 끝날 때마다. 이 개발일지 한 편과 하나씩 짝을 이룬다 |
| 수, 세 번째 | 기능 하나가 실제로 동작할 때 |
| 부, 두 번째 | 마일스톤 하나가 끝날 때 |
| 주, 첫 번째 | 외부 배포 |
판올림할 때 폴더를 통째로 새로 만든다. 직전 버전의 develop 과 screenshot 과 posting 을 그대로 복사해 새 폴더를 만들고, 이전 폴더는 손대지 않는다. 그래서 리소스와 개발일지가 버전마다 함께 상속된다.

4. 결정 3. 비밀번호를 네트워크에 흘리지 않는다
이 프로젝트에서 가장 오래 붙잡은 부분이다. ID 와 비밀번호로 접속하는 구조에서 비밀번호를 어떻게 검증할 것인가.
흔한 구현이 왜 못 쓰는가
가장 먼저 떠오르는 방법은 비밀번호를 해시해서 보내는 것이다. 두 가지 이유로 못 쓴다.
- 해시를 가로챈 사람은 그대로 재전송해서 접속한다. 그래서 해시가 그대로 비밀번호 구실을 한다.
- 8자 비밀번호의 해시는 GPU 로 초당 수십억 회 시도해 오프라인에서 깨진다.
그래서 SPAKE2 를 쓴다. 비밀번호 인증 키 교환 계열 프로토콜이다. 비밀번호를 타원곡선 위의 점을 가리는 값으로만 사용하고, 오가는 메시지에서는 비밀번호를 역산할 수 없다. 한 번의 접속 시도로 후보 하나만 검증되므로 오프라인 사전 공격이 성립하지 않는다.

남는 위협은 온라인 추측뿐이다
오프라인 공격을 막았으니 남는 것은 직접 접속을 시도하며 비밀번호를 찍어 보는 공격이다. 8자 임시 비밀번호의 엔트로피를 실제로 계산해 보면 39.6 bit 다. 시도 제한이 없으면 초당 1만 회로 약 15시간이면 뚫린다. 그래서 시도 횟수 제한이 선택이 아니라 전제다.
/// 온라인 추측 방어. 이 횟수를 넘기면 호스트가 접속을 잠근다.
pub const MAX_AUTH_ATTEMPTS: u32 = 5;
/// 잠금 시간, 초 단위.
pub const LOCKOUT_SECS: u64 = 300;
5회 실패 시 300초 잠금. 이 두 상수가 39.6 bit 를 실제 안전 수준으로 바꾸는 장치다.
비밀번호 문자 집합도 설계 대상이다
임시 비밀번호는 전화로 불러 주는 값이다. 그래서 오독이 그대로 접속 실패가 된다. 혼동하기 쉬운 문자를 뺐다.
/// 임시 비밀번호 문자 집합. 혼동하기 쉬운 0 O o 1 l I 를 뺐다.
const PW_ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
pub fn generate(len: usize) -> Self {
let n = PW_ALPHABET.len() as u32;
// u32 범위를 n 의 배수로 자른 뒤 그 밖의 값은 버린다.
// 그냥 나머지 연산을 쓰면 앞쪽 문자가 더 자주 뽑히는 편향이 생긴다.
let limit = u32::MAX - (u32::MAX % n);
...
}
난수 편향 제거와 로그 노출 차단을 함께 넣었다. 둘 다 나중에 고치기 어려운 종류의 실수다.
공유 비밀 하나에서 키 넷을 뽑는다
PAKE 가 만든 공유 비밀을 그대로 암호화 키로 쓰면 안 된다. 왜냐하면 양방향에 같은 키를 쓰면 공격자가 호스트가 보낸 암호문을 호스트에게 되돌려 보내 유효한 메시지로 통과시키는 반사 공격이 성립하기 때문이다.

nonce 는 난수가 아니라 카운터다
ChaCha20-Poly1305 는 같은 키에 같은 nonce 를 두 번 쓰면 평문이 드러난다. 난수 nonce 는 생일 문제로 충돌 확률이 남지만 카운터는 구조적으로 충돌하지 않는다. 그리고 수신 측이 기대 시퀀스와 정확히 일치하는 값만 받도록 하면, 같은 코드가 재생 공격까지 함께 막는다.
pub fn open(&mut self, framed: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
let seq = u64::from_be_bytes(s);
if seq != self.rx_seq {
// 이 한 줄이 재생 공격과 순서 뒤바꾸기를 함께 막는다.
return Err(Error::SequenceMismatch { expected: self.rx_seq, got: seq });
}
let pt = self.dec.decrypt(&nonce_from_seq(seq), Payload { msg: &framed[8..], aad })
.map_err(|_| Error::DecryptFailed)?;
// 복호화가 성공한 뒤에만 상태를 전진시킨다.
self.rx_seq = seq.checked_add(1).ok_or(Error::SequenceExhausted)?;
Ok(pt)
}
복호화 실패 시 상태를 전진시키지 않는다. 그래서 실패한 시도로 시퀀스를 밀어 정상 통신을 끊는 공격이 막힌다.
지금 접속한 상대가 지난번 그 컴퓨터인가
비밀번호만으로는 이 질문에 답할 수 없다. 호스트는 최초 실행 시 Ed25519 키쌍을 만들고 핸드셰이크 마지막에 트랜스크립트에 서명한다. 뷰어는 서명을 검증하고 공개키 지문을 화면에 띄운다.
호스트 지문 3222 7B80 9928 0F80 D655 64E0
96 bit 를 4자 6묶음으로 끊었다. 전화로 불러 주기를 전제한 길이다. 이 값이 바뀌면 중간자를 의심해야 한다.
5. 결정 4. 운영체제 차이는 트레이트 두 개로 가둔다
화면 캡처와 입력 주입은 운영체제마다 완전히 다른 API 를 쓴다.
| 계층 | Windows | macOS | Linux |
|---|---|---|---|
| 화면 캡처 | Windows.Graphics.Capture 와 DXGI Desktop Duplication | ScreenCaptureKit | PipeWire 와 portal, X11 에서는 XShm |
| 입력 주입 | SendInput | CGEventPost | uinput 과 XTest |
| 권한 | 별도 승인 없음 | 화면 기록과 손쉬운 사용 승인 | portal 대화상자 |
| 배포 | 코드 서명 권장 | 공증 필수 | 배포판별 패키징 |
공통 라이브러리 하나로 덮으려는 시도는 대개 실패한다. 그래서 인터페이스를 좁게 잘라 격리한다. 상위 코드가 아는 것은 Frame 과 InputEvent 뿐이고, 운영체제 타입은 이 경계를 넘지 않는다.
trait ScreenSource {
fn displays(&self) -> Vec<DisplayInfo>;
fn next_frame(&mut self, timeout: Duration) -> Result<Option<Frame>>;
}
trait InputSink {
fn dispatch(&mut self, ev: InputEvent) -> Result<()>;
}
v1 범위를 호스트 Windows 하나로 좁힌 이유가 있다. 캡처와 입력 주입은 운영체제별 난이도 차이가 극단적이다. 세 운영체제의 호스트를 동시에 만들면 어느 것도 완성되지 않는다. 그래서 조종당하는 쪽을 Windows 하나로 고정하고, 조종하는 쪽인 뷰어를 여러 운영체제로 넓힌다. 뷰어는 화면을 그리고 입력을 보내기만 하므로 플랫폼 의존이 훨씬 얕다.
6. 결정 5. 80번 포트 요구를 443 으로 바꾼다
요구사항은 Chrome 원격 데스크톱처럼 80번 포트로 통신하는 것이었다. 조사 결과 두 가지를 바로잡았다.
- Chrome 원격 데스크톱이 실제로 쓰는 것은 443 이다. UDP 를 먼저 시도하고, 막히면 TCP 443 의 TURN 중계로 떨어진다. 80번이 아니다.
- 80번은 평문 HTTP 포트다. 여기로 암호화된 바이너리 스트림을 흘리면, 프로토콜 검사를 하는 방화벽이 HTTP 가 아닌 트래픽으로 판단해 오히려 더 잘 차단한다.
443 은 TLS 포트라 내용이 암호문인 것이 정상이고 검사 장비가 통과시킬 확률이 높다. 페이로드는 이미 4절의 종단간 암호화를 거친 상태이므로, 그 위의 TLS 는 기밀성을 위해서가 아니라 통과를 위해서 두른다. 그래서 중계 서버가 TLS 를 벗겨도 안쪽 암호문은 읽지 못한다.
중계 서버는 뒤로 미뤘다. v0 은 서버 없이 LAN 과 직접 IP 로만 붙는다. 이 단계에서 프로토콜과 암호와 캡처와 인코딩을 모두 확인할 수 있고, 무엇보다 서버라는 신뢰 요소 없이 보안 설계를 시험할 수 있다.
7. 만든 것. grc-core
결정 사항 중 보안 부분을 코드로 옮겼다. 파일 여섯 개, 645줄이다.
| 파일 | 줄 | 담당 |
|---|---|---|
| error.rs | 57 | 오류 타입. 오류 문자열에 비밀값을 담지 않는 것이 규칙 |
| id.rs | 100 | 9자리 장치 ID 와 임시 비밀번호 생성 |
| wire.rs | 77 | 프레이밍. 길이 검사 후 할당 |
| crypto.rs | 141 | 키 유도와 AEAD 봉인 및 해제, 시퀀스 검사 |
| handshake.rs | 252 | SPAKE2 상호 인증, 트랜스크립트, 호스트 서명 |
| lib.rs | 18 | 크레이트 진입점 |
프레이밍에서 한 가지를 특히 신경 썼다. 길이 필드를 그대로 믿고 버퍼를 할당하면, 4GB 를 보내겠다는 헤더 한 방으로 호스트가 죽는다.
pub fn read_frame(buf: &[u8]) -> Result<Option<(MsgType, &[u8], usize)>> {
if buf.len() < HEADER_LEN { return Ok(None); }
let ty = MsgType::from_u8(buf[0])?;
let len = u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]);
// 할당 이전에 상한을 본다.
if len > MAX_FRAME { return Err(Error::FrameTooLarge { len, max: MAX_FRAME }); }
...
}
직렬화 라이브러리를 쓰지 않고 수동으로 작성했다. 왜냐하면 역직렬화기는 원격 입력을 가장 먼저 받는 표면이라 코드가 눈에 보여야 하기 때문이다.
8. 검증. 정상 경로 하나와 공격 경로 넷
시험은 동작한다를 확인하는 것이 아니라 공격이 막힌다를 확인하도록 짰다.
#[test]
fn 재생공격은_시퀀스_검사에_걸린다() {
let (v, h, _) = run("r1v5s4qp", "r1v5s4qp");
let mut vc = v.into_channel();
let mut hc = h.into_channel();
let sealed = vc.seal(b"click at 100,200", b"hdr").unwrap();
assert!(hc.open(&sealed, b"hdr").is_ok());
// 같은 바이트를 한 번 더 던진다. 마우스 클릭 재전송 공격에 해당한다.
match hc.open(&sealed, b"hdr") {
Err(Error::SequenceMismatch { expected, got }) => {
assert_eq!((expected, got), (1, 0));
}
other => panic!("재생이 통과했다: {other:?}"),
}
}
시험 이름을 한글로 썼다. 그래서 무엇을 막으려는 시험인지가 실행 결과에 그대로 나온다.

확인된 항목은 다음과 같다.
- 같은 비밀번호를 쓰면 양쪽 확인값이 일치한다.
- 비밀번호가 한 글자만 달라도 확인값이 어긋나고 서명 검증이 무너진다.
- 같은 암호문을 두 번 보내면 시퀀스 불일치로 거부된다.
- 프레임 헤더를 한 바이트 바꾸면 복호화가 실패한다.
- 길이 필드가 4GiB 를 주장하는 프레임은 할당 이전에 거절된다.
- ID 를 1000회 생성해도 전부 9자리 범위이며 표시와 입력 형식이 왕복에서 일치한다.
- 임시 비밀번호에 혼동 문자가 없고 디버그 출력에 값이 노출되지 않는다.
9. 이번 회차에서 하지 못한 것
지원하지 않는 것을 지원한다고 적지 않는 편이 낫다. 그래서 정직하게 적는다.
- Windows 실기 빌드는 아직이다. 이번 회차의 코드는 리눅스에서 컴파일하고 시험했다. grc-core 에 운영체제 API 가 없어서 가능한 일이고, 바꿔 말하면 Windows 쪽 검증은 다음 회차 몫이다.
- UAC 승인 창과 잠금 화면은 제어할 수 없다. Windows 의 보안 데스크톱은 별도 데스크톱에 그려지고 일반 권한 프로세스의 SendInput 이 닿지 않는다. 서비스로 올려 데스크톱을 전환하는 우회가 있으나 범위에서 제외했다.
- 화면 캡처와 인코딩과 실제 전송은 코드가 없다. 이번 회차는 프로토콜과 암호까지다.
10. 다음 회차
2편은 기존 원격제어 소프트웨어와 기능을 대조해 없으면 곧바로 못 쓰는 것부터 채우는 작업이다. 기능 협상과 화면 전송과 입력 주입과 파일 전송이 들어간다. 소프트웨어 버전은 v1.0.1.0 이다.
이 글에 해당하는 소스는 아래 첨부파일에 담았다. GenealogyRemoteControl_v1.0.0.0_develop.zip 을 풀고 develop 폴더에서 cargo test 를 실행하면 본문 8절의 결과를 그대로 재현할 수 있다.