안드로이드 보안 쪽이 문득 궁금해졌다. 폰에 깔린 앱들이 서버와 실제로 무슨 얘기를 주고받는지, 그걸 내 눈으로 직접 보고 싶었다.
그러려면 HTTPS 를 중간에서 열어야 하고, 그러려면 내 인증서를 폰이 믿게 만들어야 한다. 그런데 안드로이드는 사용자가 설치한 인증서를 앱이 기본으로 믿어 주지 않는다. 시스템 저장소에 넣어야 하고, 거기에 파일을 쓰려면 루트가 필요하다. 그래서 연구용으로 쓸 구형 갤럭시를 한 대 구했다.
Magisk 로 금방 될 줄 알았다. 앱으로 펌웨어 이미지를 패치해서 Odin 으로 구우면 끝 — 그게 요즘 표준 절차니까.
그런데.
Odin 은 초록색 PASS 를 띄웠다. 폰이 재부팅되고, 다시 리커버리로 들어가 보면 거기엔 삼성 순정 리커버리가 떠 있었다. TWRP 는 없었다.
다시 플래시했다. 또 PASS. 또 사라졌다.
이걸 이틀 했다. 2026년 8월 4~5일, 갤럭시 S10 5G(SM-G977N)를 붙들고 있었다. 주력폰이 아니라 보안 연구용으로 사둔 여분 기기다.
범인은 키 조합도, Odin 설정도, 내 손가락도 아니었다. /vendor/bin/install-recovery.sh 라는 셸 스크립트 한 개였다. 절반은 그걸 찾은 이야기, 절반은 왜 이틀이나 걸렸나에 대한 이야기다.

먼저 대가부터. 언락하면 데이터가 전부 초기화되고 Knox e-fuse 가 영구히 트립된다(warranty_bit=1). 삼성페이·보안폴더·Samsung Pass 는 그날로 끝, A/S 도 거부될 수 있고 되돌아가지 않는다. 주력폰에는 하지 마세요.
경고 하나 더. 커스텀 바이너리가 올라간 상태에서 다운로드 모드의 Lock bootloader? 화면에서 볼륨위를 누르면 영구 벽돌이다. 절대 누르지 마라.
시작하기 전에 — 이 글에 나오는 것들
안드로이드 보안 쪽을 처음 들여다볼 때 내가 제일 헷갈렸던 게 이 단어들이었다. 사전처럼 다 풀진 않고, 이 글을 읽는 데 필요한 만큼만 적는다.
| 용어 | 한 줄 | 이 글에서 하는 역할 |
|---|---|---|
| 루팅 | 안드로이드에서 root 권한을 얻는 것 | 시스템 인증서 저장소에 파일을 쓰려면 필요하다 |
| 부트로더 / 언락 | 부팅 전에 이미지 서명을 검사하는 첫 코드. 언락하면 그 검사를 강제하지 않는다 | 모든 것의 전제. 대신 Knox 가 트립된다 |
| recovery 파티션 | 평소 쓰는 안드로이드와 별개로, 복구용으로 따로 부팅되는 작은 리눅스 | 이 기기에서는 여기가 루트가 사는 자리 |
| TWRP | 대표적인 커스텀 recovery. 파티션을 마운트하고 셸을 쓸 수 있다 | /vendor 를 rw 로 마운트해 범인을 무력화한 곳 |
| Magisk | systemless root. 시스템 파티션을 안 건드리고 부팅 과정에 끼어든다 | init 을 magiskinit 으로 갈아끼운다 |
| Odin | 삼성 기기에 이미지를 굽는 윈도우 도구. 폰을 다운로드 모드로 넣고 쓴다 | PASS 를 띄우고도 결과가 사라지던 그 도구 |
| vbmeta / Verified Boot | 부팅 체인 무결성 검증(AVB). 이미지가 서명대로인지 본다 | flags 1바이트를 꺼야 커스텀 리커버리가 뜬다 |
두 가지만 풀어 쓴다.
왜 루트가 필요했나. 안드로이드는 인증서 저장소가 두 개다. 사용자가 설정에서 직접 설치한 것(사용자 CA)과, 시스템 파티션에 박혀 있는 것(시스템 CA). API 24(안드로이드 7)부터 앱은 기본적으로 사용자 CA 를 믿지 않는다. 내 인증서를 앱이 믿게 하려면 시스템 저장소에 넣어야 하고, 거기에 쓰려면 루트가 있어야 한다.
이 기기는 Android 12 라 저장소가 /system/etc/security/cacerts 라는 평범한 디렉터리였다. Android 14+ 는 불변 APEX 로 옮겨가 훨씬 까다롭다.
왜 하필 recovery 를 건드리나. 순정 recovery 는 초기화와 OTA 정도만 하는 잠긴 물건이다. TWRP 같은 커스텀 recovery 로 갈아끼우면 거기서 파티션을 마운트하고 셸을 쓸 수 있다. 게다가 이 기기는 뒤에서 보듯 Magisk 자체가 recovery 파티션에 들어간다. 그래서 이 이야기의 중심이 계속 리커버리다.
boot.img 에 램디스크가 없다 — 그래서 리커버리가 지워지면 루트도 사라진다
그 표준 절차가 왜 안 통했는지부터. Magisk 는 보통 boot.img 램디스크에 자기 init 을 끼워 넣는데, 이 기기엔 램디스크가 없다.
말로 하면 안믿기니까 순정 펌웨어를 직접 깠다. 부트 헤더를 struct 로 파싱하는 열 줄짜리 파이썬이면 된다.

