어디에도 기록 안 된 줄 알았던 서브도메인이 털렸다 — @@Dh2OA

사이트 하나가 털렸다. flywithu.com 이야기는 아니고, 개인적으로 아주 작게 쓰던 기록용 사이트다. 아이들 학원비 납부 여부를 월별로 체크하는 정도의 페이지였다. 주소는 어디에도 링크를 걸거나 공유한 적이 없는 서브도메인이었다. 외부 어디에도 기록되지 않은 도메인이라 노출될 일이 없다고 생각했다.

그런데 어느 날 들어가 보니 기존 데이터는 사라졌고, 과목명 자리에 @@Dh2OA라는 정체불명의 한 줄만 남아 있었다.

학원비 관리 페이지에 @@Dh2OA라는 항목만 남아 있는 화면
남아 있던 흔적. 개인 정보는 블러 처리.

피해와 조치

  • 피해: 기존 기록 삭제, 의미 없는 항목 1건 추가
  • 복구: 주기적으로 백업해 둔 데이터로 전체 복원
  • 조치: 로그인 추가. “어차피 아무도 모르는 주소”라는 이유로 인증 없이 열어 두었던 것이 가장 큰 원인

결국 핵심은 나의 무관심이다. 그래도 궁금한 건 남는다. 어떻게 이 주소를 알았고, 왜 굳이 들어와서 이런 걸 남겼을까.

어떻게 찾아왔을까 — 추정

로그를 깊게 본 것은 아니라 확정할 수는 없다. 다만 가장 유력한 경로는 인증서 투명성(Certificate Transparency, CT) 로그다.

  • Let’s Encrypt 등으로 HTTPS 인증서를 발급받으면, 그 인증서에 들어간 도메인 이름이 공개 CT 로그에 기록됨
  • crt.sh 같은 사이트에서 누구나 조회 가능. 내가 어디에도 적지 않았어도 인증서 발급 기록에는 남아 있음 – 가능성에 대해서는 Claude 가 알려줌.
  • 스캐너 봇들은 CT 로그를 실시간으로 구독하다가 새 호스트가 생기면 수 분~수 시간 안에 찾아옴
  • DNS 자체는 “등록된 레코드 목록 보기” 기능이 없음. 이름을 알아야 조회되는 구조
  • 다만 우회 경로는 여럿: 공개 리졸버를 거친 조회를 모아 둔 passive DNS 데이터베이스, 흔한 이름을 대입해 보는 서브도메인 브루트포스, 설정이 잘못된 존 전송(AXFR), DNSSEC NSEC 레코드를 따라가는 존 워킹

즉 “주소를 숨겼다”는 생각 자체가 틀렸던 셈이다. 개별 인증서를 발급받는 순간, 그 서브도메인은 사실상 공개 목록에 올라간다. 숨기고 싶다면 와일드카드 인증서(*.example.com)를 쓰는 편이 낫지만, 그것도 보안이 아니라 약간의 은닉일 뿐이다.

왜 들어와서 이런 걸 남겼을까

아마 “왜”라고 물을 상대가 없을 가능성이 높다. 사람이 학원비 페이지를 노리고 들어왔다기보다는, 자동화된 스캐너가 지나가다 건드린 쪽에 가깝다고 본다.

  • 입력 폼 발견 → 아무 값이나 넣고 제출 → 그 값이 저장되어 다시 화면에 나오는지 확인. 저장형 XSS·인젝션 취약점을 찾는 전형적인 절차
  • 인증 없이 쓰기가 되는 페이지는 스캐너 입장에서 “쓸 만한 곳” 목록에 올라감. 이후 스팸 링크 게시나 다른 공격의 발판 후보
  • 데이터가 통째로 사라진 것도 같은 맥락으로 추정. 화면의 삭제가 단순 링크(GET 요청)였다면, 페이지의 링크를 전부 따라가는 크롤러가 지나가기만 해도 행이 하나씩 지워짐. 악의라기보다 설계 실수가 만든 결과일 가능성

마지막 항목이 개인적으로 제일 뜨끔했다. 삭제나 수정 같은 상태 변경을 GET 링크로 만들면, 사람이 아니라 봇이 누르게 된다.

정리

  • “어디에도 안 적었다”는 착각. 인증서 발급과 동시에 공개 기록에 남음
  • 인터넷에 열린 페이지는 개인용이든 아니든 봇이 찾아옴. 시간 문제일 뿐
  • 최소한의 인증은 필수. 이번에 로그인 추가
  • 삭제·수정은 GET 링크가 아니라 POST로
  • 백업 덕분에 피해는 0. 백업 습관 하나는 칭찬

