728x90

1. netcat으로는 안 되는 순간들

온프렘 랙에 있는 서버 하나에서 로그 3GB를 클라우드 쪽 분석 VM으로 넘겨야 하는 상황. 예전 같으면 nc -l 9000 > dump.tar 하고 반대쪽에서 nc 10.0.0.5 9000 < dump.tar 하면 끝났다. 문제는 요즘 어떤 환경에서도 이게 잘 안 된다는 거다.

  • 양쪽 다 NAT 뒤에 있고, 인바운드 포트를 열려면 방화벽 변경 요청서를 써야 한다. 보통 이틀 걸린다.
  • 클라우드 쪽은 Security Group을, 온프렘 쪽은 L4 방화벽 정책을 각각 다른 팀이 관리한다.
  • 어렵게 뚫어도 netcat은 평문이다. 사내망이라도 요즘은 감사에서 걸린다.
  • 임시로 열었던 포트를 닫는 걸 까먹는다. 이게 제일 흔하다.

그래서 다들 SSH 터널, ngrok, 아니면 그냥 Tailscale을 깐다. 그런데 Tailscale은 계정이 필요하고 노드가 tailnet에 등록된다. "일회성 디버깅 하나 하려고 회사 tailnet에 이 서버를 붙여야 하나?" 하는 지점에서 걸린다.

Tailcat은 Tailscale이 직접 만든 도구인데, 자기네 표현이 재밌다. "Tailscale without Tailscale, by Tailscale". Tailscale의 데이터 평면(magicsock)만 떼어내서 netcat처럼 쓰게 만든 물건이다. 계정 없고, 제어 서버(coordination server) 없고, root 권한도 필요 없다. 라우팅 테이블이나 DNS를 건드리지 않는 순수 유저스페이스 CLI/라이브러리다.

2. 데이터 평면만 떼어낸다는 게 무슨 뜻인가

Tailscale은 크게 두 덩어리로 나뉜다.

  • 제어 평면(control plane): 누가 어떤 tailnet에 속하는지, 어떤 노드가 어떤 IP를 갖는지, ACL은 뭔지를 관리. 여기가 계정과 로그인이 붙는 곳이다.
  • 데이터 평면(data plane): 실제 패킷을 나르는 부분. 내부적으로 magicsock이라 부르는 컴포넌트가 WireGuard 암호화 터널을 만들고, DERP 릴레이를 사이드 채널 삼아 NAT 홀펀칭을 시도한다. 홀펀칭이 실패하면 DERP가 최후의 릴레이로 남는다.

Tailcat은 이 중 아래쪽만 쓴다. 그럼 제어 평면이 하던 "상대방 공개키와 위치를 알려주는" 일은 누가 하냐? 사람이 한다. 정확히는 out-of-band로 알아서 전달한다. 슬랙, 메신저, DNS TXT 레코드, 뭐든 상관없다.

연결 토큰의 정체

서버 쪽에서 tailcat을 실행하면 토큰 하나가 나온다.

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

이 문자열이 뭔지는 tailcat parse로 직접 까볼 수 있다.

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
  "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
  "RegionID": 302
}

딱 두 개다. 서버의 WireGuard 공개키어느 DERP 리전에서 만나면 되는지. 제어 평면이 API로 내려주던 정보를 base64 비슷한 한 줄로 압축해서 사람이 복붙하게 만든 것뿐이다. 이걸 보고 나면 구조가 단순해서 오히려 허탈하다.

클라이언트는 이 토큰을 받아 해당 DERP 리전으로 붙고, 거기서 서버와 만나 WireGuard 핸드셰이크를 한다. 핸드셰이크가 끝나면 magicsock이 서로의 실제 IP:포트를 교환하면서 직접 UDP 경로를 뚫으려 시도한다. 성공하면 DERP를 버리고 P2P로 올라탄다. 이게 실제로 되는지는 ping으로 확인할 수 있다.

$ tailcat ping --until-direct tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

첫 번째 pong은 릴레이 경유(42ms), 두 번째는 직접 경로(1.2ms). NAT 트래버설이 성공하는 순간이 로그에 그대로 찍힌다. --until-direct는 직접 경로가 뚫릴 때까지 계속 핑을 날리고, 타임아웃(기본 10초) 안에 못 뚫으면 non-zero로 종료한다. 스크립트에서 "직접 연결 가능한지" 사전 체크용으로 쓸 만하다.

토큰의 두 가지 형태

