중국 내수 전용으로 나온 레노버 리전 Y700 4세대(TB322FC) 를 샀습니다. 성능은 훌륭한데 한국어가 안 됩니다. 정확히는 — 한국어를 고를 수는 있는데 고르면 중국어가 나옵니다.
1년 가까이 "이 롬엔 한국어가 없다" 고 생각하고 살았습니다. 틀렸습니다. 번역은 처음부터 다 있었고, 프레임워크가 가로채서 바꿔치기하고 있었습니다.
이 글은 그걸 부트로더를 잠근 채, 파일을 한 개도 수정하지 않고 뚫은 기록입니다. 그리고 다 끝난 뒤에 "내가 처음 찾았다" 고 쓰려다가, 검증을 돌려 보고 그게 전부 선행 연구였다는 걸 알게 된 기록이기도 합니다.
이 글에 나오는 기법 자체는 2024년 2~4월에 이미 공개돼 있었습니다. 필터의 정체도 contains("zh") 를 통과시킨다는 발상도 갓파더 님이 2024-02-17 에, 하이픈을 넣은 BCP-47 variant 형식(ko-KR-zhzhzh)은 같은 분의 2024-04-21 정리글에 먼저 나와 있습니다. 전부 뽐뿌 안드로이드탭 게시판입니다. 제가 그걸 못 찾은 채 혼자 헤맸을 뿐입니다.
제 몫은 "왜 통하는지를 AOSP 원전으로 끝까지 설명한 것" 정도입니다. 자세한 크레딧은 맨 아래 크레딧 — 먼저 찾은 사람들 에 모았습니다.
세 줄 요약
- 레노버가
Configuration.setLocales()에 "소문자zh도en_US도 아니면zh_CN으로 바꿔치기" 하는 필터를 심어 뒀습니다. 레노버 자신의 문서에 적힌 그 기능의 목적은anti crossing sell to South Korea입니다. - BCP-47 variant 를 붙인
ko-KR-zhzhz는 문자열에zh가 들어가 필터를 통과하는데, 리소스 매칭 단계는 variant 를 아예 보지 않아서 한국어 리소스가 그대로 잡힙니다. - 그래서 프로퍼티 1개(
persist.sys.locale)와 설정 1개(system_locales)면 끝납니다. 파일 수정 0건. 명령은 아래 5번에 있습니다. 단, 기법 자체는 2024년에 이미 공개돼 있던 것입니다.
아래에서 ko-KR-zhzhz 라는 variant 우회값을 씁니다. 그게 이 글의 핵심인데, 시스템 로케일만 지킵니다. 앱이 자기 화면에 평범한 ko 로케일을 걸면 필터가 런타임에 발화해 중국어로 바꿔칩니다. 카카오·넥슨 로그인 SDK 팝업이 중국어였던 게 그 때문입니다.
지금은 필터를 통째로 끄고 평범한 ko-KR 을 씁니다. 우회값이 필요 없어졌고, 키보드 모듈도 은퇴했습니다. 경위는 2편 14번에 있습니다.
아래 내용은 그 길을 찾기까지의 기록으로 남겨 둡니다. variant 가 왜 통했는지의 분석은 그대로 유효합니다.
기기와 전제
모델 Lenovo Legion Y700 4th gen / TB322FC (코드네임 s10) / SM8750P
펌웨어 TB322FC_CN_OPEN_USER_Q00041.1_W_ZUXOS_1.5.10.259_ST_260611
Android 16 / SDK 36 / 커널 6.6.118
상태 ro.boot.flash.locked=1 · verifiedbootstate=green
vbmeta.device_state=locked · veritymode=enforcing · SELinux Enforcing부트로더는 끝까지 잠근 채로 갑니다. 이게 이 글의 제약이자 재미입니다.
1. 증상 — 한국어를 고르면 중국어가 나온다
처음 단서는 카카오톡이었습니다. 앱별 언어를 한국어로 지정했더니 중국어가 됐습니다. 영어도 아니고 중국어가.
settings put system system_locales 에 온갖 값을 다 넣어 봤습니다. 재부팅하면 전부 en-US 로 되돌아갔습니다. 저는 "이 롬은 지원 로케일이 en-US 하나뿐이고, 매칭에 실패하면 영어가 아니라 중국어로 폴백한다" 고 결론 내리고 제 기기 메모에 그렇게 적어 뒀습니다. 그 메모가 1년 가까이 저를 속였습니다.
2. 첫 번째 반전 — 번역은 처음부터 다 있었다
프레임워크 리소스를 직접 까 봤습니다.
adb -d exec-out "su -c 'cat /system/framework/framework-res.apk'" > analysis/framework/framework-res.apk
$ANDROID_HOME/build-tools/36.0.0/aapt2 dump configurations analysis/framework/framework-res.apk | grep -E '^ko'
ko, ko-rKR 이 멀쩡히 있습니다. 지원 로케일 목록도 봤습니다.
$ANDROID_HOME/build-tools/36.0.0/aapt2 dump resources analysis/framework/framework-res.apk | grep -A200 'array/supported_locales' | grep -oE '"ko[a-zA-Z-]*"'
supported_locales 는 641개고 그 안에 ko-KR 이 있습니다. 번역이 없어서가 아니었습니다.
3. 범인 — setLocales() 안에 심어둔 하드코딩
framework.jar 를 baksmali 로 뜯었습니다. android/content/res/Configuration.smali 의 setLocales() 입니다.
grep -n -A2 -e ZuiAntiCrossSell -e lgsi.region -e is_test_mode -e '"zh"' -e '"en_US"' -e '"CN"' analysis/framework/Configuration.setLocales.smali
자바로 옮기면 이렇습니다. (원문 전재가 아니라 baksmali 출력에서 분기 조건만 사람이 읽을 수 있게 옮긴 재구성입니다. 상호운용성 분석에 필요한 최소 범위만 싣습니다.)
if (LgsiFeatures.enabled("ZuiAntiCrossSell")
&& "prc".equals(SystemProperties.get("ro.config.lgsi.region"))
&& !SystemProperties.getBoolean("persist.sys.lenovo.is_test_mode", false)
&& locale != null
&& !locale.toString().contains("zh") // 소문자 부분문자열만 본다
&& !locale.toString().equals("en_US")) { // en_US 만 정확히 일치로 면제
mLocaleList = new LocaleList(new Locale("zh", "CN")); // 하드코딩 치환
locale = mLocaleList.get(0);
}폴백이 아니라 치환입니다. "적당한 번역을 못 찾아서 중국어로 떨어진 것" 이 아니라, 코드가 명시적으로 new Locale("zh","CN") 을 만들어 덮어쓰고 있었습니다. 면제는 둘뿐입니다. 문자열 어딘가에 소문자 zh 가 들어 있거나, 정확히 en_US 이거나.
이걸로 1번의 모든 관측이 설명됩니다. ko-KR → 중국어. en-US → 통과. 그래서 "지원 로케일이 en-US 하나뿐" 인 것처럼 보였던 겁니다.
두 번째 반전 — 레노버는 이게 무슨 기능인지 문서로 적어 뒀습니다
LgsiFeatures.enabled("ZuiAntiCrossSell") 이 조회하는 그 feature 목록이, 사람이 읽으라고 만든 HTML 로 기기 안에 들어 있습니다. /product/etc/lgsi_build_info.html, 137KB. 글을 쓰는 내내 "아직 안 읽은 파일" 로 남겨 뒀던 건데, 열어 보니 이 글의 빈칸 두 개가 거기 있었습니다.
miner7222 님의 DynoBox 소스(fuck_lgsi.rs)에 "the OEM ships a human-readable manifest at product.img/etc/lgsi_build_info.html" 이라고 이미 적혀 있고, 테스트 픽스처에는 ["ZuiAntiCrossSell", "lenovo", "anti cross sell.", "Ported", "Enabled", "Readonly", …] 행까지 들어 있습니다. 제가 더한 건 실기기 259 펌웨어의 원문을 그대로 읽은 것 뿐입니다. 픽스처의 설명은 anti cross sell. 로 줄여져 있는데, 실제 기기의 문장은 끝까지 적혀 있습니다.
adb -d shell "su -c '/system/bin/lgsi_features dump ZuiAntiCrossSell'"; python3 analysis/lgsi/lgsi-feature.py ZuiAntiCrossSell
- feature 는 505개 입니다. DynoBox 소스에서 세던 그 숫자가 레노버 자기 파일로 확정됐습니다. 325개 Enabled / 180개 Disabled.
- 설명란이 이렇습니다 —
Enable this feature to anti crossing sell to South Korea. 매칭 실패도, 번역 누락도, 폴백 사고도 아닙니다. 한국으로 흘러가는 병행수입을 막으려고 넣은 기능이라고 만든 쪽이 직접 적어 놨습니다. 같은 줄에 이걸 선언한 설정 파일 경로(lenovo/lgsi/components/system/config/lgsi_zuianticrosssell_feature_config.xml:79)가 있고, 담당자 연락처 칸도 채워져 있습니다. 레노버 직원 개인 정보라 옮기지 않았고 캡쳐에서도 뺐습니다. LgsiFeatureInfo의 두 불리언이 뭐였는지도 여기서 끝납니다. DynoBox 는 이걸(mIsRoot, mIsActive)로 부르고 저는(defEnabled, writable)로 읽었는데,/system/bin/lgsi_features바이너리의 심볼 테이블이 답을 갖고 있었습니다.LgsiFeatureInfo::enabled(),LgsiFeatureInfo::writable(),LgsiFeatures::setEnabled(std::string, bool). 표의 열 이름도Enabled State/Writable State입니다.- 그리고 끄는 명령이 OS 에 이미 들어 있습니다.
lgsi_features enable|disable <feature>. 그런데 505개가 전부Readonly라 거부됩니다. 공식 스위치가 있는데 잠겨서 나온 겁니다. DynoBox 가 dex 를 직접 고치는 이유가 이것입니다. 런타임으로 끄는 길도 이미 공개돼 있습니다. 같은 저자의 DeZUXposed 가LgsiFeatures.enabled를 후킹해ZuiAntiCrossSell을 포함한 67개 feature 를 끕니다. 다만 LSPosed 상주가 전제라, 이 글이 택한 "아무것도 설치하지 않는" 길과는 다릅니다.
그럼 그냥 꺼버리면 되지 않나
ZuiAntiCrossSell 이라는 문자열이 기기에서 나오는 곳을 전수로 찾으면 6곳 입니다.
adb -d shell "su -c 'grep -rl ZuiAntiCrossSell /system /system_ext /product /vendor /apex /odm'"| 파일 | 정체 |
|---|---|
/system/framework/framework.jar | 로케일 필터 ① (Configuration.setLocales) |
/system/framework/services.jar | 로케일 필터 ② (ActivityTaskManagerService.updateConfiguration) |
/system/framework/arm64/boot-framework.art | 위를 미리 컴파일해 둔 부트 이미지 |
/system/lib64/liblgsi_features.so | 네이티브 feature 레지스트리 |
/apex/com.android.wifi/javalib/service-wifi.jar | feature 레지스트리의 자바 사본 |
/product/etc/lgsi_build_info.html | 위에서 본 문서 |
다섯 번째가 궁금해서 꺼내 봤습니다. 세 번째 로케일 필터가 아니라, com.lgsi.config.LgsiFeatures 클래스 자체가 wifi APEX 안에 같이 실려 있는 것이었습니다. 그리고 그 안에 답이 하나 더 있었습니다.
.method static constructor <clinit>()V
sget-object v1, Ljava/lang/Boolean;->FALSE:Ljava/lang/Boolean;
sget-object v2, Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean;
...
const-string v3, "ZuiAntiCrossSell"
invoke-virtual {v0, v3, v2}, Ljava/util/HashMap;->put(...) # v2 = TRUEfeature 테이블은 그냥 HashMap<String, Boolean> 이고, <clinit> 이 const-string 505개로 한 줄씩 채웁니다. 값은 레지스터 둘 중 하나 — v1(FALSE) 아니면 v2(TRUE). 세어 보면 TRUE 324 + FALSE 178, 앞쪽에서 레지스터 번호가 다른 세 줄을 더하면 325 / 180 으로 레노버 문서의 숫자와 정확히 같습니다. 505 라는 수가 이렇게 세 군데서 맞아떨어집니다. 레노버 HTML, DynoBox 의 JSON, 그리고 기기의 dex.
그래서 기능 하나를 끄는 건 invoke-virtual {v0, v3, v2} 를 {v0, v3, v1} 로 바꾸는 일, 즉 dex 바이트의 니블 하나를 뒤집는 일입니다. miner7222 님의 DynoBox 가 하는 게 정확히 이것이고, 저는 이번에 기기 바이트코드에서 그 자리를 눈으로 확인한 것뿐입니다.
글을 올린 뒤 framework.jar 쪽도 끝까지 뜯어 보고 두 사본이 같지 않다는 걸 알았습니다. 레지스트리의 본체(framework.jar/classes5.dex)가 채우는 건 HashMap<String, Boolean> 이 아니라 sAllFeaturesInfo: HashMap<String, LgsiFeatureInfo> 입니다. (sAllFeatures 라는 Boolean 맵도 선언돼 있지만 <clinit> 이 한 번도 채우지 않습니다.) 그리고 판정 함수가 이렇습니다.
boolean enabled() {
if (!mWritable) return mEnabled; // ← 여기서 끝난다
return SystemProperties.getBoolean("persist.lgsi." + mName, mEnabled);
}lgsi_features disable 이 거부되는 진짜 이유가 이것입니다. readonly 피처는 프로퍼티를 아예 읽지 않습니다. 끄는 스위치가 잠긴 게 아니라, 애초에 배선이 안 돼 있습니다. 뒤집어 말하면 writable 쪽 니블을 true 로 바꾸면 setprop persist.lgsi.ZuiAntiCrossSell 0 이 살아납니다.
덤으로, LgsiFeatures.enabled("ZuiAntiCrossSell") 호출지점은 2곳이 아니라 4곳입니다. 로케일 필터 2곳에 더해 PackageManagerService 에서 2곳. 로케일 필터가 둘이라는 건 맞지만, 이 feature 가 하는 일이 로케일뿐인 건 아닙니다.
이 니블은 framework.jar 안에도 있습니다(classes5.dex 오프셋 0x4dcda1, 0x34→0x33. dex adler32·SHA-1 과 ZIP CRC 양쪽까지 합쳐 33바이트면 끝납니다). 파일 크기도 안 바뀝니다. 그런데 부팅 순서가 막습니다.
odrefresh(부트 클래스패스가 바뀌면 다시 컴파일하는 쪽)는 init.rc 의 on post-fs-data 블록에서 돕니다. KernelSU 는 자기 post-fs-data 를 그 뒤에 덧붙입니다. 즉 모듈이 bind-mount 로 올려 둔 패치본을 odrefresh 는 영원히 못 봅니다.
결과는 "한 번 재컴파일하고 끝" 이 아닙니다. zygote 가 매 부팅 체크섬 불일치를 잡고 부트 이미지를 통째로 메모리에서 다시 만들며, 부트클래스패스 체크섬이 바뀌므로 설치된 모든 앱의 odex 가 무효화됩니다. 되돌릴 수는 있지만(모듈 제거, 안 되면 부팅 중 볼륨다운으로 KernelSU 안전모드) 상시로 치르는 값이 너무 큽니다. 파일을 건드리지 않은 게 취향이 아니라 계산 결과였습니다.
jar 를 안 건드리고 같은 효과를 내는 길은 둘입니다. persist.sys.lenovo.is_test_mode=true, 또는 ro.config.lgsi.region 을 prc 아닌 값으로. 조건 3개가 전부 호출 시점에 live 로 읽히므로(캐싱 없음) 하나만 뒤집어도 4곳이 전부 빠져나갑니다. 둘 다 부작용 범위를 아직 다 재지 못해서 하지 않았습니다.
ZuiAntiCrossSell 분기 앞에 또 다른 덩어리가 있습니다. PC 모드에서 호출자 패키지가 ru.yandex.searchplugin 이면 ZuiDesktop 태그로 "not allow yandex to change locals =" 를 찍고 그대로 return 해 버립니다(.line 2608~2611). 앱 하나를 이름으로 박아서 막아 둔 건데, 이건 선행 기록을 찾지 못했습니다.
필터는 services.jar 의 ActivityTaskManagerService.updateConfiguration() 에도 복제돼 있습니다. 이것도 갓파더 님이 2024-02-21 에 먼저 적었고("services.jar에 코드가 있는 걸 발견했습니다" — 196040), 같은 글이 persist.sys.lenovo.is_test_mode 게이트도 공개했습니다. forumi0721 님의 ZuiKoreanizer(2024-07)는 그걸 모듈로 구현한 쪽입니다.
그런데 두 사본은 동작이 다릅니다. 이건 제가 찾은 부분입니다. Configuration.setLocales() 쪽은 1순위 로케일 하나만 봅니다. 반면 ActivityTaskManagerService 쪽은 리스트를 끝까지 순회하고 위반을 하나라도 찾으면 단수 setLocale() 을 불러 리스트 전체를 zh_CN 하나로 붕괴시킵니다. 실용적으로 중요한 결론이 여기서 나옵니다.
ko-KR-zhzhz,ko-KR,en-US 처럼 쓰면 리스트 전체가 zh_CN 하나로 붕괴합니다. 1순위가 멀쩡해도 뒤에 섞인 그 한 줄이 전부를 중국어로 되돌립니다. 안전한 조합은 모든 원소가 각각 면제되는 것뿐입니다.
4. 우회 — variant 서브태그에 zh 를 심는다
필터가 보는 건 locale.toString() 의 소문자 부분문자열 입니다. 그러니 한국어 로케일이면서 문자열 어딘가에 zh 가 들어가면 됩니다. BCP-47 의 variant 서브태그가 그 자리입니다.
제가 쓴 값은 ko-KR-zhzhz 입니다.
필터의 판정을 그대로 옮긴 코드를 짜서 측정했습니다.
// analysis/locale/LocaleTest.java
static boolean replaced(Locale l) {
String s = l.toString();
return !s.contains("zh") && !s.equals("en_US");
}java analysis/locale/LocaleTest.java
표를 읽는 법:
| 값 | toString() | 결과 |
|---|---|---|
ko-KR | ko_KR | ❌ 치환됨 |
ko-KR-zhzhz | ko_KR_zhzhz | ✅ 통과, region 보존 |
ko-KRzhzh | ko__KRzhzh | ⚠️ 통과하지만 region 상실 |
ko-KR-x-private-zh | ko_KR_#x-private-zh | ⚠️ 자바는 통과 (뒤에서 다시 다룸) |
Locale.of(ko,KR,ZHZHZ) | ko_KR_ZHZHZ | ❌ 대문자라 contains("zh") 실패 |
· 대문자(ZHZHZ) 금지 — 네이티브 파서는 tolower() 로 알아서 소문자화하지만, 자바 Locale.toString() 은 variant 대소문자를 보존합니다.
· 4자(zhzh) 금지 — variant 자리의 4자는 well-formed 가 아니라 파서가 조용히 버립니다(ko-KR-zhzh → ko_KR). 그대로 필터에 걸립니다. 4자가 Zhzh 처럼 첫 글자만 대문자로 바뀌는 건 ko-Zhzh-KR 처럼 script 자리에 넣었을 때고, 그건 갓파더 님의 측정값입니다.
· 9자 이상 금지 — 파서가 버립니다.
· 쓸 수 있는 건 5~8자(또는 숫자로 시작하는 4자)입니다. RFC 5646 §2.1 의 variant = 5*8alphanum / (DIGIT 3alphanum) 그대로입니다.
· 엄밀히 말하면 zhzhz 는 well-formed 일 뿐 valid 하지 않습니다. §2.2.5 가 variant 는 IANA 에 등록돼야 한다고 요구하는데 zhzhz 는 등록된 적이 없습니다. 파서가 받아줄 뿐입니다.
5. 적용 — 프로퍼티 1개 + 설정 1개
# 1) 앱 1개로 먼저 탐침한다 (실패해도 무해)
adb shell 'cmd locale set-app-locales com.android.settings --locales ko-KR-zhzhz'
adb shell 'monkey -p com.android.settings -c android.intent.category.LAUNCHER 1'
adb shell 'dumpsys window windows' | grep -A20 'com.android.settings' | grep -oE 'mFullConfiguration=\{[^}]{0,60}'
# [ko_KR_zhzhz,en_US] 가 보이면 성공. [zh_CN] 이면 이 롬에선 안 되는 것
# 2) 탐침 원복 후 전체 적용
adb shell "cmd locale set-app-locales com.android.settings --locales ''"
adb shell 'su -c "setprop persist.sys.locale ko-KR-zhzhz"'
adb shell 'settings put system system_locales "ko-KR-zhzhz,en-US"'
# 3) 커밋 확인 후 재부팅 — 이 순서를 어기면 실패한다
adb shell 'getprop persist.sys.locale; settings get system system_locales'
adb rebootsettings put 직후 되읽지 않고 바로 재부팅하면 커밋되지 않아 값이 날아갑니다. 제 첫 원복이 정확히 이것 때문에 실패해서 기기가 중국어로 남았습니다. 적용이든 원복이든 반드시 되읽어 확인한 뒤에 재부팅하세요.
재부팅 후:
adb -d shell "getprop persist.sys.locale; settings get system system_locales; am get-config"