작은 사이트라서 괜찮겠지, 라는 생각이 제일 위험했다.

WebSocket으로 가상 센서 구현하기

1. 들어가며

redroid로 안드로이드를 컨테이너에 띄워 쓰다 보면 금방 벽에 부딪힙니다. 화면과 입력은 원격으로 해결되지만, 카메라와 GPS, 그리고 BLE는 없습니다. 서버에는 그런 하드웨어가 처음부터 없기 때문입니다. 카메라로 코드를 비추고, 그 결과로 근처 BLE 장치의 잠금을 여는 식의 앱이라면 첫 단계부터 막힙니다. 읽을 카메라도, 말을 걸 BLE 라디오도 없으니까요.

그래서 방향을 바꿨습니다. 하드웨어는 이미 손에 있는 폰에 다 있습니다. 폰이 센서와 무선 신호를 WebSocket으로 보내고, 서버가 이를 가상 카메라, 가상 BLE 라디오, 가상 GNSS로 재현하면 됩니다. 컨테이너 안의 앱은 자기가 실제 폰에서 돌고 있다고 믿게 됩니다.

이 글은 그 구조를 정리하고, 특히 BLE를 붙이면서 겪은 “광고 패킷 홍수” 문제를 어떻게 4단계로 풀었는지 다룹니다.

2. 전체 아키텍처

flowchart TB
  subgraph C["📱 클라이언트 · RemoteController 앱"]
    direction TB
    CAM[Camera<br/>ForwardService]
    GPS[Gps<br/>ForwardService]
    BLE[ScoutService<br/>BLE]
  end
  WS(("WebSocket<br/>LAN"))
  R["🖥️ RemoteRelay :8080<br/>(redroid 컨테이너)"]
  C <-->|"카메라 · GPS 업로드 / BLE 양방향"| WS
  WS <--> R

그림 1-1. 클라이언트: 폰의 서비스들이 WebSocket 하나로 서버의 RemoteRelay에 연결

flowchart TB
  R["RemoteRelay :8080"]
  R --> CF[CameraUploadForwarder]
  R --> GF[GpsForwarder]
  R <--> BB["BleBridgeClient<br/>(Bluetooth 모듈)"]
  CF --> RH["remotecamera_helper<br/>:28554"] --> VC[가상 카메라]
  GF --> FG["fakegps_helper<br/>:2947"] --> GH[GNSS HAL]
  BB <--> GS[GattService]
  VC --> APP[["컨테이너 안 앱"]]
  GH --> APP
  GS <--> APP

그림 1-2. 서버: RemoteRelay가 기능별 helper와 가상 센서를 거쳐 컨테이너 앱으로 전달

구성 요소는 크게 둘입니다.

  • 클라이언트(RemoteController 앱): CameraForwardService, GpsForwardService, ScoutService(BLE)
  • 서버(redroid 컨테이너): RemoteRelay가 WebSocket(:8080)으로 모든 스트림을 받아 중계

서버 쪽 경로는 기능별로 나뉩니다.

  • 카메라: CameraUploadForwarder → remotecamera_helper(:28554) → 가상 카메라
  • GPS: GpsForwarder → fakegps_helper(:2947) → GNSS HAL
  • BLE: RemoteRelay → Bluetooth 모듈의 BleBridgeClient → GattService

여기에 두 가지 운영 규칙을 더했습니다.

  • 기능별 소유권: camera, gps, ble는 한 번에 한 컨트롤러만 사용. claim으로 가져오고 기존 소유자는 revoked 수신
  • 수요 기반 전송: 컨테이너 앱이 카메라를 실제로 열 때만 서버가 demand 메시지 송신, 그때부터 클라이언트 전송 시작 (DemandMonitor가 감지)
stateDiagram-v2
  [*] --> 비어있음
  비어있음 --> A소유: 컨트롤러 A claim
  A소유 --> B소유: 컨트롤러 B claim / A에 revoked
  B소유 --> A소유: 컨트롤러 A claim / B에 revoked
  A소유 --> 비어있음: A 연결 끊김
  B소유 --> 비어있음: B 연결 끊김
  note right of A소유
    camera, gps, ble
    각 기능마다 독립적으로 적용
  end note

그림 6. claim → 사용 중 → revoked 기능 소유권 상태도