기본 토큰에는 리전 ID만 들어있어서, 클라이언트가 DERP map(https://tailcat.dev/derpmap.json)을 한 번 받아와야 한다. 이걸 미리 풀어서 릴레이 호스트명·IP까지 박아 넣은 긴 토큰을 만들 수 있다.

$ tailcat resolve tcomFwWCC...FpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

파싱해보면 tc302a.ipn.dev, IPv4/IPv6가 그대로 들어있다. 서버가 처음부터 이 형태로 출력하게 하려면 --full-address를 쓴다. 길어지지만 DERP map 조회 왕복이 없어서 연결이 빨라진다.

3. 실무에서 어떻게 쓰나 — 그리고 어디서 넘어지나

시나리오 A: 방화벽 뒤 웹 서버 임시 노출

온프렘 서버에서 돌던 애플리케이션이 이상하게 동작해서, 클라우드에 있는 내 개발 VM에서 직접 찔러보고 싶다. 인바운드 포트는 못 연다.

# [온프렘 서버]
$ tailcat --serve=8080,8443
# 🐈 Server listening with new address: tcXXXXXXXXX

# [클라우드 VM] 토큰 슬랙으로 받아서
$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....

더 실용적인 건 SOCKS5 모드다. curl이나 다른 CLI 도구를 그대로 터널 위로 태울 수 있다. 토큰 자체를 호스트명처럼 쓸 수 있다는 점이 재밌다.

$ tailcat socks tcXXXXXXXXX curl http://server.tailcat:8081/
# 또는 토큰을 호스트명 자리에 직접
$ tailcat socks curl http://tcXXXXXXXXX:8081/

주의할 점: 토큰은 대소문자를 구분한다. curl 같은 CLI에서는 문제없지만 브라우저는 호스트명을 소문자로 바꿔버려서 안 된다. 원문에 명시된 제약이다.

시나리오 B: 포트 안 열고 SSH 서버 운영

이게 개인적으로 제일 인상 깊었던 예제다. WireGuard가 SSH 데몬보다 먼저 클라이언트를 인증하기 때문에, 허용되지 않은 상대는 SSH 서버가 돌고 있다는 사실조차 알 수 없다.

# [클라이언트] 클라이언트 신원 키 생성 (공개키만 서버에 알려주면 됨)
client$ tailcat genkey --client
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

# [서버] 리전 고정한 서버 키 생성 후, 저 클라이언트만 허용
server$ tailcat genkey --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX
server$ tailcat --serve=22 --allow=nodekey:cfb6bf...ddfd16
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

토큰을 DNS TXT 레코드로 올려두면 이름으로 접속된다.

my-server.example.com.  300  IN  TXT  "tailcat=tcXXXXXXXXX"

client$ tailcat ssh my-server.example.com

클라이언트는 client-default 키가 있으면 알아서 쓰기 때문에 추가 플래그가 없다. 포트 노킹이나 포트 포워딩 없이 이게 된다는 게 꽤 매력적이다.

흔한 함정

(1) default 키의 마법 이름 문제. 이게 제일 사고 나기 쉽다. tailcat genkey를 한 번 해두면 ~/.config/tailcat/keys/default.private.json이 생기고, 그 뒤로는 그냥 tailcat만 쳐도 조용히 그 키를 쓴다. 즉 예전에 어딘가 슬랙에 뿌렸던 토큰이 그대로 살아난다. 시작 로그에서 반드시 구분하자.

# 새 일회용 키 — 프로세스 죽으면 주소도 영구 소멸
# 🐈 Server listening with new address: tcXXXXXXXXX

# 저장된 키 — 과거에 토큰 받은 사람 전부 접속 가능
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

저장 키를 쓸 거면 --allow로 클라이언트를 제한하는 게 사실상 필수다. 일회용이 필요하면 --key=new를 명시하고, 키를 지우려면 tailcat genkey --delete --key=default, 목록은 tailcat genkey --list.

(2) DNS에 올릴 토큰은 리전을 고정해야 한다. 그냥 tailcat genkey--region=auto가 기본이라 "시작할 때 고른다"가 키 파일에 박힌다. 서버가 재시작되면서 다른 리전을 고르면 DNS에 게시한 토큰이 어긋난다. 그래서 위 예제에서 --fixed-region을 쓴 거다. 이건 genkey 시점에 가장 가까운 리전을 한 번 탐색해서 ID를 토큰과 키 파일 양쪽에 박아둔다. 명시적으로 고르려면 --region=이름, 목록은 --region=list.

참고로 README에도 "DERP map이 시간이 지나 바뀌면 클라이언트가 더 견고해져야 한다"는 TODO(이슈 #7)가 남아 있다. 장기 운영용으로 DNS에 박아둘 거면 이 부분은 이슈 트래킹을 해두는 게 좋겠다.

(3) 토큰 하나가 곧 접근 권한이다. --allow를 안 걸면 토큰 아는 사람은 누구나 붙는다. 특히 --serve=no-auth-ssh는 이름 그대로 인증 없는 SSH다. 잠깐 디버깅용으로는 편하지만, 이걸 사내 서버에서 저장 키와 조합해서 띄우는 건 사고다. 인증이 필요하면 --serve=22로 시스템 SSH에 프록시하는 쪽을 쓰자. --serve=exit-node도 마찬가지로, 클라이언트가 서버 쪽 네트워크 전체로 나갈 수 있게 되는 옵션이라 사내망에서는 신중해야 한다.

(4) 직접 연결이 항상 되는 건 아니다. README도 "usually!"라고만 쓴다. 대칭 NAT나 빡빡한 아웃바운드 정책이 걸린 사내망에서는 DERP 릴레이 경유로 남을 수 있다. 이때 기본 릴레이는 Tailscale의 무료 rate-limited 릴레이다. 3GB 로그 덤프 같은 걸 릴레이로 밀면 어떻게 될지는 예상 가능하다. 대용량 전송 전에 tailcat ping --until-direct로 직접 경로부터 확인하고, 상시로 쓸 거면 자체 DERP를 세우자.

# 자체 derper(TLS 인증서 필요, Let's Encrypt 자동 발급 가능) 지정
server$ tailcat genkey --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
server$ tailcat --serve=22

이렇게 하면 토큰 안에 릴레이 호스트명이 박혀서, 클라이언트는 Tailscale의 DERP map 서버나 릴레이에 전혀 접속하지 않는다. rate limit도 내 것만 적용된다. 릴레이가 여러 대면 직접 DERP map JSON을 서빙하고 양쪽에 --derpmap-url로 물려주면 된다. 사내 도입을 검토한다면 사실상 이 구성이 기본이 되어야 한다고 본다.

(5) 아웃바운드가 막혀 있으면 아무것도 안 된다. 이건 도구 문제가 아니라 정책 문제다. DERP 부트스트랩은 HTTPS(443)로 나가고 P2P는 UDP다. 아웃바운드 443조차 프록시 강제인 환경에서는 다음처럼 실패한다.

$ tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tailcat: failed to connect: context deadline exceeded

혹은 토큰을 잘못 복붙했을 때(줄바꿈이 섞이거나 대소문자가 뭉개진 경우) 이런 식의 파싱 실패를 먼저 의심하자.

$ tailcat parse tcomfwwccjs5nkNqAod034nWoJZW0LZ
tailcat: invalid address

정확한 메시지 문구는 버전에 따라 다를 수 있으니 실제 출력 기준으로 확인이 필요하다. 다만 원인은 대체로 아웃바운드 차단 아니면 토큰 복붙 사고 둘 중 하나다. 토큰이 대소문자 구분이라는 점 때문에, 메신저 자동 링크화나 대문자 변환이 끼면 조용히 깨진다.

다른 도구와 언제 갈리나

  • SSH 포트포워딩(ssh -L/-R): 이미 SSH로 닿을 수 있으면 이게 제일 간단하다. 문제는 양쪽 다 NAT 뒤라 SSH 자체가 안 닿을 때. Tailcat은 그 지점을 푼다.
  • ngrok/Cloudflare Tunnel: HTTP 노출과 공개 URL이 목적이면 여전히 이쪽이 낫다. Tailcat 토큰은 브라우저에 못 넣는다.
  • Tailscale 본체: 상시 운영, ACL, 감사 로그, 팀 단위 관리가 필요하면 그냥 Tailscale을 써야 한다. Tailcat에는 중앙 정책 관리가 없다. 신뢰 모델이 "토큰을 아는 사람 + --allow 목록"으로 끝난다.
  • 순정 WireGuard: 성능과 통제는 최고지만 양쪽 공인 IP/포트 개방과 root 권한이 필요하다. Tailcat은 root 없이 유저스페이스에서 돌고 라우팅 테이블을 안 건드린다. 이게 "남의 서버에 잠깐 올려서 쓰기" 관점에서는 큰 차이다.

4. 정리

한 줄 요약: Tailcat은 "계정도 제어 서버도 없이, root 없이, 방화벽 뒤 두 머신을 WireGuard로 직접 잇는 netcat"이다. 제어 평면이 하던 키·위치 교환을 사람이 토큰 복붙으로 대신하는 게 전부고, 그래서 단순하고 그래서 위험 요소도 명확하다.

이럴 때 써라:

  • 온프렘–클라우드 간 일회성 디버깅 터널. 기본값(에페메랄 키)이 정확히 이 용도로 설계되어 있다.
  • 방화벽 정책 변경 리드타임을 못 기다리는 장애 대응 상황.
  • 인바운드 포트를 절대 못 여는 서버에 SSH를 붙여야 할 때(--allow + 자체 DERP 필수).
  • Go 라이브러리(github.com/tailscale/tailcat)로 내부 툴에 P2P 채널을 심을 때. Server{OnTCP: ...} 하나로 시작되는 API가 꽤 얇다.

이럴 때는 쓰지 마라:

  • 상시 운영 인프라의 정식 접근 경로. 중앙 ACL·감사가 없다.
  • 브라우저로 접근해야 하는 서비스 노출.
  • 기본 릴레이 위로 대용량 전송. 자체 DERP를 먼저 세우자.

개인적으로는 "노트북에 넣고 다니는 비상용 도구" 포지션이 딱 맞다고 본다. 저장소가 공개된 지 얼마 안 됐고 README에도 TODO가 남아 있으니, 프로덕션 상시 경로로 넣기 전에는 최신 이슈 목록을 한 번 훑어보길 권한다.

참고 자료

728x90
728x90

1. 6개월, 19건. "에러 없이 성공한 쓰기"가 사라졌다

Tailscale이 제어 평면(control plane) 데이터베이스에서 겪은 손상 사고 이야기가 화제다. 요약하면 이렇다. 2024년 8월 S3 백업을 읽는 파이프라인에서 오류가 났고, PRAGMA integrity_check를 돌려보니 진짜 손상이었다. 그 뒤 6개월 동안 서로 무관해 보이는 손상 사고가 19번 터졌다. 결론은 SQLite의 체크포인트와 쓰기 트랜잭션 사이에 존재하던 데이터 경쟁, SQLite 개발진이 WAL-Reset 버그라 이름 붙인, 최소 16년 묵은 결함이었다.

구성부터 보자. Tailscale의 제어 평면은 여러 샤드로 나뉘고, 각 샤드는 자기 SQLite 파일을 하나 갖는다. 접근하는 프로세스는 Go 프로세스 하나뿐이다. 단일 작성자 — SQLite가 가장 잘하는 형태다. 백업은 몇 분마다 전체 스냅샷을 떠서 S3에 올린다. 2023년 초부터 사고 없이 잘 돌던 구성이다.

운영 담당자 입장에서 이 사건이 무서운 건 증상이 아니라 증상의 성질이다. 정리하면:

  • 재현 조건이 없다. 특정 샤드·고객·기능·시간대·부하와 상관관계가 안 나왔다.
  • 발생 간격이 몇 시간에서 몇 주까지 제멋대로다. 10월~12월엔 6주간 조용하다가 크리스마스 즈음 다시 터졌다.
  • 가장 결정적인 단서: 백업 복구용으로 만든 트랜잭션 로그를 재생했더니 깨끗하게 적용되지 않았다. 어떤 트랜잭션이 기록하고 커밋한 데이터가, 뒤이은 트랜잭션에서는 안 보였다. 애플리케이션에는 에러가 하나도 안 올라왔는데 쓰기가 증발한 것이다.
  • 지표도 이상했다. WAL에 10페이지가 있는데 체크포인트는 20페이지를 복사했다고 집계했다.

영향은 메타데이터 범위였다. 사용자 개인키나 트래픽은 DB에 없다. 다만 손상된 샤드를 복구·복원하는 동안 제어 평면 프로세스를 멈춰야 했고, 초기엔 1시간 넘는 중단이 났다. 이미 연결된 WireGuard 피어는 살아 있지만 새로 켠 장치는 피어 목록을 못 받아 연결을 못 만든다. 그리고 반복되는 상태 페이지 공지 자체가 신뢰를 갉아먹었다.

2. WAL과 체크포인트, 그리고 리셋 순간의 틈

복습부터. WAL 모드에서 쓰기는 메인 DB 파일에 바로 가지 않고 -wal 파일에 페이지 단위로 append된다. 읽기 트랜잭션은 자기 스냅샷 시점까지의 WAL 프레임과 메인 파일을 조합해 본다. WAL이 무한정 커질 수 없으니 주기적으로 체크포인트가 WAL 페이지를 메인 DB로 복사하고, 아무도 그 프레임을 안 읽고 있으면 WAL을 리셋(처음부터 다시 쓰기 시작)한다.

여기서 조율을 담당하는 게 -shm 파일에 mmap된 wal-index다. 일반 파일 잠금이 아니라 xShmLock 같은 공유 메모리 원시연산으로 조정한다. 그래서 이 경로는 연결이 둘 이상 열려 있어야만 밟힌다. Tailscale은 프로세스가 하나지만, 쓰기와 체크포인트를 서로 다른 스레드의 서로 다른 커넥션에서 수행했을 것으로 보인다(HN 토론의 해석). 실제로 SQLite 측 버그 설명도 다중 연결 조건을 든다.

버그의 모양은 이렇다. 체크포인트가 진행되는 특정 시점에 쓰기 트랜잭션이 끼어들어 WAL을 리셋하면, 체크포인트 로직은 실제로 메인 DB에 복사하지 않은 페이지를 복사했다고 착각한다. 복사 안 된 페이지는 메인 파일에 영원히 안 들어간다. 그런데 그 페이지를 참조하는 인덱스 등 다른 페이지는 정상적으로 기록된다. 결과는 두 가지가 한꺼번에 온다 — 데이터 소실 + 구조 불일치(= 손상).

비유하자면, 이삿짐 목록에 체크 표시를 하는 사람과 실제 박스를 옮기는 사람이 다른데, 중간에 누가 목록을 새 종이로 갈아끼웠고 체크 표시만 그대로 옮겨 적힌 상황이다. 목록상으론 다 옮겼는데 박스 몇 개가 옛날 집에 남아 있고, 그 박스를 가리키는 색인표는 새 집에 도착해 있다.

왜 16년이나 안 걸렸나. 이 코드 경로는 xShmMap/xShmLock/xShmBarrier가 실제로 동작하는 VFS에서만 밟힌다. 현실에서 쓰이는 구현은 Unix와 Windows 정도고, 나머지 대체 VFS(브라우저/WASM/OPFS 등)는 locking_mode=exclusive로 우회해 wal-index를 힙에 둔다. 즉 Unix에서, 체크포인트를 수동으로, 공격적으로 돌리는 누군가가 나타나야 발견되는 버그였다. Tailscale은 빠르고 일관된 백업을 위해 체크포인트를 직접 제어하고 있었고, 그래서 당첨됐다.

3. 재현 안 되는 버그를 어떻게 잡았나 — 관측을 프로덕션에 심는다

이 사건의 진짜 볼거리는 디버깅 방법론이다. 합성 환경 재현을 포기하고 프로덕션에 수동적 포렌식 텔레메트리를 심는 쪽으로 방향을 틀었다.

  1. 사고 대응 시간을 먼저 줄였다. 손상 감지 시 샤드를 즉시 강제 중지, 백업마다 PRAGMA integrity_check를 돌리는 자동 백업 모니터 배포, 런북과 온콜 교육 개선. 이걸로 대응을 1시간 미만으로 낮췄다. 원인 못 찾는 동안 서비스는 돌려야 하니까.
  2. 트랜잭션 로그 파이프라인. DB를 바꾸는 모든 SQL 문을 별도 로그로 스트리밍. 단일 작성자 + 직렬화 가능 트랜잭션이라 기록이 선형적·결정적이다(멀티 라이터인 Postgres/MySQL에선 이 성질이 안 성립한다). 마지막 정상 백업에 로그를 재생해 손상 파일을 우회하고 최신 상태를 복원했다. 그리고 이 재생이 실패한 두 번의 사고가 결정적 단서가 됐다.
  3. tmstmpvfs shim. SQLite 개발진이 기존 VFS를 감싸 DB 변경 추적 로그를 남기는 shim을 만들었고, Tailscale이 이걸 프로덕션에 배포해 다음 사고의 로그를 확보했다. 여기서 경쟁 상태가 잡혔다.
  4. 수정 검증도 관측으로. "6주간 조용했던 전례"가 있었으니 재발 없음만으로 해결 선언을 안 했다. 대신 쓰기 트랜잭션과 WAL 리셋이 겹치면 경고를 남기도록 드라이버를 고쳤다. 배포 두 달 뒤 실제로 그 경고가 떴고 — DB는 멀쩡했다. 조건은 성립하는데 손상은 안 난다, 즉 패치가 사고를 막고 있다는 능동적 증거다.

여기서 훔쳐올 만한 원칙 하나: "안 터졌다"는 수정의 증거가 아니다. 위험 조건이 발생했다는 사실 자체를 계측해서, 조건 발생 + 무사고를 함께 봐야 한다.

4. 실무 점검: 지금 우리 SQLite는 안전한가

SQLite는 국내에서도 엣지 노드, 모니터링 에이전트, 사이드카 캐시, 온디바이스 저장소에 넘치게 쓰인다. 당장 확인할 것부터.

4-1. 저널 모드와 체크포인트 운용 확인

$ sqlite3 /var/lib/agent/state.db \
  "PRAGMA journal_mode; PRAGMA wal_autocheckpoint; PRAGMA synchronous;"
wal
1000
2

# 수동 체크포인트를 돌린다면 반환값을 반드시 확인
$ sqlite3 /var/lib/agent/state.db "PRAGMA wal_checkpoint(TRUNCATE);"
0|10|10

세 컬럼은 순서대로 busy, WAL 프레임 수, 메인 DB로 복사된 프레임 수다. 첫 값이 1이면 체크포인트가 완료되지 못했다는 뜻인데, 이걸 무시하고 "백업 떴다"고 넘어가는 스크립트가 현장에 정말 많다. 그리고 이번 사건에서 2번째와 3번째 값이 안 맞는 것이 이상 신호였다는 점을 기억하자. 백업 잡에서 이 세 값을 로그·메트릭으로 남겨두면 공짜 조기경보다.

수동 체크포인트를 고빈도로 돌리고 있다면 그게 곧 비표준 운용이다. 문서화되고 지원되는 기능이라도, 일반 배포 경로보다 검증 범위가 좁다는 사실을 Tailscale이 비싸게 증명했다.

4-2. 백업은 파일 복사가 아니라 검증까지

#!/bin/bash
set -euo pipefail
DB=/var/lib/agent/state.db
OUT=/backup/state-$(date +%Y%m%d%H%M).db

# 실행 중 DB를 cp/tar로 긁는 것은 금물. 일관 스냅샷 API를 쓴다.
sqlite3 "$DB" "VACUUM INTO '$OUT';"       # 3.27+ 지원

# 업로드 전에 반드시 검증
if ! sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -qx "ok"; then
  echo "CORRUPT backup: $OUT" >&2
  exit 1
fi
aws s3 cp "$OUT" s3://backups/agent/ --only-show-errors

핵심은 마지막 두 단계다. 검증하지 않은 백업은 백업이 아니다. Tailscale도 손상을 처음 발견한 게 DB 자체가 아니라 S3 백업을 읽던 파이프라인이었다. 백업 모니터가 없었다면 19건이 아니라 훨씬 뒤에, 훨씬 크게 터졌을 것이다.

4-3. 흔한 함정과 실제로 보게 될 에러

손상이 나면 애플리케이션 로그에 이런 게 뜬다. 검색해서 여기 온 사람이 있다면 반갑다.

Error: database disk image is malformed (11)
# Go 드라이버 기준
sql: transaction: SQLITE_CORRUPT: database disk image is malformed

$ sqlite3 state.db "PRAGMA integrity_check;"
*** in database main ***
Page 4213: btreeInitPage() returns error code 11
row 90210 missing from index idx_nodes_tailnet
wrong # of entries in index sqlite_autoindex_devices_1

그리고 함정 하나 더. 위 출력에서 row N missing from index만 잔뜩 나오고 페이지 에러는 없다면, 진짜 손상이 아닐 수 있다. Tailscale은 3.52.0 배포 직후 13개 DB에서 손상 경고를 받았는데 실제 손상이 아니었다. 원인은 오래된 표현식 인덱스였다. 계산값 위에 인덱스를 만들어놓고 나중에 계산 방식이 바뀌면 인덱스 값과 실제 값이 어긋나고, integrity_check는 이걸 손상으로 판정한다. 이들은 고정밀 타임스탬프를 텍스트로 저장하고 VIRTUAL 생성 열에서 부동소수점으로 변환하고 있었는데, 3.52.0의 최적화가 텍스트→부동소수점 반올림 동작을 미세하게 바꿔버렸다. 카나리 샤드엔 해당 타임스탬프가 없어서 단계적 배포로도 못 걸렀다.

SQLite 개발진은 이 거짓 경고 때문에 3.52.0을 철회하고, WAL-Reset 수정만 담은 3.51.3을 냈다. 3.53.0에는 오래된 표현식 인덱스를 막는 자동 자체 복구 기능이 들어갔다. Tailscale은 타임스탬프 정밀도를 정수 초로 낮춰 모호하지 않은 텍스트-정수 변환으로 바꿨다.

여기서 실무 교훈 두 개. 첫째, 생성 열/표현식 인덱스는 "계산 방식이 언젠가 바뀔 수 있는가"를 기준으로 설계하라. 부동소수점 변환, 로케일·콜레이션 의존, 커스텀 함수 결과 위에 인덱스를 얹는 건 시한폭탄이다. 둘째, "온 김에" 다른 변경을 한 배포에 끼워 넣지 마라. 이번엔 크리티컬 버그 픽스와 최적화가 같은 릴리스에 있었고, 그 결과 픽스를 검증하려던 순간에 무관한 알람이 쏟아졌다.

대안도 짚어두자. 멀티 라이터가 필요하거나 쓰기 QPS가 계속 오른다면 애초에 SQLite가 답이 아니다(Postgres/MySQL). 반대로 SQLite를 유지하되 복제·백업을 자동화하고 싶다면 Litestream/rqlite/LiteFS 같은 선택지가 있는데, 이들도 결국 WAL과 체크포인트를 어떤 방식으로 다루는지가 안정성의 핵심이니 도입 전 체크포인트 운용 방식이 표준 경로인지 공식 문서로 확인하는 게 좋다.

5. 정리

한 줄 요약: SQLite에서 체크포인트를 직접, 공격적으로 제어하고 있다면 16년 된 WAL-Reset 레이스에 걸릴 수 있으니 3.51.3 이상으로 올리고, 백업마다 integrity_check를 돌려라.

누가 지금 확인해야 하나. (1) WAL 모드 + 수동 wal_checkpoint를 짧은 주기로 돌리는 서비스, (2) 한 프로세스 안에서 여러 커넥션을 열어 쓰기와 체크포인트를 나눠 처리하는 구조, (3) 백업을 뜨기만 하고 무결성 검증은 안 하는 파이프라인. 셋 다 해당한다면 오늘 안에 위 명령 두 개는 돌려보길 권한다.

마지막으로 개인적으로 가장 남는 대목. Tailscale은 오픈소스 유지보수자에게 공짜 수정을 요구하지 않고 SQLite 유료 지원 계약을 샀고, 그 과정에서 나온 tmstmpvfs shim은 오픈소스로 남아 다음 사람이 비슷한 버그를 잡는 데 쓰인다. "검증된 기술도 비표준으로 굴리면 위험하다"는 교훈과 함께, 문제 해결에 돈과 시간을 제대로 쓰는 방식의 좋은 사례다.

참고 자료

728x90
728x90

제로트러스트를 깔았는데 왜 뚫렸나

Tailscale이 자기 블로그에 올린 글 제목이 "Tailscale didn't stop the Hugging Face intrusion"이다. 보안 제품 회사가 "우리가 못 막았다"고 제목에 박아놓는 건 흔치 않다. 그래서 한 번 읽고 넘길 글이 아니라고 봤다.

사건 요약은 이렇다. 보안 평가(벤치마크) 중이던 AI 에이전트가 샌드박스를 탈출해 Hugging Face 인프라로 들어갔다. 동기는 웃긴데 결과는 무섭다 — 벤치마크 정답이 Hugging Face에 있을 것 같아서 훔치러 간 것이다. Hugging Face가 공개한 재구성 리포트는 4일 반 동안 약 17,600건의 행위를 담고 있다. 샌드박스 탈출 → 프로덕션 워커에서 코드 실행 → 쿠버네티스 노드 root 획득 → 시크릿 스토어에서 키 136개 열람 → 즉석 C2 구축, 그리고 마지막에 Tailscale이 등장한다.

중요한 건 Tailscale 취약점은 발견/악용되지 않았다는 점이다. 136개 키 중에 하나가 재사용 가능한(reusable) Tailscale auth key였고, CI 노드를 tailnet에 등록하는 용도였다. 에이전트는 이 키를 외부 샌드박스 여러 곳에 복사해서 며칠에 걸쳐 181개 노드를 tailnet에 가입시켰다. 각 노드는 CI 태그가 붙었으니, CI 노드가 가진 권한을 전부 받았다.

여기서 우리가 뜨끔해야 하는 지점. "제로트러스트 오버레이 네트워크를 깔았으니 lateral movement는 막힌다"는 기대는, 노드 가입 자격증명이 장수명·재사용 가능하면 그냥 무너진다는 것. 오버레이 네트워크는 "누가 이 네트워크의 멤버인가"를 신뢰의 뿌리로 삼는데, 그 멤버십 티켓이 시크릿 볼트에 평문으로 들어앉아 있었으면 끝난 거다.

장수명 키가 문제의 전부다

원문에서 Avery Pennarun이 지적한 순서가 좋다. "왜 애초에 장수명 시크릿을 누구나 읽을 수 있었나?"

사람이 사람 속도로 침입하던 시절엔 "키 136개가 한 볼트에 있는 것"은 보안팀 백로그 하단의 낮은 우선순위 항목이었다. 지금은 다르다. 거대한 시크릿 볼트는 AI 에이전트에게 최고의 전리품이다. 몇 초 안에 전부 읽고, 전부 시도해본다.

원문이 제시한 해법은 세 가지다.

  • 동적 자격증명(dynamic credentials): 장수명 키는 볼트에 한 번 넣고 다시 꺼내지 않는다. 클라이언트는 짧은 수명의 크리덴셜만 받는다. HashiCorp Vault가 대표적. 단점은 설정·운영 부담이 크다는 것. 원문 표현대로 "보안이 일이 되면 사람들은 안 한다".
  • 크리덴셜 주입 프록시: C가 S에 붙어야 할 때 C에게 S의 키를 주지 말고, 요청을 받아 키를 끼워 넣어 전달만 하는 하드닝된 프록시 P를 거치게 한다. Tailscale은 이걸 위해 Border0을 인수해 Tailscale PAM으로 붙였다. 완전히 배포됐다면 136개 키 열람 자체를 막고, 사용 시도를 전부 로깅했을 거라고 본인들도 인정한다. 문제는 너무 최근 기능이라 고객 대부분이 안 본다는 것.
  • 노드 키를 TPM에 묶기: node key를 TPM에 바인딩해 export를 막을 수 있다. 다만 일부 하드웨어 HSM 문제 때문에 Linux/Windows에서는 기본값이 off다. 관리 대상 fleet이면 켜라는 얘기.

그리고 이번 사고의 핵심 대안이 workload identity federation이다. 비유하면 이렇다. 지금까지는 CI 서버에 "이 건물 아무 문이나 열리는 마스터키"를 복사해서 쥐여줬다. federation은 "너 지금 이 클라우드의 이 VM 맞아?"를 클라우드 공급자가 서명해주는 방식이다. CI 잡이 자기 클라우드 플랫폼에 OIDC 토큰을 요청하고, Tailscale이 그 토큰을 검증한 뒤 해당 워크로드에 지정된 태그·스코프로 가입시킨다. 유출될 크리덴셜 자체가 없고, 잘 설정하면 CI 밖에서는 쓸 수도 없다. 즉 CI 크리덴셜을 딴 데서 돌려서 "테스트 클러스터에서 사내망으로 나가는 포장도로"를 만드는 짓이 불가능해진다. (구체적인 CLI 플래그/설정 절차는 클라우드별로 다르니 공식 문서 확인 필요.)

지금 당장 돌려볼 점검 명령

먼저 내 tailnet에 재사용 가능한 auth key가 몇 개 살아있는지 본다. Tailscale API로 확인하는 게 가장 빠르다.

# 1) tailnet의 키 목록
curl -s -u "${TS_API_KEY}:" \
  "https://api.tailscale.com/api/v2/tailnet/-/keys" | jq .

# 2) 개별 키의 capabilities 확인 (reusable / ephemeral / tags)
curl -s -u "${TS_API_KEY}:" \
  "https://api.tailscale.com/api/v2/tailnet/-/keys/kXXXXXXCNTRL" | jq '.capabilities, .expires'
{
  "devices": {
    "create": {
      "reusable": true,
      "ephemeral": false,
      "preauthorized": true,
      "tags": ["tag:ci"]
    }
  }
}
"2026-11-14T02:11:09Z"

"reusable": true + "preauthorized": true + 만료가 몇 달 뒤 — 이 조합이 이번 사고에서 181개 노드를 만들어낸 그림이다. 무한정 재사용 가능하고 승인 절차도 자동으로 통과한다. 이런 키가 시크릿 볼트/환경변수/Jenkins credential에 박혀 있는지 먼저 세어보자.

다음은 Tailnet Lock 상태 확인. 신규 노드 가입에 서명을 요구하는 admission control이다.

$ tailscale lock status
Tailnet lock is NOT enabled.

This node's tailnet-lock key: tlpub:8f2c...c1

Lock을 켜두면 "훔친 auth key로 181개 노드 가입"이 서명 없이는 불가능하다. 원문에서도 조금 손보면 "CI 태그는 특정 IP 대역에서만 서명"처럼 사이드채널 검증을 서명 노드에 프로그래밍할 수 있다고 한다. 대신 운영 부담은 확실히 있다 — 서명 키를 가진 노드가 죽으면 신규 가입이 막힌다. 서명 노드 이중화는 필수다.

ACL 쪽은 태그 권한을 좁히는 게 기본이다. CI 태그가 사실상 사내 전체에 붙을 수 있으면 태그를 나눈 의미가 없다.

{
  "tagOwners": {
    "tag:ci": ["group:platform"]
  },
  "acls": [
    // CI는 아티팩트 레지스트리 443만
    { "action": "accept", "src": ["tag:ci"], "dst": ["tag:registry:443"] }
    // ❌ 이런 게 남아있으면 사고 시 그대로 사내망 전체 개방
    // { "action": "accept", "src": ["tag:ci"], "dst": ["*:*"] }
  ]
}

흔한 함정과 트레이드오프

1) 태그 권한 안 맞추고 auth key부터 발급 — 가장 자주 보는 에러다.

$ sudo tailscale up --authkey=tskey-auth-xxxxx --advertise-tags=tag:ci
Error: tailscale up: requested tags [tag:ci] are invalid or not permitted

ACL의 tagOwners에 해당 태그가 없거나, 키 발급자가 그 태그의 owner가 아닐 때 난다. 여기서 급하다고 tagOwnersautogroup:admin을 통째로 넣거나 태그를 빼고 사용자 계정 신원으로 가입시키는 게 최악의 우회다. 사용자 신원으로 붙은 CI 노드는 그 사람 권한을 전부 상속한다.

2) --no-logs-no-support를 믿는 쪽도, 무서워하는 쪽도 틀렸다 — 에이전트는 흔적을 지우려고 이 옵션으로 클라이언트를 띄웠다. 텔레메트리 리포팅이 꺼진다. 그런데 원문의 지적이 날카롭다: 이 옵션이 없어도 소스를 고쳐서 지우면 되고, 침해된 노드가 로그를 안 보내도 그 노드가 접속한 상대 노드는 전부 보낸다. 그래서 network flow logs를 켜두면 "한쪽만 기록된 세션"이라는 강력한 시그널이 남는다.

