
GhostLock (CVE-2026-43499) for OPPO Find X5 Pro (PFEM10) — OPlus watchdog 및 heap-spray detector 리버스 엔지니어링
English · 中文
ColorOS 16 기반 OPPO Find X5 Pro용 GhostLock (CVE-2026-43499) 포팅. uid=0 자식 프로세스와 로드된 kernelsu.ko에 도달하며, 루트 프로세스는 가로채진다.
CVE-2026-43499 — futex PI use-after-free. remove_waiter()는 current가 requeuer일 때 current->pi_blocked_on을 지우며, 이는 rt_mutex_start_proxy_lock()의 -EDEADLK 롤백 경로에서 발생한다.
remove_waiter @ 0xffffffc0081ed254 — 패치 이전 형태.
"루트 프로세스 생존"에 관하여: evidence/kill.log의 실행들은 uid=0에 도달하고 kernelsu.ko를 로드하며, 실제로 이를 폴링한 실행에서 KernelSU 관리자 프로세스는 120초 동안 생존했고 kernelsu는 여전히 /proc/modules에서 Live 상태였다. 이후 실행에서 동일한 체인은 모듈이 여전히 Live인 상태에서 Android 프레임워크 서비스에 도달할 수 없게 만들었다(Can't find service: package/power/input/phone/wifi). 어떤 [ROOTCHECK-*] 커널 라인도, 어떤 $$sys_call_number@@ 페이로드도 결코 캡처된 적이 없으므로, 이후 실행의 상태 원인은 귀속되지 않는다. evidence/notes.md §2.3, §2.4 및 §7 참조.
task_struct
thread_info
| 필드 | 오프셋 |
|---|---|
flags |
cred
LT perf leak target task_struct → file W7 stage 1 task+0x778 = V (real_cred → private sprayed page; V observed) W7 stage 2 task+0x780 = V (cred) ★ V12_W7_VALUE=V — THE SAME VALUE, not a new page W7 stage 3 V+8 = 0 (LOCAL repair of the page that was installed, ZERO shape) LT child fexecve(memfd of loader) — no execve of a /data path loader ksud late-load → kernelsu ... Live
**2단계에는 1단계의 값을 명시적으로 전달해야 한다.** 1단계와 2단계는 각자
자체적인 spray를 가진 두 개의 독립적인 프로세스이므로, "cred 페이지를 두
슬롯 모두에 기록하라"는 것은 함정이다: 순진하게 읽으면 `(pageA, pageB)`를
생성하고, `commit_creds`는 **포인터**를 비교하기 때문에 두 쓰기가 모두
성공하더라도 그 쌍은 서로 다르다. 이것은 가상의 상황이 아니다 — 정확히
실행 3과 9에서 일어난 일이다:```
run 9 0x778 shot write value = 0xffffff88679bade0
0x780 shot write value = 0xffffff8785d6ade0 <- a different page
run 3 0x778 shot write value = 0xffffff8787b5ade0
0x780 shot write value = 0xffffff881bad2de0 <- a different page
run_bootA.sh는 따라서 V12_W7_VALUE=<stage 1's observed value>로 stage 2를 실행하며, 그 값을 복구할 수 없으면 아예 실행을 거부한다. HOLD는 stage 2보다 오래 살아 있어야 한다. 그렇지 않으면 stage 1의 페이지가 해제되고 재할당되어 "같은 값"이 dangling pointer가 된다. the same-value rule을 참조하라.
부팅당 한 페이지가 복구된다. Stage 3는 V+8을 0으로 만든다. 두 개의 서로 다른 페이지가 있을 경우, 둘 다 0으로 만들면 gid/suid 스탬프(아래)가 지워지고 divergence가 agreement처럼 보이게 되므로, runner는 실제로 설치된 페이지만 복구하고 두 값이 다르면 중단한다.
cred 페이지는 payload.c에 의해 구성된다: 8개의 id 필드 모두 0, 5개의 capability set 모두 full, 그리고 user / user_ns / group_info는 root_user / init_user_ns / init_groups를 가리킨다. Stage 3가 존재하는 이유는 write의 side effect가 설치하는 cred의 cred+8(gid/suid)을 항상 덮어쓰기 때문이다.
init_cred에 관하여 — 명시적 이분법여기 두 섹션이 서로 모순되곤 했다("절대 전역 init_cred가 아니다" vs "CONTROL=1은 cell 2를 재현한다", 그리고 cell 2는 바로 init_cred이다). 두 진술 모두 서로 다른 역할에 대해 참이다:
init_cred 포인터를 쓰면 side effect가 init_cred+8을 전역적으로 손상시킨다 — init_cred는 모든 커널 스레드가 공유하며, Uid: 0 0 4294967176 0이 바로 그 손상이다. 코드는 V12_ALLOW_INIT_CRED=1이 의도적으로 설정되지 않는 한 이 경로를 거부한다.0xffffff802a7e0be0)를 썼으므로, 구성상 real_cred == cred이다 — 그래서 execve까지 살아남았다. CONTROL=1이 이를 재현한다. 이는 control이지, 기반으로 삼을 구성이 아니다.perf leak: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
[0xffffff8400000000, 0xffffff90000000)를 허용, 투표 ≥ 15%.
UAF는 rb_erase_cached Case 1-left를 통해 구동된다. 이는 하나가 아니라 두 개의 store를 제공한다:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value`는 비트 0이 클리어된 상태에서 8바이트 정렬되어야 합니다 — 이는 `0`이거나 유효한 커널 주소입니다. **이것이 `g_boot_state`를 이 프리미티브로 설정할 수 없는 이유입니다**: `1`이 되어야 하는 바이트는 정렬 요구사항에 의해 하위 비트가 `0`으로 강제되며, `write_value`는 부작용이 발생하는 주소와 동일한 값입니다.
### 부작용은 `write_value`가 가리키는 대상에 기록됩니다
`write_value`는 *저장되는 값*이자 *부작용이 기록되는 주소*(`+8` 위치)입니다. 이를 전역 커널 객체를 가리키게 하면 해당 객체가 손상됩니다.
**W7은 정확히 이렇게 했습니다** — `write_value`를 `init_cred` 별칭을 향하게 하여 — 그리고 이는 읽기에서 확인할 수 있습니다. `out/t5_w7_778.txt`에서:```
shape shift=0 wps=5: in[0]=0xffffff802a7e0be0 (write_value) in[2]=0xffffff8800cdd178 (write_target)
W7[W7] write_target= 0xffffff8800cdd178
Uid: 0 0 4294967176 0
write_value는 init_cred 별칭이었고 write_target은 child_task+0x778이었습니다.
init_cred+8은 gid/suid이므로, 부작용으로 0xffffff8800cdd178이
거기에 저장되었습니다: init_cred.gid = 0x00cdd178 그리고 init_cred.suid = 0xffffff88 = 4294967176 — 정확히 위 Uid: 줄의 4번째 awk 필드입니다. init_cred+8을
0으로 만들자 복구되었습니다(out/t5_repair.txt: Uid: 0 0 4294967176 0 →
Uid: 0 0 0 0). 이것이 "W7 stage 3"의 전부였습니다.
이 경로는 이제 코드에서 거부됩니다. V12_W7_INIT_CRED=1은
V12_ALLOW_INIT_CRED=1도 함께 설정되지 않으면 설명과 함께 중단되며, W2/W6/LTC 경로는
private cred 페이지가 없을 때 더 이상 init_cred로 폴백하지 않고
대신 중단합니다. 기본값이자 유일하게 온전한 경로는 스프레이된 cred 페이지입니다.
부작용 자체는 피할 수 없습니다: write_value는 반드시 cred
포인터여야 하므로, cred+8은 항상 쓰기 대상으로 덮어써집니다. 선택할 수 있는 것은
그 위치뿐이며 — 그리고 복구는 이제 전역 객체에 대한 쓰기가 아니라
cred_page+8(stage 3)의 로컬 0 쓰기입니다.
잘못된
groups=출력은 이 증상이 아니라 별개의 증상입니다. 이는gid와egid가 깨끗하게 읽힌 실행에서 관찰되었으므로,init_cred+8부작용에서 비롯될 수 없으며, 가짜 cred 자체의group_info필드를 가리킵니다.evidence/notes.md§10.6을 참조하세요.
BUG_ON입니다이 프리미티브는 패스당 정확히 하나의 주소에 저장합니다. task+0x778
(real_cred)과 task+0x780 (cred)은 서로 다른 두 주소이므로, 0x778만 또는
0x780만 착륙한 쓰기는 태스크를 cred != real_cred 상태로 남깁니다 — 분기
상태입니다.
이 이미지에서 그 상태는 경고가 아니라 강성 패닉입니다. commit_creds는
BUG_ON(task->cred != task->real_cred)로 시작합니다:```
commit_creds @0xffffffc008186784
0x1867a4 ldr x19, [x20, #0x778] ; old = task->real_cred
0x1867a8 ldr x8, [x20, #0x780] ; task->cred
0x1867ac cmp x8, x19
0x1867b0 b.ne #0xffffffc008186b68
0x186b68 brk #0x800 ; == BUG()
and the kernel is built with **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` carries the same family of assertions
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
So the divergence is *latent* — it does nothing while the victim just spins —
until **any** `commit_creds` happens on that task: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, or **`execve` via `install_exec_creds`**.
> **⛔ 철회됨 (2026-09-18 늦게): 이것은 재부팅 메커니즘이 아니다.**
>
> 이 섹션의 이전 개정판에서는 이 divergence를 "재부팅의 유력한 메커니즘 후보"라고
> 불렀고 "shape split을 설명한다"고 했다. 그렇지 않으며, 그 이유는 이제 논증이
> 아니라 측정으로 확인된다:
>
> * `commit_creds`는 **`current`**에서 task를 가져온다 — `0x1867a0 mrs x20, sp_el0`.
> 시그니처는 `commit_creds(struct cred *new)`이며, task 인자가 없다.
> 따라서 divergence는 이를 **보유한** task가 스스로 `commit_creds`를 호출할 때만
> 문제가 된다.
> * 재부팅이 발생한 실행은 모두 `V12_NO_EXEC=1`이었다 (`run3_0445.log:18`,
> `run9_0606.log:20`, `run10_0616.log:24`에 그대로 명시됨). 따라서 victim은
> `execve`를 전혀 발행하지 않았고 `commit_creds`에 도달하지도 않았다.
> * Run 10에는 poke가 전혀 없었다 (`grep -c poke` = 0).
> * `exit_creds`는 `put_cred` 전에 **두** 포인터를 모두 null로 만든다
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`). 따라서
> victim의 `_exit(0)`은 divergence에 걸리는 대신 이를 **지워버린다**.
>
> ⇒ 그 실행들에서 divergence는 **무해(inert)**했다. `BUG_ON`은 실재하지만,
> 터지지 않은 지뢰일 뿐이다. "Shape A는 절대 재부팅하지 않는다"는 다시 상관관계로
> 돌아간다. 이 지뢰가 실제로 제약하는 것은 **laundering**이다. 왜냐하면
> `setresgid`/`setresuid`가 스스로 `commit_creds`를 호출하기 때문이다.
>
> **체인을 실제로 구분하는 양은 포인터 동등성이며, 이는 두 shot이 하나의 값을
> 써야 함을 의미한다** — 다음 하위 섹션을 참조.
### ★★★ 두 shot은 *동일한* 값을 써야 한다 — 단지 둘 다 성공하는 것으로는 부족하다
`BUG_ON`은 **포인터**를 비교한다. 둘 다 `uid 0`을 담고 있는 두 페이지는 여전히
서로 다른 객체다. 캡처가 이 구분을 구체화한다:
| chain | 0x778 shot | 0x780 shot | pointers |
|---|---|---|---|
| old (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **동일** → ksud 로드됨, manager 120초 생존 |
| new (`run_bootA.sh`) | `0xffffff88679bade0` (run 9) | `0xffffff8785d6ade0` | **상이** → 둘 다 성공해도 divergent |
`tools/t5loop.sh`는 **모든** 오프셋에 **하나의** `$ENVV`를 적용하므로,
`MODE=CRED`는 두 shot을 *구성상* 동일하게 만들었다. `run_bootA.sh`는 step 5와
step 6을 각각 빈 `$extra`로 발사했으므로, 각각 **자기 자신의** 페이지를
spray했다.
⇒ 요구사항은 **"두 shot이 동일한 값을 쓴다"**이다. `run_bootA.sh`는 이제 이를
강제한다 (`SAME_VALUE=1`, 기본값): step 6은 step 5에서 관측된 `write_value`를
그대로 재사용하며, 그 값을 복구할 수 없으면 **아예 발사를 거부한다** — 발사하면
divergent 쌍을 만들게 되기 때문이다.
⚠ `HOLD`는 두 번째 shot보다 오래 살아남아야 한다. 첫 번째 shot의 PIN 자식이
먼저 죽으면 페이지가 해제되고 재할당되어 "동일한 값"이 dangling pointer가 된다.
기본 `HOLD=20`은 **너무 짧다**; `HOLD=600`을 사용하라. 이제 `SAME_VALUE=1`일
때는 이것이 *기본값*이다 — 기존의 무조건적인 20초는 기본 구성 자체가 함정이었다
— 그리고 `SAME_VALUE=1`과 함께 명시적으로 짧은 `HOLD`를 지정하면 조용히
dangling pointer를 만드는 대신 이제 크게 경고한다.
⚠ `CONTROL=1`은 예전에 **step 5만** 변경했으므로, `(init_cred, fresh page)` —
divergent 쌍 — 을 만들어냈으며, 이 파일은 그것이 cell 2를 재현한다고 주장했다.
수정됨: 이제 두 shot 모두 `init_cred`로 설정한다. (Cell 2의 비용은 그대로다:
부작용이 `init_cred+8`을 전역적으로 손상시키며, 이것이 `Uid: 0 0 4294967176 0`이다.)
**credential을 laundering하려는 모든 것에 대한 결과**
(`setresgid` + `setresuid`로 spray된 페이지를 실제 `struct cred`로 교체):
- 메커니즘은 실재하며 검증되었다 — `commit_creds`는 `x21`을 **`task+0x778`과
`task+0x780` 모두**에 쓴다 (`0x186998` / `0x1869a0`). 따라서 한 번의 호출로
split이 영구적으로 복구된다; `prepare_creds @0xffffffc008186070`은
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`이며, 147/149는 둘 다 guard의 예외 목록에 있다.
- **그러나 그 전제조건은 "0x778 shot을 건너뛴다"의 반대다.** launder 자체가
`commit_creds`를 호출하므로, **두** 포인터가 이미 동일한 값을 보유할 때만
발행되어야 한다.
- `V12_LAUNDER=1`은 **두** 가지에 의해 게이트되며, 첫 번째는 관측이 아니다:
1. **`V12_W7_SAME_VALUE=1`** — 두 shot에 동일한 값이 주어졌다는 *출처
(provenance)* 사실. read primitive가 없으면 포인터 동일성은 관측
불가능하므로, 더 나은 userspace 검사로 대체할 수 없다; 선언해야 한다.
2. `consistent=1` — `0x780` 뷰 (`getuid()`)가 `0x778` 뷰
(`/proc/self/status` `Uid:`)와 일치함. **그 자체로는 필요조건일 뿐
충분조건이 아니다**: 둘 다 `uid 0`을 담은 두 개의 서로 다른 페이지는
포인터가 다르면서도 동일하게 읽힌다 — 이는 정확히 runner가 예전에
만들어내던 경우다. (1)이 주어지면 충분조건이 된다: 일치 + 동일한 값 ⇒
둘 다 같은 페이지에 성공적으로 기록됨.
둘 중 하나라도 검사에 실패하면 ⇒ 거부하고, 네 가지 경우의 표가 evidence에
들어간다. LT report 라인은 두 뷰 (`uid=` / `real_uid=` / `consistent=`)와
`same_value_declared=`를 출력하여 상태를 추론하지 않고 읽도록 한다.
### ★ Instrument 1 — 부작용은 target을 겨냥한 STAMP다
`*(write_value + 8) = write_target`이며, `cred+8` / `cred+0xc`는 `gid` / `suid`이므로,
하나의 8바이트 store가 둘 모두에 걸쳐 기록된다:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
That is a measurement, not a model. out/t5_w7_778.txt has
write_target = 0xffffff8800cdd178 and Uid: 0 0 4294967176 0, where
4294967176 = 0xffffff88 = hi32(write_target); notes.md §11 records the other
half, init_cred.gid = 0x00cdd178 = low32(write_target).
두 가지 용도:
task+0x778의 착지 오라클이다. /proc/<pid>/status는 real_cred = task+0x778 — 방금 설치된 바로 그 cred — 를 읽으므로, 스탬프는 사용자 공간에서 직접 읽을 수 있다. 스테이지 3 이전에 읽어라: 복구는 cred+8을 0으로 만들고 그것을 지운다 (notes.md §11의 t5_repair.txt는 성공적인 복구 전에 4294967176을, 후에 0을 읽는다).0을 읽으므로, 서로 다른 두 페이지가 모두 "일관됨"을 보고한다. 그러나 low32(T+0x778)과 low32(T+0x780)은 정확히 8만큼 다르므로, 두 페이지가 있으면 getgid() (cred에서)와 (에서)가 불일치한다 — 그리고 는 uid뿐 아니라 gid도 비교한다.⇒ V12_W7_SAME_VALUE는 두 번째 게이트이지, 유일한 게이트가 아니다. 여전히 중요하다: 스탬프는 두 부작용이 모두 발동했을 때만 구별하므로, 동일값 규칙이 그 잔여 구멍을 막는다. 그리고 게이트가 무엇인지 주목하라 — 방지기가 아니라 탐지기다. 그것은 거부할 수만 있을 뿐이며, 부팅의 나머지 동안 태스크를 분기된 상태로 남겨둔다. 동일값 규칙이 그 쌍을 올바르게 만드는 것이며, 이는 옛 체인이 가지고 있던 것이고 execve에 도달하기 위해 필요한 것이다.
probe_state는 착지 기준이 아니다이 프로젝트에서 세 번이나 틀렸다: W1은 전역에 착지하고 R을 보고했다; 런 12의 D는 cred가 아니라 전역을 겨냥했다; 그리고 런 11의 R은 착지인 것처럼 테이블에 기록되었다 (run11_w778r1_miss.txt와 런 7의 w7_w7781.txt는 줄 단위로 동형이다 — 둘 다 probe_state = R, probe_done = 0). 대상별 오라클을 사용하라:
run_bootA.sh는 이제 스테이지를 1에 스탬프를 사용한다 — 이것이 task+0x778에 대한 ROUNDS>1 재시도를 의미 있게 만든다, 실패한 라운드가 추론되는 대신 읽을 수 있기 때문이다 — 그리고 스테이지 1이 착지하지 않으면 스테이지 2를 발동하지 않는다.
⛔ "awk 필드"라고 말하라, "3번째 필드"라고 절대 말하지 마라.
uid_line은 레이블도 출력하므로 (Uid: 0 0 4294967176 0), awk의$1은"Uid:"이고 네 개의 id 값은$2..$5이다:$2=uid$3=euid$4=suid$5=fsuid. 스탬프는cred+8, 즉gid(low32)와suid(hi32)에 위치한다 — 따라서Gid:$2와Uid:****이다. 그것을 "3번째 필드"라고 부르는 것(을 세는 것이며, §11이 그렇게 표현한다)은 코드가 을 읽도록 유도하는데, 이는 가짜 cred에서 = 이고 결코 과 같을 수 없다. 이 오프바이원이 여기에 존재했다: 는 착지한 샷에 대해 "스탬프 없음"을 반환했고, 그래서 스테이지 2가 결코 발동하지 않았고 세탁 게이트가 영원히 거부했다 — , 왜냐하면 "스탬프 없음"은 진짜 미스의 정상적인 결과이기도 하기 때문이다.
알려진 양성 샘플에 대해 결코 테스트되지 않은 기준은 기준이 아니라 추측이다 — 그리고 이 부류의 실패 (이 오프바이원, probe_state, dmesg -w, 빈 klog.host, 공백 리드백)는 항상 *"아무 일도 일어나지 않았다"*로 나타나는데, 이는 또한 정당한 실험적 결과이기도 하다. 그래서 이 검사는 이제 두 번 방어된다:
stamp_selftest()**는 프리플라이트에서 실행되고 실패 시 exit 9하며, 게이트가 사용하는 동일한 추출 함수를 out/t5_w7_778.txt의 측정값 (write_target = 0xffffff8800cdd178 → Uid $4 = 4294967176, Gid $2 = 13488504)과 음성 및 읽을 수 없는 샘플에 대해 구동한다. 검사를 재구현하는 자체 테스트는 아무것도 증명하지 못하므로, 필드 추출은 uid_suid_field / gid_gid_field로 분리되었다.tools/test_stamp_criterion.sh — run_bootA.sh에서 실제 함수를 추출하는 독립형 회귀 테스트로서의 같은 것이다.stamp_ok()는 세 가지 상태를 반환한다, 왜냐하면 "읽을 수 없음"은 "스탬프 없음"이 아니기 때문이다 (그 혼동이 런 13을 "변화 없음"처럼 보이게 만들었다): 0 = 존재, 1 = 읽을 수 있고 스탬프 없음, 2 = 읽을 수 없음. 그리고 probe_state = D인 동안 1을 반환하면, 러너는 "착지하지 않음" 메시지 대신 ⛔ ORACLE INCONSISTENT — "가서 기준을 확인하라" — 를 출력하는데, 이는 운영자를 완전히 다른 곳 (새 부팅, 또는 적중률 사냥)으로 보낸다.
전체 유도: evidence/2026-09-18-divergence-is-latent.md
그리고 evidence/2026-09-18-cred-launder-verification.md
(후자의 §2.3은 제자리에서 철회된다). 쓰기 형태 중첩 자체 검사는 오프라인으로 닫힌다: 형태 단어들은 커널 스택의 fd_set 그리드에 있는 반면 부작용은 스프레이된 페이지 내부에 착지하므로, 둘은 어느 형태에서도 중첩될 수 없다.
세 개의 독립적인 리포터. 어느 것도 다른 것의 폴백이 아니며, 경로 1만이 호출 태스크를 죽일 수 있다.
경로 2는 자격 증명 변경이 아니라 execve에서 발동하며, 경로 1과는 별개의 코드 경로다. 그것은 kevent_send_to_user를 통해 보고하므로, 다음에 일어나는 일은 커널이 아니라 사용자 공간 데몬의 결정이다.
검사는 exec되는 이미지의 경로에 대한 것이므로, 로더의 페이로드를 memfd로 로드하는 것만으로는 충분하지 않다: 로더 자체가 /data/local/tmp에서 exec되면, 그 첫 execve가 이미 보고한다. memfd에 대한 d_path()는 /memfd:…이므로, 로더 자체가 memfd를 통해 exec되어야 한다 — 바로 이 이유로 V12_EXEC_MEMFD가 이제 기본으로 켜져 있다. 옛 동작은 RUN 4에서 볼 수 있다:```
LT child exec /data/local/tmp/glx12 (4 args) <- execve of a /data path while uid=0
LT child memfd loaded 5014624 bytes (fd=5) <- memfd only protected the second image
경로 2에 대한 Grep 가능한 마커:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
path 2와 path 3은 kevent를 통해서만 보고하므로, "커널 로그에 [ROOTCHECK-*]가 없다"는 것은 둘 중 어느 것도 발동하지 않았다는 것을 배제하지 못한다. 그 추론에는 우리가 아직 찾지 못한 userspace 수신기가 필요하다.
oplus_security_guard.kosys_enter 캐시:```asm
ldrsw x8, [x1, #0x118] ; regs->syscallno
mrs x9, sp_el0 ; current
ldr x10, [x9, #0x780] ; cred
str x8, [x9, #0xdf8]
ldr w8, [x10, #4] → str x8, [x9, #0xe00] ; uid
ldr w8, [x10, #0x14] → str x8, [x9, #0xe08] ; euid
ldr w8, [x10, #8] → str x8, [x9, #0xe10] ; gid
ldr w8, [x10, #0x18] → str x8, [x9, #0xe18] ; egid
`sys_exit` 확인:```asm
ldr x0, [x8, #0xe00] ; cached uid
cbz x0, #0x48c ; cached uid == 0 → return
adrp x9, #0 ; ldrb w9, [x9] ; g_boot_state
tbnz w9, #0, #0x48c ; is_unlocked → return
ldr x9, [x8, #0x780] ; cred
ldr w3, [x8, #0xdf8] ; cached syscallno
cmp x0, w10 ; b.hi #0x468 ; uid descending → kill path
; euid / gid / egid, same shape
ldr x9, [x8, #8] ; addr_limit
cmp x9, #0x8000000001
b.lo #0x48c ; addr_limit != KERNEL_DS → return
sub w9, w3, #0x8f ; syscallno - 143
cmp w9, #0x47
b.hi #0x4a0 ; outside 143..214 → kill
ldrsw x12, [x10, x9, lsl #2] ; jmp table @ .rodata+0
br x11
0x48c: ret
0x4a0: bl oplus_root_check_succ ; printk + kevent_send_to_user
bl oplus_root_killed ; printk + do_exit(SIGKILL)
제어 흐름 참고 (익스플로잇 순서에 중요). 네 개의 내림차순 엣지
비교는 0x468으로 곧바로 분기하며, 이는 syscall 번호 디스패치이다 — 이들은
addr_limit 게이트를 통과하지 않는다. 0x454–0x464는 어떤 id도
내려가지 않았을 때만 도달된다. 따라서 디스패치는 어떤 id가 내려갔거나
addr_limit == KERNEL_DS일 때 진입된다; 이는 addr_limit에 의해 게이팅되지 않는다.
결과:
sys_enter가
아직 이전 uid를 캐시하고 있던 syscall이다. syscall이 진입할 때 태스크가 이미
uid=0이라면 (0x400 cbz), 훅은 반환하고 그 이후로는 계속 눈이 먼 상태가
된다.delivery/外部建议评审_2026-09-18.md를
참조하라.g_boot_state — 1바이트 .data..ro_after_init, 모듈 init 시 verified_bootstate로부터
strstr을 통해 설정된다. is_unlocked() = LDRB + RET.
모듈 VA 쓰기는 폴트를 발생시킨다 (CONFIG_STRICT_MODULE_RWX=y) — physmap 별칭
0xffffff80…을 사용하라.
리포트 페이로드: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, 인덱스 143–214나머지 60개 항목 → 리포트 + 킬.
자격 증명이 변경되는 동안 스레드를 sendmsg (211)에서 블록하지 마라. 이는
테이블에 없으므로, 해당 스레드는 리포트되고 킬된다. 그 목적으로 블록된 상태로
있어도 안전한 유일한 syscall은 위의 열두 개이다:
setregid, setgid, setreuid, setuid, setresuid, setresgid,
connect, getsockname, setsockopt, shutdown, readahead, brk.
정정 (2026-09-18). 이 테이블의 이전 개정판은 모든 항목을 실제 arm64 syscall 번호보다 하나 낮게 표기했다 (
146을setresuid라고 불렀으나, 이는setuid이다 —setresuid는147이다). 번호는 항상 올바랐으며, 이름만 잘못되었다. 이름은 이제 이 기기의 커널 이미지에서sys_call_table@0xffffffc00a13d8c0로부터 확인된다. 특히sendmsg(211),munmap(215),getsockopt(209),getpeername(205)은 면제가 아니다 — 자격 증명이 변경되는 동안 이들 중 어느 것에서든 스레드를 블록하는 것은 통과가 아니라 킬이다.tools/gen_exempt_table.py로 재생성하라.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, 둘 다 무조건적입니다.
`oplus_heapspray_check` — 카운터 `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, `ktime_get_real_ts64` 델타, 7개의 보고 지점(`snprintf` + `printk` + `kevent_send_to_user`), `verified_bootstate`에 따라 게이트됩니다.
### 회피
| 프리미티브 | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | 필터링되지 않음 |
| `setsockopt` level `SOL_IPV6` (41) | 필터가 `level`을 읽는 경우 필터링되지 않음 |
| `setxattr` | 항상 카운트됨 |
| `/proc/cpuinfo` | 항상 카운트됨 |
| `socket()` / `socketpair()` | 후킹되지 않음 |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | 후킹되지 않음 |
## Config```
CONFIG_CFI_CLANG=y
CONFIG_PTR_AUTH=y
CONFIG_SHADOW_CALL_STACK=y
CONFIG_STRICT_MODULE_RWX=y
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""
CONFIG_SET_FS=y
CONFIG_RANDOMIZE_BASE=y
CONFIG_RANDOMIZE_MODULE_REGION_FULL=n
CONFIG_UNMAP_KERNEL_AT_EL0=y
CONFIG_ARM64_VA_BITS=39
CONFIG_ARM64_SW_TTBR0_PAN=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
CONFIG_RANDOM_KMALLOC_CACHES=n
CONFIG_USER_NS=n
CONFIG_NF_TABLES=n
CONFIG_SYSVIPC=n
CONFIG_ANDROID_BINDER_IPC=y
CONFIG_KASAN=y
perf_event_paranoid = -1
NDK r28c. -O1 / API 26 / -D__ARM=1은 고정되어 있습니다 — 이들은 reclaim 스택 프레임 구조(delta=0 캘리브레이션)를 유지합니다. 이 중 하나라도 변경하면 기기에서 재캘리브레이션이 필요합니다.```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
수동:```bash
"$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang" \
-D__ARM=1 -O1 -Wall -Wextra -pthread -Isrc/core -Isrc/devices/pfem10 \
-o exploit_guard src/core/exploit.c
CI는 모든 푸시마다 빌드됩니다 (.github/workflows/build.yml, Ubuntu + NDK r28c, 아티팩트 exploit_guard).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## 파일```
src/core/ exploit.c payload.c payload.h fdset_map.h
src/lib/ KernelSnitch — kernelsnitch.h futex_hash.h timeutils.h utils.h
src/devices/pfem10/ pfem10_target.h
model/ model.c — host-side rtmutex chain-walk model
tools/ kdis.py kdis_ko.py kdis_ko_reloc.py gen_guard_disasm.py
gen_exempt_table.py mod_layout.py sct_dump.py
find_task_off.py slide_resolve.py
test_stamp_criterion.sh regression test for the 0x778
landing criterion (run it after
touching uid_line/gid_line)
artifacts/ guard_post_handler.s kill chain, relocations resolved
guard_relocs.txt raw .text relocation dump
guard_disasm.txt guard + heap-spray detector
guard_exempt_table.txt 72-slot jump table, real names
evidence/ kill.log notes.md — device captures and their limits
Makefile build.sh exploit build (-O1, API 26, NDK r28c)
run.sh device-side run orchestration (retry across reboots)
.github/workflows/ build.yml — cloud build + artifact
evidence/kill.log — 네 번의 root 실행에 대한 adb shell 트랜스크립트 원문: 전체 타임라인, 대상 태스크의 uid가 0이 되는 순간, kernelsu.ko 로드, 그리고 그 이후의 상태. 먼저 헤더 블록을 읽어라: 이 파일이 포함하지 않는 것과 그 이유를 나열하고 있다.
evidence/notes.md — 커널 측. 모듈 주소와 어떤 SELinux 상태에서 어떤 /proc 채널이 작동하는지; strstr 키를 포함한 전체 g_boot_state 도출; 수정된 exempt 테이블; 누락된 커널 절반을 생성할 캡처 레시피; 그리고 아직 열려 있는 항목들의 목록.
evidence/2026-09-18-bootA/ — 현재 설계의 첫 기기 실행, 13회 부팅. 재부팅은 질서 정연하며(bootreason=reboot) 패닉 라인이 한 번도 캡처된 적이 없다 — 하지만 그것을 열세 번이 아니라 한 번의 샘플로 읽어라. 재부팅한 네 번의 실행 중 두 번은 빈 klog.host를 저장했고, 한 번은 다음 부팅의 로그를 저장했으며, 오직 한 번의 윈도우만이 자기 자신의 재부팅을 그럴듯하게 포괄할 수 있다. 마찬가지로, 한 실행의 probe_state와 victim readback은 둘 다 비어 있으며(기기는 이미 사라졌다), 따라서 그 쓰기가 성공했는지에 대한 정보를 담고 있지 않다 — 빈 필드는 "변화 없음"이 아니다. 이 디렉터리는 또한 알아둘 가치가 있는 방법론적 오류를 문서화한다: dmesg -w는 이 기기에서 no-op이다(toybox가 한 번 덤프하고 종료한다), 그래서 이전 실행의 커널 로그에는 캡처 이전 이력만 담겨 있었다 — "[ROOTCHECK-*] 없음"은 아무것도 증명하지 못했다. evidence/notes.md §6에 수정된 poll-and-stream-to-host 레시피가 있다.
evidence/2026-09-18-cred-launder-verification.md
— 이 이미지 자체의 디스어셈블리(일반적인 5.10 소스가 아님)에 대한 credential-laundering 제안의 검증: commit_creds 이중 저장, prepare_creds 할당 및 측정된 sizeof(struct cred) = 0xA8, 위에서 언급한 BUG_ON(cred != real_cred) 분기 위험, 닫힌 write-shape 중첩 자체 점검, 그리고 13회 부팅에 대한 증거 커버리지 감사. §2.3은 제자리에서 철회된다 — 분기는 잠재적일 뿐, 재부팅 메커니즘이 아니다.
evidence/2026-09-18-divergence-is-latent.md
— 2차 검증. commit_creds는 current에서 태스크를 가져오고(0x1867a0 mrs x20, sp_el0), exit_creds는 put_cred 전에 두 포인터를 모두 null로 만들며, 원시 캡처는 이전 체인이 두 슬롯에 하나의 동일한 값(0xffffff802a7e0be0)을 쓴 반면 새 체인은 서로 다른 두 페이지를 썼음을 보여준다. 따라서 기준은 포인터 동등성이다 — "두 발 모두 같은 값을 쓴다"이지, "두 발 모두 성공한다"가 아니다.
postreboot_forensics.sh — 폴러에 의존하지 않는 재부팅 포렌식. 기준은 단일 조건이다: CONFIG_PSTORE_CONSOLE=y는 panic()이 kmsg_dump(KMSG_DUMP_PANIC) 시점에 콘솔 꼬리를 ramoops에 쓰도록 만든다 — 어떤 리셋보다도 먼저 — 따라서 그 후 박스가 재부팅하든 멈추든 무관하다. /sys/fs/pstore/를 가져오고, kernel BUG / __put_cred / cred.c를 grep하며, 부팅 이유 문자열을 출력한다(이력 항목에는 reboot,shell / bootloader / reboot,edl 접미사가 붙어 있었으므로, 이유는 epoch가 구분하지 못하는 행위자를 구분한다).
⚠ "깨끗한
bootreason=reboot"을 "패닉 없음"으로 읽지 마라. QCOM에서 SoC 워치독 어서트는 PMIC PON 블록을 통해 리셋되므로,panic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreason은 우리가 보유한 증거로는 하드웨어 리셋과 구분할 수 없는 자기 일관적 체인이다. 이 저장소 자체의total_17_dump_0_pmic_17은 17번의 비정상 재부팅 모두를pmic으로 귀속시키는데, 이는 정확히 워치독의 정상적인 형태이지 "커널이 아님"의 증거가 아니다.bootreason은 여기서 아무것도 좁혀주지 않는다; ramoops가 유일한 기준이다.
두 가지 전제 조건이 충족되지 않으면 스크립트의 판정은 무효다(铁律 8 — 무신호 결론은 채널이 먼저 도달 가능함이 입증되어야 한다):
/sys/fs/pstore/*는 root 전용이므로, Enforcing 하에서는 adb pull과 cat 모두 실패한다 — 그리고 "읽을 수 없음"은 "읽었는데 비어 있었음"과 동일한 출력을 낸다. 2-상태 스크립트는 한 번도 열지 않은 채널로부터 "pstore가 비어 있음 ⇒ 패닉 반증됨"을 출력한다. 따라서 스크립트는 **CHANNEL UNREACHABLE**을 내보내고(ls 실패, 또는 알려진 모든 항목이 존재하지 않는 것이 아니라 읽기에 실패), getenforce를 함께 보고한다.adb reboot 후 즉시 가져오기. 알려진 정상 재부팅이 읽을 수 있는 것을 아무것도 산출하지 못하면, 채널은 입증되지 않은 것이며 이후의 모든 "빈 pstore"는 증거가 아니다. 순서가 중요하다: 기기는 부팅 직후 레코드를 이동시키고 unlink하므로, 순서는 reboot → Permissive 획득(W1) → 즉시 스크립트 실행이다.run_bootA.sh — 그 한 번의 부팅에 대한 오케스트레이션으로, 중요한 순서대로(0x778 → 동일한 값으로 0x780 → 실제로 설치된 cred의 로컬 복구 → 확인 → 그제서야 poke). ADB=/SER=/BIN_LOCAL= 재정의 가능; SAME_VALUE=1(기본값)은 동일 값 규칙을 강제하고, LAUNDER=1은 게이트된 launder를 활성화하며, HOLD=600은 동일 값 시퀀스에 필요하다.
재시도는 단계별로 분리된다(R5/R6), 두 단계가 상반된 위험 프로파일을 가지기 때문이다:
R5는 기본값이 ROUNDS, R6는 1이다.
권장 launder 실행 — 기본 sprayed-page 경로이며, CONTROL=1이 아니다:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1`도 일관된 쌍을 만들 수 있지만, `init_cred` 포인터를 기록하면 그 부작용이 `init_cred+8`을 **전역적으로** 손상시킨다 — 그리고 "프레임워크가 죽는다"는 것은 감시 대상 중 하나이므로, 장치 전체에 걸친 결함을 측정의 배경으로 끌고 들어가면 이 실행이 존재하는 바로 그 판독값을 흐리게 만든다. 스프레이된 페이지 경로는 "두 발 모두 명중해야 한다"는 비용만 치르며, 이것이 바로 `R5=3`이 필요한 이유다. `CONTROL=1`은 유일하게 *입증된* 일관된 쌍이자 대조군으로 남으며, 권장 구성으로는 남지 않는다.
**어떤 페이지가 설치되었는지는 무조건 실행되는 줄에서 읽힌다.** `run_w7`은 쓰기 값을 두 줄에 출력하는데, 그중 한 줄만 무조건 실행된다:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
러너는 첫 번째 문구와 일치하도록 되어 있었기 때문에, CONTROL=1에서 추출 결과가
비어 있었고, if [ -n "$CRED" ]가 복구를 건너뛰었으며, 동일 값 결합의 자체
[ -n "$CRED" ] 항이 이를 0으로 유지했습니다 — 세탁 게이트는 영원히 거부했을
것입니다. 두 가지 모두에서 조용한 no-op이 발생한 것은 두 개의 출력 지점 중 하나만
인식한 정규식 때문이었습니다. 이제 그것과 write_target 추출(스탬프 기준의 입력)은
모두 wv_from/wt_from을 거치며, 이들은 기준 자체와 함께 회귀 테스트에서
검증됩니다.
두 스트림 모두 측정 대상보다 먼저 시작합니다. uid.stream은 스테이지 1부터
실행되고, cred.stream은 poke에서 시작하며, watch 이후가 아닙니다 — poke는
자식을 NO_EXEC 보고 루프로 풀어주는데, 이는 240 × 0.5초 = 120초이고 그 다음
_exit(0)입니다 (exploit.c: "LT child NO-EXEC mode done (120s)"), 따라서
t+~135초에 있던 예전 배치는 자식이 이미 사라진 후에 샘플링을 시작했으며, 이는
정확히 이 계측기가 존재하는 창이었습니다. 또한 러너는 stamp_selftest()가
실패하면 진행을 거부하고, 스탬프와 probe_state가 불일치할 때 "did not land" 대신
ORACLE INCONSISTENT를 출력합니다.
artifacts/guard_post_handler.s — 재배치가
채워진 킬 체인입니다. 이전 목록의 adrp x9, #0은 .data..ro_after_init이고,
bl #0x4ac는 oplus_root_check_succ입니다. 자신의 기기에서 벤더 모듈을 가져온
후 tools/gen_guard_disasm.py로 재생성하세요.
tools/kdis_ko.py — RELA는 sh_info로 매칭됩니다. 이 빌드에서는 .text 재배치가
.rela.text.<func>에 있으므로, 이름 기반 조회는 아무것도 반환하지 않습니다.
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | 참조 구현; 5.10 컴팩트 웨이터 |
| NebuSec CyberMeowfia | 원본 GhostLock 연구 |
GPL-3.0 — LICENSE 참조.
| 디바이스 | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| 커널 | 5.10.236-android12-9-o-gaf2075ad2c06 |
| 부트로더 | 잠김, green |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| 단계 |
|---|
Compact waiter 트리거 (CMP_REQUEUE_PI → EDEADLK) | 동작함 |
task_struct 유출 (perf) | 동작함 |
PI 쓰기 (8바이트; 값 = 0 또는 유효한 커널 주소) | 동작함 |
task+0x778 또는 task+0x780 단독 → Uid=root | 동작함 — 하지만 단일 필드 착지는 태스크를 분기 상태로 남기며, 이는 잠재적인 hard BUG_ON이다. 분기 위험 참조 |
| 두 필드에 하나의 값으로 쓰기 (일관된 쌍) | ❌ 스프레이된 페이지로는 결코 생성되지 않음. 전역 init_cred 별칭(09-14, CONTROL=1)으로만 관찰됨. 러너는 이제 이를 강제함(SAME_VALUE=1); 디바이스에서 실행되지 않음 |
자격 증명 세탁 (setresgid + setresuid) | V12_LAUNDER=1 뒤에 구현됨; 디바이스에서 실행되지 않음 |
kernelsu.ko 로드됨 | 동작함 |
| 루트 프로세스 생존 | ⚠ 확립되지 않음 — 아래 참조 |
| 재부팅 메커니즘 | ❌ 확립되지 않음. 하나의 후보(분기)는 이제 배제됨; 아래 참조 |
착지 기준으로서의 probe_state | ❌ 잘못됨 — 사용하지 말 것. 세 가지 반례; 아래 표 참조 |
| pstore/ramoops 패닉 채널 | ⚠ 계측기는 존재함; 채널은 결코 검증되지 않음 (아직 null 테스트 없음) |
| "피해자가 순수 사용자 공간에서 스핀함" | ⚠ 아직 판독값 없음 — uid.stream이 이제 utime/stime/nvcsw를 기록하므로 확인 가능 |
| pi 측 단일 패스 이중 쓰기 | ⚠ 확립되지 않음; pi.pc/pi.left는 fdset_map.h에서 하드코딩된 0 |
경로 A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| 필드 | 오프셋 |
|---|
real_cred / cred | 0x778 / 0x780 |
캐시된 syscallno | 0xdf8 |
캐시된 uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| 필드 | 오프셋 | 필드 | 오프셋 |
|---|
uid | 0x4 | cap_inheritable | 0x28 |
gid | 0x8 | cap_permitted | 0x30 |
suid | 0xc | cap_effective | 0x38 |
sgid | 0x10 | cap_bset | 0x40 |
euid | 0x14 | cap_ambient | 0x48 |
egid | 0x18 | ||
fsuid / fsgid | 0x1c / 0x20 |
status_gidreal_credlt_cred_ids_agree()| 대상 | 착지 오라클 |
|---|
task+0x778 | Uid: 4번째 awk 필드 = hi32(write_target) 그리고 Gid: 2번째 awk 필드 = low32(write_target) — 위의 스탬프; 스테이지 3 이전에 읽기 |
task+0x780 | 피해자 자신의 getuid() |
전역 selinux_enforcing | getenforce |
probe_state | ❌ 기준이 아니다. 기껏해야 체인에 대한 힌트일 뿐이며, 쓰기가 착지했다는 증거가 결코 아니다 |
$4notes.md$3euid0hi32(write_target)stamp_ok()| # | 훅 | 트리거 | 동작 |
|---|
| 1 | oplus_root_check_post_handler, sys_exit tracepoint | 일부 id가 하강했거나, addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); 그리고 oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler, sys_exit이지만 execve (221)에만 해당 | d_path(mm->exe_file)가 /data, /data/local/tmp, /data/nativetest, /data/nativetest64로 시작 | oplus_RWO_root_check → printk + kevent_send_to_user (do_exit 없음) |
| 3 | oplus_secure_harden kretprobes | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, SELinux 정책 재로드 | oplus_heapspray_check → kevent_send_to_user |
143 setregid | 144 setgid | 145 setreuid | 146 setuid |
|---|
147 setresuid | 149 setresgid | 203 connect | 204 getsockname |
208 setsockopt | 210 shutdown | 213 readahead | 214 brk |
| kretprobe | 후킹 대상 | 필터 |
|---|
socket_kretprobe | ip_setsockopt | regs[1] ∈ {41, 42, 48} |
socket_ip6_kretprobe | do_ipv6_setsockopt | regs[1] ∈ {41, 42} |
cpuinfo_kretprobe | cpuinfo_open | — |
setxattr_kretprobe | setxattr | — |
sepolicy_reload_kretprobe | spolicy_reload | — |
| ldr w8, [x1, #8] ; regs[1] | ||
| cmp w8, #0x29 ; 41 IP_MSFILTER | ||
| b.eq #0xd58 | ||
| cmp w8, #0x30 ; 48 MCAST_MSFILTER | ||
| b.eq #0xd60 | ||
| cmp w8, #0x2a ; 42 MCAST_JOIN_GROUP | ||
| b.ne #0xd68 ; else → return, no call | ||
| bl oplus_heapspray_check |
| 단계 | 재시도 안전성 |
|---|
R5 | 5단계, task+0x778 | 안전 — 실패하면 아무것도 설치되지 않고, 스탬프 기준이 실패한 라운드를 읽을 수 있게 만들어 주므로, 또 한 발은 그저 또 한 번의 시도일 뿐이다. R5=3은 발당 적중률을 ~p에서 ~1−(1−p)³로 끌어올린다. |
R6 | 6단계, task+0x780 | 안전하지 않고, 필요하지도 않다 — 5단계가 성공한 후에만 발동하므로, 재시도는 이미 분기된 태스크를 향해 쏘는 것이다: 두 번째의 서로 다른 페이지를 적중시킬 또 한 번의 기회일 뿐, 상승 효과는 없다. 하나가 적중하면 쌍이 완성되기 때문이다. 1로 유지하라. |