← 제작기

범인은 서명이 아니었다

지난 글은 이렇게 끝났습니다. 기기가 악성 앱으로 의심해 설치를 막았고, 실명 서명 키로 바꾸자 차단이 사라졌다. 원인은 공용 디버그 서명이었다고 적었습니다.

그 결론이 뒤집혔습니다. 계기는 업데이트 경로가 정말 동작하는지 보려고 시험 삼아 버전 하나를 올려 본 것이었습니다.

1. 같은 화면이 또 나왔다

기능 차이가 전혀 없는 v1.23 을 만들어 배포 과정 전체를 처음부터 끝까지 밟아 봤습니다. 매니페스트 갱신, 앱 안에서의 확인, 다운로드, 설치까지입니다. 확인과 다운로드는 잘 됐는데, 마지막 설치에서 그 화면이 다시 떴습니다.

악성 앱으로 의심 … 휴대전화와 데이터 보호를 위해 이 제한은 끌 수 없습니다.

지난번과 글자 하나 다르지 않은 문구였지만 이번에는 상황이 달랐습니다. 서명이 원인일 수가 없었습니다. v1.23 은 잘 설치돼 있던 v1.22 와 같은 키로 서명됐고, 지문을 대조해 확인했습니다. 권한 목록과 매니페스트도 비교해 보니 완전히 같았고, 다른 것은 버전 숫자와 APK 바이트뿐이었습니다.

지난번에 두 번이나 헛짚은 이유가 짐작부터 시작한 데 있었으므로, 이번에는 변인을 하나씩 통제하는 실험으로 확인했습니다.

2. 변인 하나만 바꾸는 실험

먼저 앱을 지우고, 예전에 통과했던 v1.22 를 파일 관리자로 다시 설치해 봤습니다. 경고가 떴지만 지난번과 다른 화면이었습니다. "무시하고 설치" 버튼이 있는, 넘어갈 수 있는 경고였고 설치도 됐습니다.

그 위에서 앱 안의 업데이트로 v1.23 을 받았더니 강제 차단이 떴습니다.

마지막으로, 방금 차단당한 바로 그 v1.23 파일을 파일 관리자로 열어 설치했습니다. 경고 두 번, "무시하고 설치", 완료.

같은 바이너리가 설치 경로에 따라 결과가 갈렸습니다.

앱이 스스로 설치 → 끌 수 없는 강제 차단
파일 관리자가 설치 → 넘어갈 수 있는 경고

변수는 서명도, 권한도, 바이너리도 아닌 "누가 설치를 시작했는가"였습니다. 직접 설치한 앱이 APK 를 스스로 설치하는 동작, 곧 스미싱 앱이 악성 앱을 내려받아 까는 바로 그 동작을 설치 세션 단위로 거부하고 있었습니다.

이렇게 보면 지난 글의 결론도 다르게 읽힙니다. 그때 실명 키로 바꾸면서, 자동 업데이트로는 서명이 바뀐 앱을 배포할 수 없어서 설치 방법도 수동(파일)으로 함께 바뀌었습니다. "키를 바꾸자 풀렸다"에서 키만 보고, 경로가 함께 바뀐 것은 보지 못했습니다. 변인 두 개를 한꺼번에 바꾸면 어느 쪽이 답인지 알 수 없게 됩니다.

3. 앱이 설치를 포기했다

플랫폼이 설치 세션 단위로 막는 것을 앱이 이길 방법은 없습니다. 맞서는 대신 v1.24 에서 앱이 APK 를 설치하는 일 자체를 없앴고, 새 버전이 있으면 Drive 공유 링크를 열어 주기만 하게 했습니다. 받는 사람은 Drive 가 제안하는 설치 관리자로 열고 경고를 넘기면 끝납니다. 실제 기기로 처음부터 끝까지 확인한, 실제로 통과하는 경로입니다.

덤도 있었습니다. 앱이 설치를 하지 않으니 REQUEST_INSTALL_PACKAGES 권한이 필요 없어졌습니다. "문자를 읽고, 문자를 보내고, 스스로 APK 를 설치하는 앱"이라는 의심스러운 조합에서 항목 하나가 빠진 셈입니다.

Drive 링크는 파일 이름과 상관없이 파일 ID 에 붙기 때문에, 배포는 같은 파일을 덮어쓰는 방식으로 고정했습니다. 링크가 바뀌지 않으니 받는 사람이 권한을 다시 요청할 일도 없습니다.

4. "v1.24 배포 완료"는 사실이 아니었다

여기까지 하고 v1.24 를 배포한 뒤, 사용자 입장에서 직접 업데이트를 해 봤습니다. 업데이트는 잘 됐는데, 설치가 끝난 휴대폰에서 이상한 것이 보였습니다. 최신 버전인데 업데이트 버튼이 아직 살아 있었습니다.

휴대폰에서 APK 를 꺼내 읽어 보니 versionCode=23 이었습니다. Drive 에 v1.24 로 올라가 있던 파일이 사실은 실험하려고 만든 낡은 시험 빌드였습니다. 앱은 아무 잘못이 없었습니다. 설치된 버전이 23 이니 "새 버전 있음"은 맞는 판정이었습니다.