Accounts & cloud service 한 줄이 영어로 남아 있는 게 보일 겁니다. 중국어가 아니라 영어입니다. 2순위 en-US 가 일하고 있는 것으로 읽히는 한 장면입니다. 다만 한 줄로 2순위 전체를 증명할 수는 없으니, "적어도 여기서는 중국어로 떨어지지 않았다" 까지만 말하겠습니다.
6. 왜 안 깨지나 — AOSP 원전까지 내려가기
선행 기록들은 "되더라" 까지였습니다. 저는 왜 되는지가 궁금했습니다. 한국어 리소스는 ko-rKR 로 붙어 있는데 기기 config 는 zhzhz 라는 variant 를 달고 있습니다. 이게 왜 매칭될까요?
여기서 제가 처음 쓴 설명은 틀렸습니다. 저는 "ResTable_config::match() 가 리소스 쪽 variant 가 비어 있으면 비교를 건너뛴다" 고 썼는데, 원전을 열어 보니 그런 코드가 아예 없습니다.
// frameworks/base/libs/androidfw/ResourceTypes.cpp — match() 2899행, 주석 2909행
// "Don't consider country and variants when deciding matches."match() 본문에는 localeVariant 가 한 줄도 등장하지 않습니다. variant 가 비어서 통과하는 게 아니라, variant 는 매칭 단계의 심사 대상이 아닙니다.
variant 가 쓰이는 곳은 두 군데뿐입니다. 순위를 매기는 isLocaleBetterThan()(2512행)과 동률을 깨는 isLocaleMoreSpecificThan()(2318행). 그리고 둘 다 탈락시키는 힘이 없습니다. isLocaleBetterThan() 은 후보 양쪽의 variant 를 요청과 각각 비교한 뒤 localeMatches != otherMatches 일 때만 결론을 내는데, 아무 리소스도 zhzhz 를 갖고 있지 않으니 항상 무승부로 지나갑니다.
즉
zhzhz는 자바 필터를 속이는 데만 쓰이고, 리소스 선택에는 표가 없습니다. 이게 이 우회법이 깨지지 않는 구조적 이유입니다.
네이티브 파서 쪽 제약도 원전으로 확인했습니다. assignLocaleComponent()(3224행)는 subtag 길이로만 종류를 판정합니다. 1자는 u/U 만 허용하고 나머지는 전부 에러, 2–3자는 language(차 있으면 region), 4자는 script(첫 글자가 숫자면 variant), 5–8자는 variant 로 tolower() 를 거쳐 저장.
BCP-47 private-use 확장을 쓴 -x-private-zh 형식도 시도했었습니다. 공개 선행 보고를 찾지 못한, 제가 임의로 만든 값입니다(처음에 이걸 "XDA 에서 돌던 형식" 이라고 썼는데, 검증해 보니 그런 출처가 없었습니다. 제 착각이었습니다). logcat 에는 자바 필터를 통과한 기록이 남아 있었습니다. 네이티브 파서가 1자 subtag x 를 거부하는 것도 원전으로 확인했습니다. 그런데 거부의 결과는 "중단" 이 아닙니다. setBcp47Locale() 은 에러를 찍고 break 할 뿐 이미 읽은 ko 와 KR 은 버리지 않고, 이어서 computeScript() 가 Kore 까지 채웁니다. 그러면 한국어 리소스가 매칭됐어야 합니다.
-x- 를 떼어내는 AOSP 코드도 찾지 못했습니다. 왜 끝내 zh_CN 이 됐는지는 아직 모릅니다. 확실한 건 -zhzhz 는 자바·네이티브 양쪽 파서가 모두 1급으로 인정하는 형식이라 그 미지의 경로를 아예 밟지 않는다는 것뿐입니다.
7. 루팅 — 잠긴 부트로더에서 KernelSU
한글화 자체는 루트가 꼭 필요하진 않습니다(persist.sys.locale 쪽만 루트가 필요하고, system_locales 만으로 성공한 선행 보고가 둘 있습니다). 하지만 저는 다른 목적이 있었습니다. 고정 포트 상시 ADB, ZUI 메모리 정리기가 못 죽이는 Tailscale.
아래 절차는 abl 과 vbmeta 를 덮어씁니다. 이 기기에는 자동 폴백 슬롯이 없고(아래 "되돌릴 수 있게 해 두기" 참조 — _b 는 원복 경로가 아닙니다), 실패하면 EDL(9008) 복구가 유일한 길입니다. 아래 "10. 따라하기 전에 — 경고" 를 먼저 읽고, 파티션 백업을 뜬 뒤에 시작하세요.
경로는 이렇습니다. 공식 259 펌웨어를 전체 플래시하면서 ABL 만 testkey 서명본으로 교체해 AVB 키 게이트를 열고, init_boot_a 에 KernelSU v3.3.1 LKM 을 패치한 뒤 vbmeta_a 를 KernelSU 내장 testkey_rsa4096 으로 재서명했습니다.
adb -d shell "su -c id; getprop ro.boot.flash.locked; getprop ro.boot.verifiedbootstate; getprop ro.boot.vbmeta.device_state; getprop ro.boot.veritymode; getenforce"
testkey ABL 교체는 도구에 내장된 기능입니다. miner7222 님의 LTBox PR #96("Add an optional testkey bootloader step to Firmware Flash", 2026-09-04 머지)이 그대로 하는 일입니다. 취약점 클래스 자체도 학술 선례가 있습니다. JKU Linz 의 "AVBTestKeyInTheWild: Bypassing Android Verified Boot Using A Firmware Supply Chain Vulnerability on Locked Devices"(SPICES 2025). 논문은 원인을 "운영 펌웨어에 AOSP 테스트키를 쓰는 약한 서명 관행" 으로, 결과를 "잠긴 기기에서 userdata 를 지우지 않고 수정된 펌웨어를 플래시" 로 적습니다.
green 이 유지되는 이유는 정의대로입니다. AOSP 기준에서 green 은 "기기가 LOCKED 이고 사용자가 설정한 신뢰 루트를 쓰지 않는 상태" 입니다. 여기서 testkey 는 제가 등록한 키가 아니라 ABL 바이너리에 원래 박혀 있는 키라서 green 이 유지됩니다.
MEETS_DEVICE_INTEGRITY 는 "부트로더가 잠겨 있고 로드된 OS 가 인증된 제조사 이미지" 까지 요구합니다. init_boot 을 패치한 이상 뒤쪽 조건이 깨집니다. 같은 문서가 verdict 가 비는 사유로 "system compromise (such as being rooted)" 를 명시합니다. 은행·결제 앱을 전제로 따라하지 마세요.
루팅으로 얻은 것
adb -d shell "su -c 'iptables -L ZINO_ADB -n -v; echo; ip6tables -L ZINO_ADB -n -v'"
nc -z -v -w5 192.168.35.231 5555; nc -z -v -w5 100.77.183.84 5555
ADB 5555 를 고정하되 테일넷에서만 받습니다. LAN 에서 때리면 거부됩니다. IPv6 도 같은 모양으로 막아 뒀습니다. 여기가 흔히 뚫리는 자리입니다.
그리고 포트에 닿는 것과 셸을 얻는 것은 다릅니다. ro.adb.secure=1 을 그대로 뒀기 때문에, 테일넷 안에 있더라도 승인된 RSA 키(제 PC 한 대)가 아니면 unauthorized 에서 멈춥니다. 위 캡쳐에 보이는 기기 주소는 100.64.0.0/10, 테일넷 안에서만 라우팅되는 대역입니다(인터넷에서는 닿지 않습니다). 따라한다면 ro.adb.secure 는 절대 끄지 마세요 — 끄는 순간 "포트에 닿으면 루트 셸" 이 됩니다.
adb -d shell "su -c 'tail -14 /data/local/tmp/zino-persist.log'"
ZUI 의 메모리 정리기는 "최근 앱 모두 지우기" 한 번으로 VPN 포그라운드 서비스까지 UID 째 죽입니다. 안드로이드 쪽 보호(배터리 최적화 예외·대기 버킷 EXEMPTED·always-on VPN)는 전부 무시당합니다. KernelSU 모듈로 올린 워치독이 20초마다 보고 되살립니다. 위 로그는 부팅 직후 16초 구간이라 터널이 아직 안 올라온 Tailscale(프로세스는 살아 있고 tun100=no)과 아직 안 뜬 앱을 각각 되살리는 장면이고, 메모리 정리기가 UID 째 죽인 뒤의 소생은 이 캡쳐에 담기지 않았습니다.
되돌릴 수 있게 해 두기
cd ~/Dev/Android/tablet-root/backup/partitions && sha256sum -c SHA256SUMS
cd ~/Dev/Android && tree -L 1 --dirsfirst tablet-root
A/B 기기니까 "문제 생기면 _b 로 부팅하면 된다" 고 생각했는데, 감사를 돌려 보니 GPT 의 _b priority 가 0 이고 super 에 *_b 익스텐트가 하나도 없습니다. 자동 폴백도 없습니다. 안전망은 생각보다 얇았고, 실제 복구 경로는 위 백업 + EDL 뿐입니다.
8. 솔직한 결과 — 시스템은 됐고, 앱별로는 아직입니다
시스템 UI 는 끝났습니다. 그런데 실사용에서 "한국어가 안 된 앱이 많다" 는 말을 듣고 앱별로 전수조사를 했습니다. 결과는 섞여 있습니다.
잘 되는 것