| 이미지 | kernel_size | ramdisk_size | page_size | header_version |
|---|---|---|---|---|
boot.img |
45,520,908 | 0 | 2,048 | 1 |
recovery.img |
45,520,908 | 14,944,549 | 2,048 | 1 |
커널 크기는 바이트까지 같은대 램디스크만 한쪽이 0이다. Magisk 공식 문서도 램디스크 없는 기기는 recovery 에 설치하라고 못박는다. 여기서 리커버리 파티션은 곧 루트고, 리커버리가 되돌아가면 루트도 날아간다.
파티션은 boot=sda14, recovery=sda15, system=sda26, vendor=sda27, vbmeta=sda24.
이틀을 잡아먹은 가설 — “키 조합 타이밍을 놓쳤다”
나는 이 작업을 AI 와 같이 하고 있었다. AI 의 첫 가설은 “키 조합 타이밍을 놓쳤다” 였다.
나쁜 가설은 아니었다. 삼성은 실제로 키 타이밍이 까다롭고, 실패 화면이 타이밍 실패와 구분되지 않는다. 관측된 증거와 모순되지 않았다.
문제는 그다음이다. 실패할 때마다 가설이 바뀌는 게 아니라 정교해졌다. 볼륨위를 언제 놓나, 전원을 몇 초 누르나. 답은 매번 달라지는데 뿌리는 같았다.
AI 가 자신 있게 틀린 것들을 나중에 표로 정리했는데, 지금 봐도 좀 아찔하다.
| AI 의 주장 | 실제 |
|---|---|
| “vbmeta 패치가 부팅 루프의 원인이다” | 틀림. 검증 비활성 vbmeta 는 커스텀 리커버리에 필수다. 그대로 믿고 되돌렸으면 루팅은 영영 불가능했다 |
| “USB 301K 지그를 사라” | 틀림. 지그는 micro-USB 시대 물건이고 S10 5G 는 USB-C 다 |
| 다운로드 모드 키 조합 | 틀림. S20 계열 조합을 줘서 세이프 모드로 빠졌다 |
| “TWRP 진입은 볼륨위 놓는 타이밍이 관건” | 틀림. TWRP 진입에 타이밍은 필요 없다. 내가 “아까는 타이밍 안 잡아도 됐잖아”라고 한 게 맞았다 |
확신의 강도와 정확도는 별개다. 이걸 이틀에 걸쳐 배웠다.
규칙 하나만 못박자. AI 가 같은 가설의 변형을 세 번 이상 내놓으면 그 가설을 의심하라. 필요한 건 더 나은 변형이 아니라 프레임 교체다.
Odin 이 SetupConnection 에서 멈추는 별개의 문제도 있었다. 같은 PC 인데 포트를 6&1B2B7D3C&0&2(AMD USB 3.10 컨트롤러 1.20)로 바꾸니 붙었고, 8&1DC14B6F&...(1.10)에서는 매번 거기서 멈췄다. 한번 멈추면 세션이 오염되니 다운로드 모드에서 완전히 빠졌다가 새로 들어가야 한다.
이런 잡음이 겹치면 “내가 뭘 잘못하고 있다”는 프레임은 더 단단해진다.
프레임을 바꾼 한마디, 그리고 adb devices 한 줄
이틀째 새벽에 내가 던진 말은 근거가 없었다.
“뭔가 부트로더나 이미지가 복원된 건 아닐까?”
30분 만에 끝났다.
중요한 건 이게 새 정보가 아니었다는 점이다. grep -r recovery /vendor/etc/init/ 한 줄이면 나왔다. 내가 준 건 탐색 공간의 교체였다.
갇혀 있던 프레임 : "우리가 이걸 제대로 못 넣고 있다"
바꾼 프레임 : "뭔가가 이걸 되돌리고 있다"
앞쪽에서는 버튼 누르는 법을 아무리 개선해도 매 부팅 지워진다. 뒤쪽으로 옮기면 “무엇이 되돌리나”는 추적 가능한 문제가 된다.
두 프레임을 갈라줄 관측이 필요했다. 질문은 이거였다. “성공한 적이 있는데 사라진 건가, 애초에 성공한 적이 없는 건가?”
답은 adb devices 한 줄에 있었다. TWRP 가 살아 있으면 recovery product:omni_beyondx 로 나온다. 그런데 내 화면에는 unauthorized 가 찍혔다 — 순정 리커버리는 adb 인증 키가 없어서다. 성공한 적이 있었고 그 뒤에 지워진 것이었다.
범인 — /vendor 에서 매 부팅 도는 리커버리 복원 서비스
/vendor/etc/init/ 를 뒤지니 이게 있었다.
/vendor/etc/init/vendor_flash_recovery.rc
service vendor_flash_recovery /vendor/bin/install-recovery.sh
class main
oneshot
init 언어를 보자. oneshot 은 AOSP init README 정의 그대로 “종료해도 다시 띄우지 않는다”는 뜻이다. 그것뿐이다.
한 번만 도는 이유는 옆의 class main 에 있다. main 클래스는 부팅 시퀀스에서 class_start main 으로 한 번 시작된다. 둘을 합치면 이 서비스는 매 부팅 정확히 한 번 돈다. 한 번뿐이라 안전해 보이는데, 뒤집어 말하면 부팅할 때마다 반드시 한 번은 돈다.
그 스크립트 알맹이가 이거다.
# /vendor/bin/install-recovery.sh (원본, 경로·해시는 축약)
if ! applypatch --check EMMC:.../recovery:67633152:b7d67c0c...; then
applypatch --patch /vendor/recovery-from-boot.p \
--source EMMC:.../boot:57671680:32beed83... \
--target EMMC:.../recovery:67633152:b7d67c0c...
하는 일이 명확하다. recovery 해시를 순정값과 대조하고, 다르면 boot 파티션과 /vendor/recovery-from-boot.p 델타로 순정 리커버리를 다시 만들어 덮어쓴다.
인자의 67633152 가 뭔지 궁금해서 로컬 펌웨어 폴더를 봤다. recovery 이미지 파일 크기가 정확히 67,633,152 바이트였다. EMMC:경로:크기:해시 의 크기는 대조 대상 이미지의 바이트 길이다. 이미지를 통째로 대조하고 통째로 복원하니 TWRP 든 뭐든 남을 리가 없었다.
관점을 뒤집어야 한다. 이건 루팅 방해 장치라기보다 비-A/B(non-A/B) 기기의 OTA 설계다. 리커버리가 손상되면 업데이트 경로가 막히니 재생성용 델타를 /vendor 에 두고 매 부팅 점검한다. 버그가 아니라 자가 치유고, 나는 그 치유 대상이었을 뿐이다.
(recovery-from-boot.p 의 델타 포맷은 단정하지 않겠다. 그 파일을 안 뽑아둬서 확인을 못 했다.)
고리를 끊는 방법
조치는 TWRP 셸에서 했다. /vendor 를 rw 로 마운트하고 원본을 백업한 뒤 no-op 으로 갈아끼웠다.
mount -o rw /dev/block/sda27 /vendor
cp -a /vendor/bin/install-recovery.sh /vendor/bin/install-recovery.sh.orig
printf '#!/vendor/bin/sh\nexit 0\n' > /vendor/bin/install-recovery.sh
chmod 750 /vendor/bin/install-recovery.sh
chcon u:object_r:vendor_file:s0 /vendor/bin/install-recovery.sh
마지막 chcon 이 빠지면 안 된다. 파일을 새로 만들면 SELinux 컨텍스트가 달라지고, init 은 정해진 컨텍스트의 파일만 실행한다. 어긋나면 avc: denied 만 쌓인다.
지우지 않고 no-op 으로 둔 이유도 같다. 파일이 없어지는 것보다 즉시 exit 0 하는 편이 조용하다. 원본은 .orig 로 남겨뒀다.
vbmeta 1바이트 — AI 가 되돌리라고 했던 그것
앞서 말한 AVB 가 여기서 나온다. vbmeta 헤더의 flags 를 0x00 에서 0x03(VERIFICATION_DISABLED | HASHTREE_DISABLED)으로 바꿔야 하는데, 정말 1바이트인지 궁금해서 직접 비교해봤다.