수요 기반 전송은 생각보다 효과가 컸습니다. 아무도 카메라를 쓰지 않는데 JPEG를 계속 밀어 넣던 낭비가 사라졌습니다.

3. 기능별 구현

카메라와 GPS: 내 폰의 센서를 원격 폰에 빌려주기

카메라부터 보겠습니다. 한 문장으로 줄이면 “내 폰의 카메라를 원격 폰에 빌려주는 기능”입니다. 원격 폰(redroid 컨테이너)에는 카메라가 없습니다. 그래서 내 폰이 찍은 영상을 네트워크로 보내고, 원격 폰은 그 영상을 자기 카메라에서 나온 것처럼 앱에 넘겨줍니다.

원격 폰의 앱은 영상이 어디서 왔는지 모릅니다. 평소처럼 카메라를 열었을 뿐인데, 그 카메라에 내 폰이 보고 있는 장면이 들어 있는 것입니다. 앱 입장에서는 그냥 “내 카메라에 무언가가 비쳤다”일 뿐입니다.

아래 스크린샷이 그 모습입니다. 화면이 두 겹이라 처음에는 헷갈립니다. 바깥쪽 파란 테두리는 지금 손에 들고 있는 내 폰의 RemoteController 앱입니다. 안쪽 주황 테두리는 그 앱이 띄워 주는 원격 폰의 화면입니다. 원격 폰에서 QR 스캔 앱을 실행해 두고, 그 화면을 내 폰으로 다시 보고 있는 상태입니다. 그림 속 번호를 따라가면 영상이 한 바퀴 도는 길이 보입니다.

  • ① 내 폰 카메라 촬영: 원격 폰의 앱이 카메라를 여는 순간 서버가 demand 메시지 송신, 내 폰의 CameraForwardService가 실제 카메라로 촬영 시작. 앱 상단 CAM 스위치가 이 기능의 켜짐 상태
  • ② 원격 폰으로 전송: 찍은 프레임을 JPEG로 압축해 WebSocket으로 송신 → 원격 폰의 RemoteRelay → CameraUploadForwarder → remotecamera_helper(:28554) → 가상 카메라. QR 스캔 테두리 안에 보이는 QR 코드가 바로 내 폰 카메라가 보고 있는 장면
  • ③ 원격 폰 앱의 인식: 원격 폰의 QR 스캔 앱이 안드로이드 표준 카메라 API로 가상 카메라를 열고, 들어오는 프레임을 자기 카메라 입력으로 처리. 앱 코드 수정 없음
  • ④ 다시 내 폰으로: 원격 폰의 화면 전체를 다시 JPEG로 내 폰에 송신, RemoteController의 SCREEN 탭에 표시. 터치와 BACK·HOME 버튼은 반대 방향으로 원격 폰에 주입
내 폰(파란 테두리)의 RemoteController 앱 안에 원격 폰(주황 테두리)의 QR 스캔 화면이 보이고, 카메라 영상이 도는 순서가 1~4 번호로 표시된 스크린샷
① 내 폰 카메라 촬영 → ② 원격 폰의 가상 카메라로 전송 → ③ 원격 폰 앱이 자기 카메라로 인식 → ④ 원격 폰 화면이 다시 내 폰에 표시. 파란 테두리는 내 폰, 주황 테두리는 원격 폰 화면

정리하면 내 폰의 카메라 영상은 내 폰 → 원격 폰 → 다시 내 폰으로 한 바퀴를 돕니다. 스크린샷 속 QR 코드는 내 폰 카메라가 찍은 장면이 원격 폰의 앱을 거쳐 다시 내 폰 화면에 나타난 것입니다. 같은 흐름을 구성 요소 단위로 그리면 아래와 같습니다.

flowchart TB
  subgraph MY["📱 내 폰 · RemoteController 앱"]
    CAMR["실제 카메라<br/>CameraForwardService"]
    VIEW["SCREEN 탭<br/>원격 폰 화면 표시"]
  end
  subgraph RM["🖥️ 원격 폰 · redroid 컨테이너"]
    RELAY["RemoteRelay :8080"]
    HELP["remotecamera_helper :28554"]
    VCAM["가상 카메라"]
    APP["QR 스캔 앱<br/>(코드 수정 없음)"]
  end
  APP -. "⓪ 카메라 열림 → demand" .-> CAMR
  CAMR -->|"① 촬영 · ② JPEG 전송"| RELAY
  RELAY --> HELP --> VCAM
  VCAM -->|"③ 일반 카메라 API"| APP
  APP -->|"④ 화면을 JPEG로 되돌림"| VIEW
  style CAMR fill:#dbe7ff,stroke:#2563eb
  style VIEW fill:#dbe7ff,stroke:#2563eb
  style VCAM fill:#ffe6cc,stroke:#f57c00
  style APP fill:#ffe6cc,stroke:#f57c00