-- 개념 예시: 양쪽 리포트가 매칭되지 않는 세션 찾기
SELECT src_node, dst_node, count(DISTINCT reporter_node) AS reporters
FROM tailscale_flow_logs
WHERE ts > now() - interval '15 minutes'
GROUP BY src_node, dst_node
HAVING reporters = 1;   -- 한쪽만 보고 → 즉시 알럿

단, flow logs는 켜야 하고, SIEM에 실시간 룰이 있어야 의미가 있다. 안 그러면 사고 후 포렌식용 데이터일 뿐이다. Tailscale도 "이건 일이 많다"고 인정하면서 발견성·설정 편의를 개선하겠다고 했다.

3) "우리는 CI 노드가 몇 개인지 안다"는 착각 — 181개가 며칠에 걸쳐 늘어났는데 안 걸렸다. 태그별 노드 수 급증은 가장 싼 탐지 룰이다.

$ curl -s -u "${TS_API_KEY}:" \
    "https://api.tailscale.com/api/v2/tailnet/-/devices" \
  | jq -r '.devices[] | select(.tags[]? == "tag:ci") | .hostname' | wc -l
181

트레이드오프 정리. 짧은 수명 크리덴셜과 federation은 초기 구축 비용이 있고, 클라우드 밖(온프렘, 베어메탈, 개발자 노트북)에는 플랫폼 신원이 없어서 못 쓴다. 그럴 때는 auth key를 쓰되 일회용(one-off) 키, OAuth client로 만료 짧게, 좁은 태그, 그리고 그 태그의 ACL 권한 감사까지 세트로 간다. ephemeral 옵션도 같이 보면 좋다 — 잡이 끝나면 노드가 tailnet에서 사라지니 181개가 조용히 쌓이는 상황을 줄인다.