cmp -l 결과가 딱 한 줄이다. 9,552바이트 파일에서 124번째 바이트(오프셋 123) 하나만 8진수 3 대 0으로 다르다. flags 는 오프셋 120의 uint32 인데 빅엔디언이라 최하위 바이트가 123에 온다.
여기서 오해하기 쉽다. 이 1바이트를 바꾸는 순간 vbmeta 서명은 깨진다. libavb 는 AvbVBMetaImageHeader 를 통째로 해시에 밀어 넣으니 flags 도 서명 대상이다.
그런데도 부팅되는 건 검증을 통과해서가 아니라 부트로더가 이미 언락돼 있어서다(flash.locked=0, verifiedbootstate=orange). 언락 상태에서는 vbmeta 서명이 강제되지 않는다. 잠긴 기기였다면 이 1바이트로 부팅이 막혔을 것이다.
그리고 이 1바이트가 아까 표의 첫 줄이다. AI 말을 믿고 되돌렸으면 TWRP 는 영영 안 떴을 거고, 나는 이 글을 못 쓰고 있을 거다.
순서가 전부였다 — TWRP 를 Magisk 로 패치해서 넣는다
원인을 알고 나니 절차는 짧았다. 순정 recovery 에 Magisk 를 넣는 방식은 이 기기에서는 끝내 안 됐고, TWRP 를 Magisk 로 패치해 넣는 쪽이 정답이었다.
① 부트로더 언락 (다운로드 모드 → 볼륨위 길게)
② 순정 펌웨어 플래시로 기준선 확보
③ vbmeta 검증 비활성화(flags 0x03) + TWRP 플래시 — Odin AP, Auto Reboot OFF
④ 곧바로 TWRP 로 부팅 ★ 시스템 부팅을 절대 거치지 말 것
⑤ TWRP 셸에서 install-recovery.sh 무력화 ← 이걸 먼저 해야 이후가 안 지워진다
⑥ TWRP 셸에서 /system rw 마운트 → CA 인증서 주입
⑦ 시스템 부팅 → Magisk 앱으로 TWRP recovery.img 패치 (리커버리 모드 체크)
⑧ 패치본을 Odin AP 슬롯에 단독 플래시 (Auto Reboot OFF)
⑨ adb reboot recovery 로 루트 부팅
Odin 에서 꼭 꺼야 하는 체크박스 하나
③과 ⑧에 적어둔 Auto Reboot OFF 가 말처럼 사소하지 않다. 글을 쓰면서 Odin 을 다시 띄워 화면을 확인해봤다. 쓴 건 배포본 기준 Odin3 v3.14.4 이고, 창 제목에는 v3.14 로 나온다. 기기는 붙이지 않았고 Start 도 누르지 않았다. 창만 열어본 것이다.