유니티·언리얼 게임이 자체 로케일 로직으로 ko_KR_zhzhz 를 오해할까 걱정했는데, 띄워 본 앱 7개는 전부 한국어였습니다. 다만 전체화면 게임 8종은 띄우지 못해 표본에서 빠졌고, 띄운 것들 상당수가 한국 서비스 빌드입니다. 그래서 엄밀히 말할 수 있는 건 "variant 로케일이 ko 리소스 매칭을 깨뜨리지는 않는다" 까지입니다.
안 되는 것

레노버 자기 앱인 Legion Zone 이 완전 중국어입니다. 3회 재현했습니다. 상태바 시계가 한국어인 것과 대비돼서 더 눈에 띕니다.
분명히 해 둘 것 — 이건 이 우회법이 깨뜨린 게 아닙니다. 중국 내수용 앱이라 한국어 번역 자체가 들어 있지 않습니다. 완벽하게 한국 기기였어도 똑같이 중국어입니다. 로케일로 고칠 수 있는 문제가 아닙니다.

엔드필드는 앱별 로케일을 en-US 로 박아 뒀는데도 로그인창이 중국어로 뜹니다. 메이플M 도 넥슨 로그인 SDK 부분만 중국어고 게임 본체는 한국어입니다.

이 한 장이 지금 상태를 가장 잘 보여줍니다. 설정 화면은 터치 키보드·Android 키보드(AOSP)·영어(미국)·네이버 스마트보드 로 완전 한국어인데, Gboard 줄만 韩语 및 字母 입니다. 한국어 접속 부사 "및" 양옆에 중국어가 붙어 있습니다.
字母 는 AOSP 의 subtype_no_language_qwerty — 영어로는 "Alphabet (QWERTY)", 로케일은 zz(언어 없는 라틴 자판) — 의 간체 중국어 번역입니다. 즉 중국어 키보드가 아니라 이름만 중국어로 굳어 있는 라틴 자판입니다. IME 서브타입 이름은 프레임워크가 번역하는 게 아니라 IME 자신의 리소스나 /data/system/inputmethod/subtypes.xml 의 nameOverride 에서 옵니다. 왜 로케일을 바꾼 뒤에도 남는지는 두 설명이 경합했습니다. ① 출하 때 박힌 중국어 리터럴이 그대로 남은 것이냐, ② Gboard 가 이름을 만드는 그 순간 필터가 끼어드는 것이냐. ②가 맞았습니다. 실험으로 갈랐습니다.
먼저 파일을 직접 고쳐 봤습니다(nameOverride 를 한국어/알파벳 으로). 재부팅하니 그대로 중국어로 되돌아와 있었습니다. 그래서 부팅 로그를 봤더니 거기 있었습니다. locales updated from [ko_KR] to [zh_CN]. Gboard 가 부팅 때 자기 서브타입을 다시 등록하는데, 넘기는 로케일이 ko_KR(소문자 zh 없음, en_US 아님)이라 그 자리에서 치환되고, 중국어로 계산된 이름이 파일에 덮어써집니다. 같은 부팅에 비슷한 줄이 4건 더 있었습니다. 처음엔 이걸 "시스템 로케일 목록까지 치환당한다" 고 읽었는데 오독이었습니다. 로그는 from [그 Configuration 이 갖고 있던 값] to [필터를 통과한 결과] 꼴입니다. from 쪽에 멀쩡한 ko_KR_zhzhz,en_US 가 보이는 건 그 객체가 이미 우리 값을 쥐고 있었다는 뜻이고, 치환당한 건 그때 호출자가 새로 넘긴 비면제 로케일입니다. 시스템 값 자체는 멀쩡합니다. getprop persist.sys.locale 과 am get-config 로 매 부팅 확인했습니다.
그래서 필터를 껐습니다. 파일은 그대로 두고 persist.sys.lenovo.is_test_mode 를 1 로. 다음 부팅의 결과:
- 치환 5건 → 0건
韩语 및 字母→한국어 및 Alphabet(뒤쪽은 언어 없는 라틴 자판이라 영어가 맞습니다)
같은 프로퍼티를 읽는 곳이 많은데 읽는 방식이 두 가지입니다. framework.jar·services.jar 는 SystemProperties.getBoolean() 으로 읽어 1 도 참으로 봅니다. 반면 ZuiSystemUI 는 "true".equals(...) 문자열 비교라 1 은 거짓입니다. 그래서 1 로 넣으면 로케일 필터는 꺼지고 SystemUI 쪽 CTS 분기 28곳은 그대로 꺼진 채 남습니다.
덤으로 이 값은 ZuiMemCleaner 를 끕니다 — 최근 앱 "모두 지우기" 한 번으로 VPN 을 UID 째 죽이던 그놈입니다.
공짜는 아닙니다. 프레임워크의 OvWindowDecorFeatureFlags.windowDecorationDisabled() 가 persist.wm.win.decor.disabled || is_test_mode 라 PC 모드 창 제목표시줄이 사라집니다. 선행 보고에 나오던 그 증상이 여기서 옵니다. 처음엔 "원격이라 확인 못 했다" 고 적었는데, PC 모드를 켜 보니 실제로 사라져 있었습니다. 그래서 이 설정을 켠 채로 두지 않았습니다. 어떻게 둘 다 얻었는지는 2편 12번에 적었습니다. 원복은 setprop persist.sys.lenovo.is_test_mode false + 재부팅, 한 줄입니다.
앱별로 한국어가 안 되는 이유는 하나가 아닙니다
설치된 서드파티 77개를 전부 분류하고, 런처에 보이는 71개는 resources.arsc 를 직접 디코딩해 판정했습니다.
adb -d shell "pm path com.dropbox.android; echo; pm path com.openai.chatgpt"
| 부류 | 원인 | 조치 |
|---|---|---|
ko 리소스 보유 64개 (ko split 20 + base.apk 의 ko config 42 + 기본 config 가 한국어 2. 아래 "의도적 en-US" 4개도 여기 포함된다) | 정상 | 없음 |
| ko 리소스가 기기에 아예 없음 (드롭박스·Synology Photos·PS Remote Play) | ko split 이 설치돼 있지 않다. 원인이 "설치 시점 로케일" 은 아니다 — 기기가 en-US 이던 2026-06 에 설치된 앱도 en/ko/zh split 을 다 갖고 있다 | 업데이트를 시도해 볼 수는 있으나 확실한 방법은 아니다 |
| 앱 자체가 한국어 미지원 (MS 원격데스크톱) | 언어 15개 중 ko 없음 | 조치 불가 (en-US 고정도 소용없다 — 바로 아래) |
| 서버 langpack (텔레그램) | 번역이 리소스에 없음 | 앱 내 언어 설정 |
의도적 en-US 오버라이드 4개 | 내가 그렇게 둔 것 | — |
런처 71개의 내역은 64 + 3 + 1 + 1 이고, 나머지 2개(Termux:Boot 등)는 번역할 UI 문자열 자체가 없어 분류에서 뺐습니다.
여기서 제가 성급하게 틀린 것이 둘 있습니다. 티빙과 네이버게임은 멀쩡합니다 — ko split 이 없는 건 맞지만 base.apk 의 기본 config 가 애초에 한국어(한국 개발사라 values/ 가 한글)입니다. 재설치할 필요가 전혀 없는데 하마터면 "데이터 날리고 재설치하라" 고 쓸 뻔했습니다.
finsky.xml 의 pending-language-splits=false 를 보고 "variant 로케일에서 Play 가 언어 split 을 영영 안 가져온다" 는 구조적 비용을 주장하려 했습니다. 검증에서 깨졌습니다. 공식 문서상 Play 는 로케일 전환 직후 추가 언어를 즉시 받으려 시도하고 실패하면 백그라운드에서 재시도합니다. 그 플래그는 정상 상태이고 증거가 아닙니다. 판정은 pm path 에 split_config.ko.apk 가 있는지로 해야 합니다.
그래서 남은 중국어는 정확히 몇 개인가
글을 올린 뒤에 이걸 세어 봤습니다. 런처 앱 71개 + 시스템 UI 11개 = 82개 표면의 resources.arsc 를 전부 기기에서 꺼내 직접 디코딩했습니다.
| 중국어 문자열이 1건이라도 있는 표면 | 82개 중 19개 (런처 앱은 71개 중 11개뿐, 나머지 60개는 깨끗) |
| 중국어·일본어로 해석되는 문자열 총계 | 1196건 |
| 그중 결함이 아닌 것 | 113건 (언어 선택 메뉴의 简体中文, Gboard 의 倉頡·注音 같은 자판 이름, 넥슨 일본 결제 SDK 70건 등) |
| 실제 결함 | 1083건 |
| 그중 중국 전용 앱 3개에 몰린 것 | 738건 (百分网游戏盒子 369 + Legion Zone 275 + VPhoneOS 94) |
그리고 이 측정이 제가 하려던 조치 하나를 통째로 없앴습니다.
"한국어가 없으면 영어로라도 고정하자" 는 당연해 보입니다. 1196건을 전부 추적해 보니 그렇게 고칠 수 있는 건 0건입니다.
- 1001건은 애초에
enconfig 가 없습니다. 영어로 요청해도 똑같이 기본 config(=중국어)로 떨어집니다. - 181건은
enconfig 가 있는데 그 영어 값 자체가 중국어입니다. Legion Zone 의en리소스 209개 중 173개가 중국어입니다. 번역을 안 한 게 아니라 중국어를 영어 칸에 넣어 둔 겁니다. - 14건은
koconfig 의 값이 중국어입니다.
게다가 역효과가 납니다. 설정 앱은 ko 문자열이 8057개인데 en 은 24개뿐이라, 영어로 고정하면 지금 멀쩡한 한국어 화면이 중국어로 떨어집니다. 이미 en-US 를 박아 둔 엔드필드가 여전히 로그인창 중국어인 것도 같은 이야기입니다.
그리고 필터는 평상시엔 안 걸리지만 부팅 때는 걸립니다.
오래 켜 둔 기기의 로그캣 31,149줄에서 [ko_KR_zhzhz,en_US] → [zh_CN] 전이가 0건이라 처음엔 "필터는 더 이상 발화하지 않는다" 고 썼습니다. 틀렸습니다. 로그 링버퍼가 돌아서 부팅 구간이 밀려나 있었던 겁니다. 재부팅 직후에 보면 to [zh_CN] 로 끝나는 줄이 5건 찍힙니다. 2편 12번의 키보드 문제가 그중 하나입니다. 다시 말하지만 from 쪽은 그 Configuration 이 갖고 있던 값일 뿐 치환 대상이 아닙니다.
살아 있는 창 71개의 config 가 전부 한국어 우선인 것은 사실입니다. 부팅이 끝난 뒤에는 걸릴 거리가 없기 때문입니다. (from [] to [zh_CN] 꼴이 14건 보이는데, 이건 로케일이 아직 안 채워진 Configuration 객체라 치환이 아닙니다. 세면 안 됩니다. 제 검증 스크립트가 처음에 이걸 세서 "14건 발화 중" 이라는 거짓 경고를 냈습니다.)
그래도 남아 있는 중국어의 대부분은 필터 탓이 아닙니다. 그 앱에 한국어가 없는 것이고, 로케일로는 고칠 수 없습니다. 제가 "뚫었다" 고 한 건 시스템 로케일 경로 한정입니다.
9. 원복
adb shell 'su -c "setprop persist.sys.locale en-US"'
adb shell 'settings put system system_locales "en-US"'
adb shell 'getprop persist.sys.locale; settings get system system_locales' # 되읽기 필수
adb reboot끝입니다. 파일은 한 개도 건드리지 않았으므로 verity·부트 이미지·서명 전부 무관합니다.
여기서 균형을 맞춰야 할 게 하나 있습니다. 선행 해법 중 루트로 jar 를 패치하거나(ZuiKoreanizer) LSPosed 모듈을 상주시키는 쪽(DeZUXposed)에 비하면 이 점은 분명히 쌉니다. 그런데 SetEdit·Language Setting 앱으로 system_locales 만 쓰는 선행 경로(갓파더·탭붕이·jihunx)도 파일을 고치지 않고, 그쪽은 루트조차 필요 없습니다. 제 쪽은 persist.sys.locale 까지 쓰느라 오히려 루트를 더 요구합니다. "파일 수정 0건" 은 제 자랑거리가 아니라 그냥 이 경로의 성질입니다.
10. 따라하기 전에 — 경고
· 펌웨어가 1.5.10.138 보다 새롭다 → polygraphene 의 디바이스 트리가 적어 둔 "언락 없이 플래시 가능" 구간을 벗어납니다. 그리고 18027972083 의 기록에 따르면 1.5.10.259 이후로는 unlock 파티션이 삭제되고 ABL 이 생산키로 교체됩니다. 둘은 서로 다른 출처의 서로 다른 컷오프입니다. 묶어서 하나로 읽지 마세요. 플래시 전에 자기 기기 버전부터 확인하세요.
· OTA 를 받는다 → 루트가 사라집니다. 처음엔 "abl 이 레노버 키로 갱신되면 testkey vbmeta 가 거부돼 부팅 불가" 라고 썼는데, 전수검사에서 그 시나리오는 성립하지 않았습니다. 실제로는 델타 OTA 는 소스 해시 검증 단계에서 아무것도 쓰기 전에 실패하고, 풀 OTA 는 비활성 슬롯을 레노버 키로 일관되게 채운 뒤 슬롯을 넘기므로 정상 부팅하면서 루트만 조용히 사라집니다. 벽돌은 아니지만, 그래도 꺼 두는 쪽을 권합니다.
· 구버전으로 전체 다운그레이드하거나 부트로더를 재잠근다 → 벽돌 위험. 하지 마세요.
· CtsPreconditions.apk(is_test_mode=true) 경로를 쓴다 → 그 APK 를 영구 설치해 둬야 하고(원작자 본인이 정정한 조건), 분할화면·플로팅창·창 제목표시줄이 깨집니다. 제 기기에서는 부팅로고 잘림 + PC모드 제목표시줄 실종으로 나타났습니다. PC 모드를 주력으로 쓴다면 이 길은 피하세요.
· 안드로이드 17 기반 롬에서 이 글의 진단법을 쓴다 → locales updated from [...] to [...] 로그가 없습니다. 그건 AOSP android16 Configuration.setLocales() 의 Slog.v 인데 main 브랜치에서 삭제됐습니다. am get-config 와 창의 mFullConfiguration 으로 보는 쪽이 오래 갑니다.
· 로케일만 바꾸면 지역 설정이 다 따라올 거라고 기대한다 → 아닙니다. Wi-Fi 규제도메인은 CN 그대로라 6GHz 채널이 0개입니다. persist.sys.locale 과 규제도메인은 완전히 다른 경로입니다.
· 원격에서 adb reboot bootloader → USB 장치가 사라지고 안 돌아옵니다. 복구는 전원 버튼 10~20초뿐이라 사람 손이 필요합니다.
11. 아직 모르는 것
정직하게 남깁니다.
ko-KR-x-private-zh가 왜 끝내zh_CN이 됐는지 모릅니다.- SDK 가 variant 를 그대로 직렬화하는 건 관측했습니다. 로케일 변경 직후 쓰인 Statsig 저장소에
"locale":"ko_KR_zhzhz"가 들어 있습니다. 다만 그 값이 서버 쪽에서 무엇을 깨뜨리는지는 측정하지 못했습니다. 기기 한 대의 관측값입니다. (같은 prefs 에서 함께 보이는ko-KR-zhzhzh쪽은 4개월 전 mtime 의 잔재라 이번 작업과 무관합니다. 처음엔 이것도 제 변경 탓인 줄 알았습니다.) - Widevine 레벨(L1/L3)을 확인하지 못했습니다. 이 빌드엔
media.drm서비스가 아예 없어서 흔한dumpsys media.drm이 안 먹습니다. 다만 DRM 자체는 살아 있습니다. 루팅·한글화 이후인 2026-10-21 15:30 에 티빙이 Widevine CDM 생성에 성공한 로그가 있습니다. - 레노버 기본앱 깊은 화면에 남은 중국어 345건는 손대지 못했습니다. 카메라 AR 스티커, 갤러리 AI 분류, 게임 사이드바, APK 설치 창, 잠금화면 시계의
上午/下午같은 것들입니다. 고치려면 시스템 패키지의 리소스를 덮어야 하는데, 쓰기 가능한/product가 없고 verity 가 enforcing 이라 지금 구조에서는 길이 없습니다. - chroot 데스크탑과 기존 proot 우분투가 Termux-X11 의
:0를 공유합니다. 둘을 동시에 띄울 수는 없고, 그 점은 아직 그대로입니다. (처음엔 여기에 "한쪽이:0을 쥐면 다른 쪽이 흰 화면" 이라고 적었는데 틀렸습니다. 그 흰 화면의 원인은:0경합이 아니라xfwm4의 합성이었습니다. 2편 13-3에 적었습니다.)
여기까지가 한글화와 루팅입니다. 그 위에 쌓은 것 — 키보드 이름을 되찾은 KernelSU 모듈, 태블릿 안의 진짜 리눅스 데스크탑, 그걸 앱 버튼 하나로 켜게 만든 과정, 그리고 원인을 하루 동안 잘못 짚었던 기록 — 은 2편에 있습니다.
크레딧 — 먼저 찾은 사람들
이 글을 쓰려고 선행 연구를 뒤져 보고 나서, 처음에 쓴 "내가 찾았다" 를 거의 다 지웠습니다.
- 갓파더 (뽐뿌 안드로이드탭, 2024-02~04) — 이 글의 뼈대 전부입니다. 필터의 최초 공개 분석("입력된 값에 zh가 없거나 en_US가 아닐 경우 강제로 locale을 zh-CN으로 변경하는 코드"),
contains("zh")우회 발상, 우회값 5종 실측(ko-KRzhzh→ko__KRzhzh로 region 이 사라지는 것까지), CtsPreconditions 경로와 2순위en-US권고, 그리고 하이픈을 넣은 BCP-47 variant 형식ko-KR-zhzhzh. 제ko-KR-zhzhz는 variant 길이만 6자→5자로 다른 같은 기법입니다. - 돠스(dwas) — 한글이 들어간 Settings 앱과
Language Setting앱, 그리고 적용 튜토리얼. 갓파더 님의 정리글이 권장 방법 두 가지 모두에 "+ 돠스님의 한글 지원 Settings 앱 설치" 를 달아 뒀고, jihunx 님이 실제로 성공한 경로도 이 앱입니다. 제가 처음에 이 분을 통째로 빠뜨렸습니다. - 탭붕이 (디시 안드로이드 태블릿 자료 갤러리, 2026-02-27) — Y700 4세대 + 1.5.10.063 에서 되는 걸 저보다 8개월 먼저 기록했습니다.
- jihunx (BIMMER MAC, 2026-03-28) — 4세대 성공 기록. 부작용을 해결한 쪽이고, 실제로 성공한 경로는 SetEdit 이 아니라 돠스 님의
Language Setting앱입니다. - limzei89 (note.com, 2025-05-05) — 같은 발상의 일본어 적용 선례. 다만 쓴 값은 하이픈 variant 가 아니라 region 을 잃는 접미사형
ja-JPzhzh입니다. 부작용으로 Amazon Prime Video 가 실행되지 않았다고 적었습니다. - forumi0721 (ZuiKoreanizer, 2024-07) — 루트로 두 jar 를 패치하는 모듈. "필터가 두 jar 에 중복돼 있다" 는 사실이 이미 이 모듈의 전제였습니다.
- miner7222 (DynoBox / DeZUXposed / LTBox PR #96) —
ZuiAntiCrossSell이라는 이름 자체가 이미 공개 코드에 있습니다. 505개 feature 목록도(examples/.../lgsi_features.json), dex 니블을 뒤집어 끄는 방법도(patches/·fuck_lgsi.rs),/product/etc/lgsi_build_info.html이라는 파일의 존재도. DeZUXposed 는 같은 걸 런타임 후킹으로 끕니다. testkey ABL 교체 역시 LTBox 에 내장된 기능입니다. (인용한 문장들은 README 가 아니라 저장소 소스 안에 있습니다.) - polygraphene (TB322FC TWRP 디바이스 트리) — "ZUI 1.5.10.138 이하는 언락 없이 플래시 가능(Lenovo testkey vulnerability)" 이라는 전제의 독립 기록.
- ll1zt (tb322fc-droidspaces-kernel, 2026-09-06) — 같은 TB322FC 에서 부트로더 잠금을 유지한 채 testkey AVB 체인 + KernelSU LKM(
init_boot패치) + LTBox EDL 이라는, 제 §7 과 사실상 같은 조합을 6주 먼저 남긴 기록(ZUXOS 1.5.10.117 실측). §10 의 "OTA 는 꺼 두라" 는 권고도 이 README 에서 왔습니다. - 18027972083 (y700-flash-guide) — 중국 쪽 독립 기록. "TB322FC 在 ZUXOS 1.5.10.259 之后常规解锁路径全部失效(unlock 分区被删、ABL 换生产密钥)" — 259 이후 ABL 이 생산키로 바뀌어 통상 언락 경로가 전부 막혔다는 것과, EDL 로 들어가 AOSP 테스트키로
vbmeta를 재서명하는 복구 절차를 먼저 문서화했습니다. 제 §7 이 서 있는 전제가 이 기록입니다. - qwqawa64 (ZUX-ZTool) — 중국 쪽 LSPosed 모듈. 다만 이건
setLocales필터를 무장해제하는 게 아니라 설정앱의 "Add Language" UI 를 여는 쪽이라, 이 글이 푼 문제와는 다릅니다. 필터 자체를 런타임에 끄는 건 위에 적은 miner7222 님의 DeZUXposed 입니다. - Ernst Leierzopf, Stefan Kempinger, René Mayrhofer, Michael Roland (JKU Linz, SPICES 2025) — AVB testkey 취약점 클래스의 학술 선례 — 공식 제목은
Workshop:접두가 붙습니다. - DARK7BEAST (XDA thread 4736343, 2025-05-12) — Y700 2·3세대 대상의 영어권 CtsPreconditions 가이드. §10 에 적은 분할화면·플로팅창 파손은 이 스레드 답글자들의 보고입니다(원글쓴이 본인의 보고가 아닙니다). 4세대 언락 쪽은 별도 스레드 4743906(개설자 LoGen0110, 2025-06-13)입니다. (초판에서 "xdaforums 가 403 이라 본문을 못 봤다" 고 썼는데 사실이 아니었습니다. 기본 User-Agent 가 막혔을 뿐이고, 검증 단계에서 본문을 읽었습니다.)
그래서 제 몫은 어디까지인가
"공개 기록에서 못 봤다" 정도로만 조심스럽게 적습니다. TB322FC + ZUXOS 1.5.10.259 / Android 16 에서, region 을 보존한 5자 variant ko-KR-zhzhz 를 persist.sys.locale 과 system_locales 양쪽에 세워 재부팅을 넘긴 기록. 선행은 2024년 12.7/2세대의 ko-KR-zhzhzh 와 4세대의 region 상실형 ko-KRzhzh 까지입니다. 단독 발견은 아닙니다.
선행 기록을 찾지 못한, 확실히 제가 더한 것은 넷입니다.
- 두 사본의 동작 차이 —
ActivityTaskManagerService쪽이 리스트를 붕괴시킨다는 것, 그래서system_locales에 평범한ko-KR을 끼우면 안 된다는 실용 규칙. - 왜 안 깨지는지의 구조적 설명 —
match()가 variant 를 아예 심사하지 않고, 순위·동률 단계의 variant 비교는 탈락시킬 힘이 없다는 것. setLocales()의 Yandex 하드코딩.- 아주 작은 것 하나 —
LgsiFeatureInfo의 두 불리언이(enabled, writable)이라는 것을/system/bin/lgsi_features의 심볼로 확정한 것, 그리고 레노버 문서의 그 한 문장을 실기기 원문으로 옮겨 온 것. 파일 자체는 DynoBox 가 먼저 알렸습니다.
나머지는 먼저 찾은 분들 덕입니다.

Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…