그림 1-3. 카메라 영상이 한 바퀴 도는 경로 (번호는 위 스크린샷과 같음)

이 경로에는 실제로 쓰면서 넣은 세부 동작이 몇 가지 있습니다.

  • 수요 기반 시작: 원격 폰의 앱이 카메라를 쓰지 않을 때는 촬영도 전송도 없음. 앱이 카메라를 닫으면 약 3초 뒤 전송 중단
  • 끊김 처리: 전송이 잠깐 끊겨도 가상 카메라가 마지막 프레임을 유지해 앱 화면이 깨지지 않음. 부팅 후 영상이 한 번도 없었을 때만 대체 그림 표시
  • 최신 프레임 우선: 원격 폰 화면을 내 폰으로 보낼 때는 최신 JPEG 한 장만 유지하고 밀린 프레임은 버림

GPS는 같은 방식이지만 더 단순합니다. 내 폰의 위치를 fakegps_helper(:2947)로 보내면, 원격 폰의 GNSS HAL이 이를 실제 위성 신호처럼 앱에 전달합니다. 위치는 한쪽으로 흘려보내기만 하면 되니 되돌아오는 경로가 없습니다.

BLE

BLE는 양방향이라 이야기가 다릅니다.

  • 스캔: 클라이언트가 실제 스캔 수행 → ble_scan_result(address, rssi, advData) 송신 → 서버 앱의 스캔 콜백으로 전달
  • GATT: 서버 앱의 connect/read/write/notify 요청 → 클라이언트로 전달 → 실제 장치와 통신 → ble_gatt_event로 결과 회신
  • MTU 247 요청: 한 번에 실어 보내는 양을 늘려 LAN 왕복 횟수 감소

핵심은 서버의 Bluetooth 모듈 안에 넣은 BleBridgeClient입니다. GattService 입장에서는 이것이 로컬 BLE 라디오처럼 보입니다. 그래서 앱 코드는 한 줄도 바꾸지 않아도 됩니다.

sequenceDiagram
  participant A as 컨테이너 앱
  participant G as GattService
  participant B as BleBridgeClient
  participant R as RemoteRelay
  participant S as ScoutService (폰)
  participant D as 실제 BLE 장치
  A->>G: connectGatt / readCharacteristic
  G->>B: GATT 요청
  B->>R: GATT 명령 (ble_connect 등)
  R->>S: WebSocket (text 레인, 우선)
  S->>D: 실제 GATT 연결 · 읽기
  D-->>S: 응답
  S-->>R: ble_gatt_event
  R-->>B: ble_gatt_event
  B-->>G: 즉시 처리 (큐 우회)
  G-->>A: onCharacteristicRead

그림 2. 앱 connect/read → GattService → BleBridgeClient → relay → ScoutService → 실제 장치 → ble_gatt_event 회신 시퀀스

4. 트러블슈팅: BLE가 매우 느려지다

증상

주변에 BLE 기기가 많은 곳에서 BLE가 눈에 띄게 느려졌습니다. 스캔 목록이 버벅이고, characteristic read 하나에 한참이 걸렸습니다. 따라가 보니 read 응답이 앞서 쌓인 광고 수백 개 뒤에서 차례를 기다리고 있었습니다.

나중에 집에서 다시 재 보니, 60초 동안 보이는 주소가 36~43개(랜덤 주소 포함)인 평범한 환경에서도 폰이 받는 광고만 초당 약 40건이었습니다. 필터가 없으면 이 40건이 하나하나 JSON 메시지가 되어 LAN을 건넙니다.

원인

원인은 세 가지가 겹친 것이었습니다.

  • LOW_LATENCY 스캔은 기기당 초당 10~100개의 광고 보고
  • 클라이언트가 이를 전부 JSON으로 변환해 그대로 송신
  • 스캔 결과와 GATT 이벤트가 같은 FIFO 사용, 서버는 소켓 읽기 스레드에서 무거운 스캔 콜백을 직접 처리

즉 꼭 도착해야 하는 GATT 응답이, 몇 초 뒤면 의미가 없어지는 광고 데이터에 막혀 있었습니다.