이미지 하나를 구울 때는 슬롯 다섯 개 중 AP 하나만 채운다. 아래 Options 탭이 문제의 그곳이다.

Auto Reboot 가 기본값으로 켜져 있다. 이걸 그냥 두고 구우면 플래시가 끝나는 순간 폰이 알아서 재부팅되는데, 그 재부팅이 바로 앞에서 본 일반 부팅이다. 즉 리커버리 복원 서비스가 한 번 돌고, 방금 구운 TWRP 는 그 자리에서 사라진다. 체크박스 하나가 켜져 있다는 이유로 작업이 무효가 되는 것이다.
진짜 중요한 건 ⑤의 위치다. 내용이 아니라 위치. 뒤로 미루면 그사이 일반 부팅 한 번으로 전부 날아간다. 이틀 동안 내가 반복한 게 그거였다.
Magisk 가 램디스크에 뭘 넣었나
⑦에서 Magisk 가 TWRP 이미지에 무슨 짓을 하는지 궁금해서 패치 전후 램디스크를 cpio(newc)로 파싱해 비교해봤다. (헤더 인덱스를 헷갈리면 엔트리가 딱 1개만 나온다. 물어보지 마라.)

엔트리가 3,545 → 3,554, 추가 9개에 삭제 0개. 결정적인 한 줄은 init 의 크기 변화다. 2,146,400 → 199,960 바이트.
원래 init 이 훨씬 작은 magiskinit 으로 갈아끼워졌고, 원본은 .backup/init.xz 로 남는다. 부팅하면 magiskinit 이 먼저 뜨고, 할 일을 한 뒤 진짜 init 을 복원해 넘긴다.
.backup/.magisk 는 142바이트라 열어봤다.