사고가 이어진 순서는 교과서에 나올 만했습니다.

  • 실험하려고 버전을 낮춰 빌드한 뒤 설정 파일만 원래대로 돌리고 다시 빌드하지 않았습니다. 산출물 폴더에 낡은 APK 가 남았습니다
  • 배포 스크립트는 버전을 설정 파일에서 읽어 "v1.24 (code 25)"라고 출력하면서 그 낡은 파일을 복사했습니다
  • 해시 검증은 통과했습니다. 잘못된 원본과 그 복사본을 비교했기 때문입니다
  • 저장소 파일 검증도 통과했습니다. 같은 낡은 파일과 비교했기 때문입니다
검증이 네 겹이나 있었는데 모두 통과했습니다. 모두 같은 오염된 기준과 비교했기 때문입니다. 검증은 독립된 기준과 비교할 때만 검증이 됩니다.

고친 방법은 한 문장입니다. 파일이 무엇인지는 파일에게 묻습니다. 이제 배포 스크립트는 설정 파일 대신 APK 자체에서 버전을 읽고, 둘이 다르면 배포를 거부합니다. 그 사고를 그대로 재현해서 이 검사가 잡아내는 것까지 확인했습니다.

5. 검사를 만들다 생긴 조용한 실패 셋

그 검사를 넣는 짧은 셸 스크립트에서, 서로 다른 방식으로 세 번이나 "소리 없이 잘못되는" 코드를 만들었습니다.

첫째, APK 판독 결과를 head -1 로 잘랐더니 head 가 파이프를 일찍 닫으면서 판독기가 SIGPIPE 로 죽었고, pipefail 이 스크립트 전체를 아무 출력 없이 멈췄습니다. 종료 코드 141 하나만 남았습니다.

둘째, 판독 도구가 없으면 "경고만 하고 계속 진행"하도록 짰습니다. 실제 사고 때문에 만든 안전장치가 도구 경로 하나만 바뀌어도 조용히 꺼지는 구조였습니다. 이 경우를 실패로 처리하도록 바꿨습니다.

셋째, 그 실패 처리를 시험해 보니 중단 안내문이 나오지 않았습니다. 도구를 찾는 ls 대입이 set -e 에 걸려, 방금 쓴 안내문이 실행되기도 전에 스크립트가 죽고 있었습니다. 안내문은 절대 실행될 수 없는 코드였습니다.

안전장치는 실패하는 경우를 실제로 실행해 보기 전까지는 안전장치가 아닙니다. 백업이 복구해 보기 전까지 백업이 아닌 것과 같습니다.

셋 다 검사가 잘 도는지 볼 때는 보이지 않았고, 검사가 막아야 할 상황을 일부러 만들었을 때 발견했습니다. 낡은 APK 를 산출물 자리에 놓아 보고, 도구 경로를 가짜로 바꿔 봤습니다. 성공하는 경우만 돌려 봤다면 셋 다 실제 배포에서 만났을 것입니다.


남는 것

  • 재현 실험에서는 변인을 하나만 바꿉니다. 지난번에는 키와 경로가 함께 바뀌어 엉뚱한 결론을 얻었고, 이번에는 같은 바이너리를 두 경로로 설치해서 답이 한 번에 나왔습니다.
  • 검증의 기준이 오염되면 통과는 의미가 없습니다. 네 겹의 검증이 모두 통과했지만, 모두 같은 잘못된 파일과 비교하고 있었습니다.
  • 설정 대신 산출물에게 묻습니다. 설정 파일은 의도를 말하고 파일은 사실을 말합니다. 문서와 코드가 갈릴 때 코드가 맞았던 것과 같은 구조입니다.
  • 버전 표기가 같아도 바이너리는 다를 수 있습니다. 한때 "v1.24"라는 이름의 서로 다른 바이너리가 세 개 있었습니다. 실제로 퍼지는 것은 표기가 아니라 바이너리입니다.
  • 이상하다고 느낀 사람의 감각이 검증 네 겹보다 빨랐습니다. "최신인데 왜 버튼이 살아 있지?" 이 한 문장이 모든 통과 표시보다 정확했습니다.

시험 삼아 버전 하나를 올려 본 것이었습니다. 그 시험이 이전 글의 결론을 고치고, 배포 방식을 다시 설계하게 하고, 배포 과정의 사고를 밖으로 나가기 전에 잡았습니다. 시험은 통과시키려고 하기보다 이런 일을 만나려고 하는 것이었습니다. 범인이 처음 짚은 곳에 없었던 다른 사례는 예제대로 구웠으면 카드가 지워졌다에 있습니다.

The last post ended like this: Samsung blocked the install as a "suspected malicious app," the block disappeared after switching to a real-identity signing key, and the cause was the shared debug signature.

That conclusion has been overturned. And what overturned it was shipping one version, purely as a test, to see whether the update path actually worked.