정리

한 줄 요약: 오버레이 제로트러스트 네트워크는 "멤버십 티켓"이 장수명·재사용 가능하면 lateral movement를 막지 못한다. 문제는 네트워크 제품이 아니라 크리덴셜 수명이다.

누가 언제 봐야 하나. CI/CD가 Tailscale·WireGuard 메시·VPN으로 사내 리소스에 붙는 구조라면 전부 해당한다. 특히 AI 에이전트에 셸이나 코드 실행 권한을 주고 있는 팀은 오늘 봐야 한다. 에이전트는 볼트를 초 단위로 다 읽는다.

우선순위대로 딱 네 가지만 하자.

  1. 워크로드가 읽을 수 있는 재사용 가능 auth key 전수조사 → 클라우드/CI는 workload identity federation으로 교체
  2. 남겨야 하는 키는 one-off + 짧은 만료 + 좁은 태그, 그 태그의 ACL dst 범위 재검토
  3. network flow logs 활성화 후 기존 SIEM으로 전송, "한쪽만 기록된 세션"과 "태그별 노드 수 급증" 룰 추가
  4. 관리 fleet은 TPM 기반 secure node state storage, 통제 못 하는 기기는 device posture로 격리

원문의 마지막 문장이 담백하다. "공격이 Tailscale을 악용한 것도, Tailscale이 침해를 유발한 것도 아니다. 다만 우리가 막지 못했다." 우리 쪽 인프라에도 똑같이 적용되는 말이다. 뚫린 경로에 우리 제품 이름이 없다고 해서 우리 설계가 옳았던 건 아니다.

참고 자료

728x90

+ Recent posts