RECOVERYMODE=true. Magisk 앱에서 체크한 “리커버리 모드” 가 이미지 안에 이렇게 박힌다. 이게 있어야 magiskinit 이 recovery 파티션 전제로 분기를 탄다.
결과 — 부팅 경로가 곧 루트 스위치

Magisk 가 recovery 에 사는 구조라, 이 폰은 어떻게 켜느냐에 따라 루트 유무가 갈린다.
| 부팅 방법 | 결과 |
|---|---|
adb reboot recovery |
Android + Magisk (루트 있음) |
| TWRP → Reboot → System | Android + Magisk (루트 있음) |
전원 버튼만 / adb reboot |
순정 부팅, 루트 없음 |
| 볼륨위 + Bixby + 전원 (볼륨위 계속 누름) | TWRP 화면 |
adb shell su -c id 를 치면 uid=0(root) gid=0(root) groups=0(root) context=u:r:magisk:s0 가 나온다. 최종 구성은 Magisk 30.7, TWRP 3.7.0_9-1, Android 12(SDK 31), Exynos 9820.
처음에 심으려던 시스템 CA 도 파티션에 있어서, 루트 없이 켜도 그대로 동작한다. 프록시 배관과 핀닝 우회는 아직 트래픽을 잡아보지 않았으니 다음에 다뤄보겠다.
그래서 이 이야기는 구형 기기에서만 재현된다
솔직하게 한계를 적어둔다. 이 글은 전부 부트로더를 언락할 수 있는 기기에서만 성립한다. 삼성은 One UI 8(Android 16)부터 개발자 옵션에서 “OEM 잠금 해제” 항목을 코드에서 제거했고, 안티 롤백은 하드웨어 eFuse 라 하위 펌웨어로 내려갈 수도 없다.
내 주력폰 SM-S906N(Galaxy S22+)이 S906NKSS9GZE5(바이너리 9), One UI 8이다. 조사해보고 루팅 불가 판정을 내렸다. 시도조차 못 한다. 이 이야기는 S21·S10 계열 같은 구형에서만 재현된다.
마치며
갤럭시 루팅을 하다 TWRP 가 사라진다면, 순서는 이렇습니다. 리커버리로 부팅해 adb devices 부터 쳐서 실패한 건지, 성공했다가 지워진 건지를 가른다. 지워진 쪽이면 키 조합을 더 만질 이유가 없다. 곧장 /vendor/etc/init/ 를 grep 해 범인부터 무력화한다.
AI 와 같이 파는 상황이라면 신호는 하나다. 뿌리가 같은 가설이 세 번 이상 나오면 답을 더 기다릴 자리가 아니라 질문을 갈아야 하는 자리다. 근거 없는 감각도 정제하지 말고 던져라 — 근거를 만드는 건 AI 가 훨씬 잘한다.
이번 건에서 AI 는 부트 헤더 파싱도, 압축 포맷 판별도, 해시 대조도 나보다 빨랐다. 다만 프레임은 못 바꿨고, “포기하기 아깝다” 고 우기지도 않는다. 그만하자면 그만한다. 이틀째 새벽에 계속 가자고 한 판단이 없었으면 이 폰은 서랍에 들어갔을 거다. 처리량은 AI, 방향과 지속은 사람.