1. The same screen came back

I built a v1.23 with zero functional changes and walked the whole distribution chain — manifest update, in-app check, download, install — end to end. The check worked, the download worked, and at the final install step, there it was again.

Suspected malicious app … to protect your phone and data, this restriction cannot be turned off.

Word for word the same message. But this time the situation was different: the signature could not be the cause. v1.23 was signed with the same key as the v1.22 sitting happily installed — I compared the fingerprints. The permission list and manifest were diff-identical. The only differences were the version numbers and the APK bytes.

Last time I guessed wrong twice because I started from guesses. This time I ran a controlled experiment, one variable at a time.

2. Change exactly one variable

First, uninstall, then reinstall the previously-fine v1.22 through the file manager. A warning appeared — but a different one: a warning with an "install anyway" button. It installed.

On top of that, take the v1.23 update through the in-app updater. Hard block.

Finally, open the very same v1.23 file that had just been blocked — through the file manager. Two warnings, "install anyway," done.

The same binary went two different ways depending on the path.

The app installing itself → an uncloseable hard block
The file manager installing it → an overridable warning

The variable was not the signature, not the permissions, not the binary — it was who initiated the install. A sideloaded app installing APKs by itself is exactly what a smishing dropper does, and the platform rejects that session, per install.

Which reframes the last post's conclusion. When I switched to the real-identity key, the install method also changed to manual — the signature transition could not ship through the auto-updater. I credited the key and never noticed the path had changed with it. Change two variables at once and you can no longer tell which one answered.

3. So the app gave up installing

An app cannot win against a platform that rejects its install sessions. Instead of fighting, v1.24 removes the app's ability to install APKs entirely. When a new version exists, it just opens the Drive share link. The receiver opens it with the installer Drive offers, clicks past the warning, done — a path verified end to end on a real device.

There was a bonus. With no self-installing, the REQUEST_INSTALL_PACKAGES permission became unnecessary — one item removed from the suspicious profile of "an app that reads texts, sends texts, and installs APKs by itself."

Drive links attach to the file ID, not the filename, so distribution is pinned to overwriting one file in place. The link never changes, and nobody ever needs to be re-granted access.

4. "v1.24 shipped" — except it hadn't

With all that done, I shipped v1.24 and then played the user myself. The update went through. But on the freshly updated phone something was off — the latest version still had a live update button.

Pulling the APK off the phone and reading it: versionCode=23. The file sitting in Drive as "v1.24" was actually the stale test build from the experiment. The app was blameless — with code 23 installed, "update available" was the correct verdict.

The chain of failure was textbook.

  • For the experiment I built with a lowered version, then restored the config file without rebuilding — leaving a stale APK in the outputs directory
  • The publish script read the version from the config, announced "v1.24 (code 25)," and copied the stale file
  • The hash check passed — it was comparing the wrong source against its own copy
  • The release-asset check passed too — compared against the same stale file
Four layers of verification, all green — because all four compared against the same contaminated reference. Verification only verifies when the reference is independent.

The fix is one sentence: ask the file what it is. The publish script now reads the version out of the APK itself and refuses to publish when it disagrees with the config. I replayed the exact incident and watched the guard catch it.

5. Three silent deaths while building the guard

In the short shell script that carries that guard, I wrote code that failed silently three times, three different ways.

One. Trimming the APK-reader's output with head -1 made head close the pipe early, the reader died of SIGPIPE, and pipefail aborted the whole script with no output at all. All that remained was exit code 141.

Two. If the reader tool was missing, the script "warned and carried on." A safety mechanism born from a real incident could switch itself off silently the moment a tool path changed. Now it fails closed.

Three. Testing that fail-closed branch, the abort message never appeared. The ls assignment that locates the tool tripped set -e, killing the script before the message I had just written could ever run. The notice was permanently unreachable code.

A safety mechanism is not a safety mechanism until its failure branch has actually been executed — the same way a backup is not a backup until you have restored from it.

All three were found not by checking that the guard runs, but by deliberately staging what the guard exists to stop — planting the stale APK, faking away the tool path. Running only the happy path would have left all three to be discovered in production.


What stays with me

  • A reproduction experiment changes one variable. Last time the key and the path changed together and produced the wrong conclusion. This time the same binary through two paths answered in one stroke.
  • When the reference is contaminated, passing means nothing. Four layers of green, all measuring against the same wrong file.
  • Ask the artifact, not the config. Config states intent; the file states fact. The same shape as documentation versus code — when they split, the code was right.
  • Identical version strings can be different binaries. At one point three distinct binaries all answered to "v1.24." What circulates is the binary, not the label.
  • One person's "that's odd" beat four layers of verification. "I'm on the latest — why is the button still live?" That single sentence outran every green light.

It was one version, shipped as a test. That test corrected the previous post's conclusion, forced a redesign of distribution, and caught a pipeline failure before it reached anyone. The point of a test was never to pass it — it was to meet exactly these things. Another case of the culprit sitting somewhere else is in Flashing the Example Would Have Wiped the Card.