flowchart TB
  subgraph BEFORE["개선 전 · 단일 FIFO"]
    direction LR
    b1[광고] --> b2[광고] --> b3[광고] --> b4["… 수백 개 …"] --> b5[광고] --> b6["⏳ GATT read 응답"]
  end
  subgraph AFTER["개선 후 · 우선순위 분리"]
    direction LR
    a1["⚡ GATT read 응답"] --> a2["광고 (주소별 최신 1개)"] --> a3["광고 (주소별 최신 1개)"]
  end
  BEFORE ~~~ AFTER
  style b6 fill:#fde2e2,stroke:#d33
  style a1 fill:#dff5e3,stroke:#2a7

그림 3. 개선 전(광고 뒤에 GATT read가 대기) vs 개선 후(GATT가 앞질러 감) 타임라인 비교

개선 1: 클라이언트 ScanThrottle

보내기 전에 거르는 것이 가장 쌉니다. 판정 규칙은 이렇습니다.

  • 첫 발견은 항상 전송
  • 같은 기기는 300ms 이내 재전송 억제
  • 광고 데이터가 바뀌었거나 RSSI가 6dB 이상 변했을 때만 전송
  • 변화가 없어도 2초마다 keep-alive (기기가 사라진 것처럼 보이지 않도록)
// ScanThrottle.shouldSend 핵심 부분 (발췌)
State s = mStates.get(address);
if (s == null) { s = new State(); mStates.put(address, s); }   // 첫 발견: 전송
else {
    long since = nowMs - s.lastSentMs;
    if (since < mMinIntervalMs) return false;                    // 300ms 억제
    boolean changed = hash != s.advHash || len != s.advLen
            || Math.abs(rssi - s.lastRssi) >= mRssiDelta;        // 광고 변경, RSSI 6dB
    if (!changed && since < mRefreshMs) return false;            // 2초 keep-alive
}

광고 데이터는 바이트 배열을 통째로 비교하지 않고 해시와 길이만 기억해 둡니다. 걸러진 광고는 JSON을 만드는 단계까지 가지도 않습니다.

flowchart TD
  IN([스캔 결과 수신]) --> Q1{처음 본 기기?}
  Q1 -- 예 --> SEND([전송])
  Q1 -- 아니오 --> Q2{"마지막 전송 후<br/>300ms 이내?"}
  Q2 -- 예 --> DROP([버림])
  Q2 -- 아니오 --> Q3{광고 데이터 변경?}
  Q3 -- 예 --> SEND
  Q3 -- 아니오 --> Q4{"RSSI 변화<br/>6dB 이상?"}
  Q4 -- 예 --> SEND
  Q4 -- 아니오 --> Q5{"마지막 전송 후<br/>2초 경과?"}
  Q5 -- "예 (keep-alive)" --> SEND
  Q5 -- 아니오 --> DROP
  style SEND fill:#dff5e3,stroke:#2a7
  style DROP fill:#eee,stroke:#999

그림 4. 첫 발견 → 300ms → 데이터 변화/RSSI 6dB/2초 판정 흐름도

개선 2: 클라이언트 송신 큐

거르고 남은 것도 순서를 바꿨습니다.

  • GATT 이벤트: 순서 유지, 항상 스캔보다 먼저 송신
  • 스캔: 주소를 키로 최신 값만 유지, 이전 값은 덮어쓰기

개선 3: 서버 ClientOutbox 레인 분리

서버에서 클라이언트로 내려가는 방향도 같은 문제가 있었습니다. 큐를 데이터 성격별 레인으로 나눴습니다.

  • text: 제어 메시지와 GATT 명령, 최우선
  • scans: 주소별 최신 값
  • frame: 최신 JPEG 한 장

text를 먼저 비우고, scans와 frame은 번갈아 보내서 한쪽이 다른 쪽을 굶기지 않게 했습니다.

flowchart LR
  T["text 레인<br/>제어 · GATT<br/>순서 보장"]
  SC["scans 레인<br/>주소별 최신 값"]
  FR["frame 레인<br/>최신 JPEG 1장"]
  P{{"1순위: text 먼저 비움"}}
  RR{{"2순위: 라운드로빈"}}
  SND[["sender → WebSocket"]]
  T --> P --> SND
  SC --> RR
  FR --> RR
  RR --> SND
  style T fill:#dff5e3,stroke:#2a7

그림 5. text 우선, scans와 frame은 번갈아 sender로 가는 레인 구조

개선 4: 서버 수신 측 ScanCoalescer