삽질하면서 나한테 물었던 것들
Odin 은 PASS 라는데 왜 사라지지?
플래시는 실제로 성공한 겁니다. 다음 일반 부팅에서 install-recovery.sh 가 해시를 대조하고, 다르니까 순정 리커버리를 재생성해 덮어쓴 것이죠. Odin 이 보는 건 전송 성공 여부지 그 이후가 아닙니다.
실패한 건가, 성공했다가 지워진 건가?
리커버리로 부팅한 뒤 adb devices 를 보시면 됩니다. recovery product:omni_beyondx 면 TWRP, unauthorized 면 순정 리커버리입니다(순정은 adb 인증 키가 없습니다).
Magisk 를 왜 boot 가 아니라 recovery 에 넣지?
이 기기의 boot.img 는 ramdisk_size 가 0이기 때문입니다. Magisk 는 램디스크에 magiskinit 을 끼워 넣는 방식이라 boot 에는 자리가 없습니다.
vbmeta 를 건드리면 부팅 루프 나는 거 아닌가?
이 기기에서는 반대였습니다. 검증 비활성 vbmeta(flags 0x03)가 없으면 커스텀 리커버리가 아예 안 뜹니다. 다만 그 1바이트로 서명은 깨집니다. 부팅되는 건 부트로더가 언락돼 있기 때문이고, 잠긴 기기라면 막힙니다.
이거, 요즘 갤럭시에서도 되나?
안 됩니다. One UI 8부터 “OEM 잠금 해제” 항목이 제거됐습니다. One UI 7 이하에서 업데이트가 끝난 기기(미국 통신사판 U/U1 제외)만 가능합니다.
핵심 요약
- 벤더 파티션의 리커버리 복원 스크립트가 매 부팅 딱 한 번 돌면서, 해시가 순정값과 다르면 부트 이미지와 델타 패치로 순정 리커버리를 다시 만들어 덮어쓴다. 스크립트에 박힌 숫자 67,633,152 는 대조 대상 리커버리 이미지의 바이트 길이로, 내 로컬 펌웨어 파일 크기와 정확히 같았다.
- 이 기기의 부트 이미지는 램디스크가 0바이트인데 리커버리 이미지는 14,944,549바이트다. 그래서 루트가 리커버리 파티션에 살고, 리커버리가 지워지면 루트도 같이 사라진다.
- 부팅 검증을 끄는 플래그는 9,552바이트짜리 파일에서 딱 1바이트다. 그 1바이트로 서명은 깨지지만, 부트로더를 이미 언락해 둔 상태라 부팅은 된다. 이걸 부팅 루프의 원인으로 오해해 되돌렸다면 루팅은 불가능했다.
- Magisk 로 패치한 램디스크는 엔트리가 3,545개에서 3,554개로 늘고, 부팅 첫 프로그램이 2,146,400바이트짜리에서 199,960바이트짜리로 갈린다. 원본은 압축된 채 같은 램디스크 안에 백업된다.
- 순서가 전부다. 커스텀 리커버리를 구운 직후 시스템으로 부팅하지 말고 곧장 리커버리로 들어가 복원 스크립트부터 무력화해야 한다. 같은 가설의 변형이 세 번 넘게 나오면 프레임을 갈아라 — 이 건은 그러고 30분 만에 끝났다.
참고 자료
- Magisk 공식 설치 문서 — 램디스크 없는 기기는 recovery 에 설치
- AOSP 부트 이미지 헤더 구조 —
ramdisk_size필드 정의 - AOSP init 언어 README —
class main/oneshot - Android Verified Boot (AVB) README — vbmeta flags
- AOSP Verified Boot 개요 — AVB 개요
- 비-A/B 시스템 업데이트 문서 —
recovery-from-boot.p배경 - TWRP 삼성 기기 목록 — 지원 기기 확인