조건을 채우면 보상을 주는 기능에서 검증이 Android 앱 쪽에 있었다.
if (조건_충족()) {
서버에_보상_요청(userId, rewardId);
}
앱이 판단하고 서버는 준다.
Table of contents
Open Table of contents
증상 — 서버는 조건을 안 봤다
Servlet 쪽을 열어 보니 조건을 확인하는 부분이 아예 없었다. userId 와 rewardId 를 받아 지급으로 바로 갔다.
무엇을 보내는지부터 봤다. 에뮬레이터의 프록시를 Fiddler 로 잡으니 요청이 그대로 보였다.
POST /reward
userId=...&rewardId=...
그 요청을 curl 로 다시 보냈더니 보상이 들어갔다. 앱을 안 거치면 조건과 무관하게 받을 수 있다는 뜻이다.
서버가 모르는 정보였다
왜 앱에 뒀는지는 짐작이 됐다. 조건이 이런 것들이었다.
특정 화면을 봤나
일정 시간 머물렀나
앱 안에서만 아는 정보다. 서버가 모르는 것으로 판단하고 있으니 판단도 앱에서 할 수밖에 없었다.
그러면 서버가 알아야 한다고 보고 각 행동을 MySQL 에 기록하게 했다. Activity 에 들어가면 그때 서버로 보낸다.
그런데 그 기록도 앱이 보낸 것이다. 안 보고도 봤다고 보낼 수 있다. 같은 문제가 한 단계 뒤로 밀린 것뿐이었다.
요청에 서명을 붙이는 방법도 찾아봤다. 키와 시각을 합쳐 HMAC-SHA256 을 만들어 같이 보내고 서버가 다시 계산해 맞춰 보는 것이다.
그런데 그 키가 APK 안에 있다. apktool 로 풀고 dex2jar 를 거쳐 JD-GUI 로 열면 Java 코드가 거의 그대로 나온다. ProGuard 를 켜도 바뀌는 것은 클래스와 메서드 이름이고 문자열 상수는 그대로 보인다.
완전히는 못 막는다
앱 안의 행동은 서버가 직접 관측할 수 없고 서버에 남은 기록은 앱이 보낸 것이다. 완전히 막는 방법이 없다.
대신 하기 어렵게는 만들 수 있었다. 세 가지를 넣었다.
① 화면 A → 화면 B → 보상 순서가 맞아야 인정
② 화면 진입에서 보상 요청까지의 간격이 너무 짧으면 이상하게 봄
③ 같은 보상은 MySQL 의 UNIQUE 로 한 번만
서명도 넣었다. 뜯으면 뚫리지만 뜯지 않고는 못 만든다.
완전히 막는 것과 어렵게 만드는 것은 다른 목표다. 앞은 불가능하고 뒤는 가능하다. 어렵게 만들면 대부분은 안 하게 된다.
얼마나 들일지는 보상 크기로 정했다. 작은 보상에 확인을 여럿 붙이면 들이는 노력과 안 맞는다.
이상한 것을 찾는 쪽
막는 것과 별개로 이상한 것을 찾는 것도 넣었다.
SELECT user_id, COUNT(*) FROM reward_log
WHERE created_at > ...
GROUP BY user_id HAVING COUNT(*) > 임계;
GROUP BY user_id 로 지급 건수를 세면 평소보다 많이 받은 사람이 나온다. 찾기만 하고 자동으로 막지는 않았다.
얼마부터 이상한지가 애매해서 열심히 하는 사용자와 구분이 안 됐기 때문이다. 자동으로 막으면 정상 사용자가 걸릴 수 있다. 목록만 뽑고 사람이 보게 했다. 명확한 것은 조치하고 애매한 것은 둔다.
이게 되려면 로그가 있어야 한다. Tomcat 액세스 로그와 reward_log 가 둘 다 있어야 맞대어 볼 수 있다. 그래서 순서가 정해졌다.
① 남기기 — 무조건
② 어렵게 만들기 — 할 수 있는 만큼
③ 이상한 것 찾기 — 남긴 것으로
①이 먼저였다.
정리
- 앱에서 판단하면 앱을 안 거치고 요청할 수 있다
- 서버가 판단하려면 서버가 알아야 하는데 앱 안의 행동은 모른다
- 기록하게 해도 그 기록 역시 앱이 보낸 것이다
- 완전히는 못 막고 어렵게 만드는 것까지다
- 순서와 시간 간격을 보면 단순한 시도는 걸린다
- APK 안의 키는 비밀이 아니다.
apktool과dex2jar로 나오고 ProGuard 도 문자열은 안 가린다 - 들이는 노력은 보상 크기에 맞춘다
- 막는 것과 별개로 이상한 것을 찾는다
- 기준이 애매하면 자동으로 막지 말고 목록만 뽑아 사람이 본다
- 무엇보다 남기는 것이 먼저다. 로그가 없으면 이상한지도 모른다