마지막은 서버의 받는 쪽입니다. 소켓을 읽는 스레드가 onScanResult를 직접 부르던 구조를 끊었습니다.

  • 소켓 읽기 스레드: 스캔 결과를 큐에 넣기만 함
  • 별도 처리 스레드: 무거운 onScanResult 호출
  • GATT 이벤트: 큐를 거치지 않고 즉시 처리

공통 원칙

네 단계를 정리하고 보니 결국 원칙은 하나였습니다. 데이터를 두 종류로 나누는 것입니다.

종류예처리 방식
흘러야 하는 데이터영상, 스캔최신 값만 유지, 밀리면 버림
반드시 도달해야 하는 데이터GATT, 제어순서 보장, 항상 우선

결과

항목A: 필터 끔B: ScanThrottle
서버가 받은 ble_scan_result (초당 평균 / 최대)39.9 / 5111.9 / 17
페이로드 바이트 (초당 평균 / 최대)6,599 / 8,4361,970 / 2,818
characteristic read 지연 (중앙값 / p95, 30회)744 / 805 ms741 / 799 ms

ScanThrottle만 끄고 켜서 60초씩 비교했습니다. 서버 쪽 레인 분리와 ScanCoalescer, 폰의 GATT 우선 큐는 두 경우 모두 그대로입니다. 폰은 두 경우 모두 초당 약 40건의 광고를 받았고, 필터를 켜면 그중 30% 정도만 보냈습니다. 메시지 수와 바이트가 약 3.3분의 1로 줄었습니다.

%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#3b6fd4"}}}}%%
xychart-beta
  title "서버가 받은 ble_scan_result (초당, 60초 평균)"
  x-axis ["A: 필터 끔", "B: ScanThrottle"]
  y-axis "메시지/초" 0 --> 45
  bar [39.9, 11.9]

그림 7. 필터를 켜면 같은 광고 흐름에서 전달하는 메시지가 약 3.3분의 1로 줄어듦

반면 read 지연은 거의 같았습니다. 이 환경에서는 필터를 꺼도 폰과 서버의 대기열이 최대 1건 정도라 줄 자체가 생기지 않았습니다. 처음 문제가 됐던 붐비는 환경이 집에서는 재현되지 않은 것입니다. 그래서 이번 측정으로 확인한 것은 트래픽 감소까지입니다. 지연 개선은 예전 단일 FIFO 구조와 붐비는 곳에서 직접 비교해야 드러날 것 같습니다.

측정 조건: Galaxy S9+(LineageOS), 5GHz Wi-Fi, x86_64 redroid 컨테이너, 시나리오별 1회 측정.

5. 검증

  • 회귀 시험: 자동 시험 66 통과 / 1 실패. 실패한 1건은 앞선 시험이 열어 둔 앱 화면이 남아 있어서 생긴 시험 시작 상태 문제로, 이번 BLE 변경과는 관련 없음 (시작 전에 정리하도록 고쳤고 전체 재실행은 아직)
  • 호스트 단위 테스트: ScanCoalescerTest로 주소별 최신 값 유지와 대기열 상한 동작 검증

6. 한계와 다음 단계

네트워크 상태가 나쁘면 GATT 지연이 그대로 앱에 드러납니다. 기능 소유권도 한 번에 한 컨트롤러만 허용하므로, 여러 폰의 센서를 섞어 쓰는 것은 아직 안 됩니다. 측정에서 본 read 한 번에 740ms 안팎이 걸리는 것도 네트워크 왕복만으로는 설명이 안 되는 숫자라 따로 들여다볼 생각입니다.

다음으로는 붐비는 환경에서 예전 단일 FIFO 구조와 지금 구조를 직접 비교해 지연 차이를 확인하고, 남은 회귀 시험 실패 1건을 정리할 계획입니다. 웹 뷰어(클라이언트 목록, 강제 끊기, 회전)와 GApps/ARM 번역 환경 이야기는 따로 한 편으로 다루겠습니다.

하드웨어가 없는 컨테이너에 하드웨어를 “빌려주는” 일은 결국 네트워크 위에 장치를 흉내 내는 일이었습니다. 그리고 그 흉내가 자연스러우려면, 무엇을 버려도 되고 무엇은 절대 버리면 안 되는지를 먼저 정해야 했습니다.

덧붙이면, 이름은 “over LAN”이지만 같은 LAN 안에서만 되는 것은 아닙니다. 폰을 Wi-Fi 대신 5G 모바일 데이터로 붙여서도 써 봤는데, 실제로 쓰는 데는 문제가 없었습니다. LTE나 5G 정도의 연결이면 실사용에는 충분합니다.

LineageOS(Custom/Rooted Android)에서 Kbank 사용하기

나의 갤럭시 S9+를 재활용하기 위해 LineageOS를 깔았다. 덕분에 Android 10에서 13으로 업그레이드됐다.

문제는 은행앱이었다. LineageOS는 Custom OS라서, 은행앱의 루팅·변조 탐지에 그대로 걸린다. 아무 조치도 안 하면 이렇게 나온다.

해킹 위험이 감지되어 케이뱅크 앱을 종료합니다 안내
아무 조치 없이 실행한 케이뱅크

사용자단에서 이것저것 막는 게 능사는 아니다. 서버에서 검증하면 될 일을, 클라이언트에 루팅·상태 검증 로직을 잔뜩 심어놓고 사용자를 잠재적 공격자로 취급하는 셈이다.

테스트 환경

  • 기기: 갤럭시 S9+ (SM-G965F)
  • OS: LineageOS 20 (Android 13)
  • Magisk 27.0
  • 테스트 시점: 2026년 9월 기준 (은행 정책이 수시로 바뀌므로 이후엔 달라질 수 있음)

적용한 구성

  • Magisk 27.0 설치, 앱 이름 숨기기 및 Zygisk ON
  • Shamiko 모듈 설치 — DenyList 스위치는 끄고, 구성 목록에는 은행 앱만 체크
  • Play 스토어 및 Play 서비스 캐시/데이터 완전 삭제
  • Tricky Store 모듈 + Keybox 적용
  • 프레임워크 일부 수정

프레임워크 수정의 구체적인 내용은 이 글에서는 생략한다.

작업은 Claude Sonnet과 GPT(Terra) 조합으로 진행했는데, 이런 작업엔 기본적으로 협조적이지 않다.

Framework 수정 전 단계 — Magisk, Shamiko, Tricky Store 모듈 설정 등 — 는 이미 인터넷에 흔한 정보라 그런지 순순히 알려준다. 문제는 그 다음이다.

Framework를 직접 손보는 단계부터는 대놓고 발을 뺀다. 어르고 달래서 겨우 방향을 얻어내거나, 그마저 안 되면 직접 뜯어고쳐야 했다. 재미있는 건, 너무 좋은 모델(Opus나 Sol 같은)을 쓰면 의도를 파악해서 아무것도 안 한다.

그래서 한 단계 아래 모델로 내려서 진행했다.

결과

모든 동작을 검증한 것은 아니고, 일단 로그인과 이체는 동작했다.

로그인 → 이체 테스트 (개인정보는 가림)

은행 쪽 보안 솔루션은 클라이언트에서 할 수 있는 건 어느 정도 다 한 것 같다. 이 이상은 앱단(서버 쪽 판단)에서 보완해야 하지 않을까 하는 게 사견이다.

은행앱별 비교

앱LineageOS 순정Magisk 설치Magisk 모듈 적용+ Framework 수정
토스OXO
KB스타뱅킹(국민은행)XXO
케이뱅크XXXO (로그인·이체)
O: 동작 / X: 차단 / -: 해당 단계 불필요

토스는 LineageOS 순정 상태에서는 잘 돌아간다. 다만 Magisk를 설치한 순간부터는 토스도 막힌다. KB스타뱅킹은 framework 수정을 제외한 나머지 모듈만 적용하면 통과된다. 케이뱅크는 framework까지 손대야 했다.

이 정도 차이만으로도 클라이언트 상태에 얼마나 의존하느냐가 은행마다 꽤 다르다는 걸 알 수 있다.

마치며

이번 건 그냥 “된다”는 걸 확인해본 수준이고, 은행 쪽에서 동적으로 보안 정책을 계속 업데이트하는 만큼 조만간 또 막힐 가능성이 크다. 그럴 때마다 사용자단 우회로 쫓아가야 하나 싶다.

해외는 은행 업무 상당수를 그냥 웹 브라우저에서 처리한다. 물론 국내에는 관련 법률이 있지만, 그 법이 “클라이언트를 이렇게까지 통제해야 한다”고 명시하고 있는 건가 싶기는 하다.

지금 방식은 법이 요구하는 최소한이라기보다, 은행들이 알아서 더 보수적으로 잠가둔 결과에 가깝지 않을까.

※ 루팅 및 보안 우회는 기기 보증, 은행 약관 등에 영향을 줄 수 있으며 모든 책임은 본인에게 있다. 이 글은 동작 여부를 기록한 것일 뿐 권장하는 것이 아니다.

참고 자료

테슬라 모델Y vs 팰리세이드 — 차가 운전자에게 말을 거는 방식 (앰비언트 라이트, HUD)

팰리세이드에서 모델Y로 바꾸고 가장 크게 달라진 건 성능이 아니라 알림이었다. 사각지대를 앰비언트 라이트로 알려주는 방식은 왜 그렇게 좋았고, HUD 없는 테슬라는 왜 답답한가. Euro NCAP 점수까지 찾아봤다.

Read more

Setting Up xrdp with Korean Font Support on Ubuntu 24.04

If you've just spun up a fresh Ubuntu server and want a full desktop you can reach over RDP — with proper Korean (Hangul) rendering and Chrome ready to go — here's the complete, copy-paste-friendly walkthrough. Every command below uses environment variables for the server IP, the new user's name, and password, so you can…

Read more

차량 판매 경험(헤이딜러) : 팰리세이드와 미니 이야기

팰리세이드와 미니를 정리하면서 현대 인증중고차, 엔카, 헤이딜러를 모두 거쳤습니다. 3년째 미뤄지는 안전벨트 리콜, 일반 경매와 역경매를 쓰는 순서, 감가율 비교, 그리고 가장 귀찮았던 명의이전과 단기보험 이야기.

Read more

정지선에서도 신호등이 보이게: trafficlight 앱을 만든 이유

📱 Google Play에서 trafficlight 앱 다운로드 (프로젝트명 LightSignal · Android 전용) ⚠️ 운전 보조 도구입니다. 신호 확인과 운전 판단의 최종 책임은 운전자에게 있습니다. 0. 서론 . 정지선에 맞춰 서면 오히려 신호등이 시야 밖으로 사라지는 구조 . 신호등이 차량 바로 위쪽 각도에 위치, 앞유리 상단 프레임에 가려짐 . 고개를 숙이거나 앞으로 밀고 나가야만 신호 확인이 가능한…

Read more

게임 머니를 파는 아이 전용 카드, ‘퍼핀’에 묻습니다

우리집은 '돈이 사라지는 감각'부터 가르쳤습니다 저는 아이에게 돈을 가르칠 때 나름의 원칙이 있었습니다. 남는 자산과 사라지는 소비를 구분하는 것입니다. 게임 아이템, 콘텐츠 결제, 오락실, 인형 뽑기처럼 손에 남는 게 없는 '소모성·무형 지출'은 되도록 현금으로만 쓰게 했습니다. 지갑에서 지폐가 줄어드는 걸 눈으로 보게 해서, "이건 한 번 쓰면 돌아오지 않는 돈"이라는 감각을 몸에 익히게 하려는 거였죠.…

Read more

Virtual Print – 설치 개선 & 활용

안녕하세요. Virtual Print 설치 과정을 단순화 했습니다.다만, 다운로드후 아래에서 More-Info 후에 강제 설치를 해야 합니다. 혹 과정이 부담스럽다면, 이전 방법도 가능합니다. 이전 방법은 아래를 참고해주세요. https://flywithu.com/archives/7941 아래 파일을 다운 및 설치 합니다. - Win64Bit 전용 입니다. SHA256: f9a09ae10258400ab47ea48dc289a561429bbbc33cfac91b8a8e9767eec947c4 검사: https://www.virustotal.com/gui/file/f9a09ae10258400ab47ea48dc289a561429bbbc33cfac91b8a8e9767eec947c4?nocache=1 NetPrinterDriverSetupDownload 2. 설치 화면 3. 프린터 드라이벙- Samsung CLX-6200 Series PS-VP VP 붙은것이 설치 된…

Read more

Virtual Print 업데이트(26.05.04)

안녕하세요.Copilot 을 활용해 기존 Virtual Print 화면을 보다 미려하고 직관적인 디자인으로 새롭게 개선했습니다. 아래 주소로 접속하시면 인쇄 내역 확인, 미리보기(Preview), PDF 다운로드 기능을 한 곳에서 편리하게 이용하실 수 있습니다. 접속 주소:  http://vp.flywithu.com:3000 사용 메뉴얼은 기존과 동일합니다. 단 ipv4 (집에서 인터넷) 환경이 안정적입니다. 핸드폰 테더링은 계속 ip주소가 변경이 됩니다.다만 프린터 드라이버의 서버 주소를 vp.flywithu.com 으로 변경해 주시길 요청드립니다.기존…

Read more