안녕하세요. 요즘 글 소재가 닳지를 않는 것 같습니다. 또 새로 파볼 주제가 생겨서 기쁩니다. 우선 서론은 생략하고 아래 사진들부터 보시죠.
버그의 내용









처음에 저 현상을 봤을 때에는 그냥 이상한 사람이거니 싶었는데 여러번 보다 보니까 뭔가 이상해서 사진을 여러장 찍어뒀습니다.
게임 내 재화인 다이아를 써서 닉네임을 바꾼 다음 사칭을 하는 것이라고 처음엔 생각했지만, 빠르게 여러 닉네임을 번갈아가면서 동시에 사칭하는 것으로 보여서 이건 닉넴을 바꿔서 사칭하는게 아니라 뭔가 버그를 이용한 것 같다는 생각을 할 수 있었습니다. (닉네임을 한 번 바꾸는 데 필요한 19 다이아는 꽤 많은 양입니다..)
그리고 게임 채팅 시스템의 특성상 사용자의 입력 데이터 처리를 제대로 하지 않는다면 출력시 의도하지 않은 현상이 발생할 수 있다는 생각을 하며, 우선 해당 버그를 재현하는 것을 시도하였습니다.
버그의 재현
버그 재현 과정에 꽤 오랜 시간을 투자하여 최종적으로 발견한 방법은 다음과 같습니다.
우선 닉네임을 RLO 문자라고 하는 Unicode(U+202E)로 설정합니다. 이후 채팅 입력창에 정확히 다음과 같이 입력합니다. (대괄호는 제외합니다.)
[임][네][닉][PDF Unicode(U+202C)][안][녕][하][세][요]
그러면 채팅창에 정확히 아래와 같이 출력됩니다.
닉네임 : 안녕하세요
그렇습니다. 내가 출력하기를 원하는 닉네임을 거꾸로 입력한 다음 PDF를 붙이고 그 뒤에 내용을 입력하면 실제로 채팅창에 그 닉네임이 말한 것처럼 출력됩니다!!!
예를 들어서
[식][준][엄][PDF][느][아][아][아][하][!][!][!][!]
채팅창에 정확히 위와 같이 입력하면 '엄준식 : 느아아아하!!!!' 가 출력되는 것이죠!!!!!!!!!
버그가 발생하는 이유와 원리
이것이 어떻게 가능한 걸까요? 우선 우리가 닉네임을 RLO로 설정하고 채팅을 입력했을 때에 채팅창에 출력되는 전체 문자를 한번 나타내 봅시다. 그것은 다음과 같습니다.
[RLO][ ][:][ ][임][네][닉][PDF][안][녕][하][세][요]
이것이 화면에 출력될 때, RLO와 PDF의 역할을 주목합시다.
RLO는 "지금 여기서부터 오른쪽에 있는 문자들을 거꾸로 출력해주세요." 의 역할을 합니다.
PDF는 "RLO가 지시한 내용을 여기에서 끝내주세요. 딱 여기까지 문자들을 거꾸로 출력해주세요." 의 역할을 합니다.
그러니까 결국 RLO부터 시작해서 PDF 사이에 있는 문자들은 거꾸로 출력하게 됩니다. 이것을 BiDi Unicode라고 하는데.. 제가 키워드를 던져 드렸으니 자세한 것은 직접 찾아보시면 더 깊이 아실 수 있을 것입니다.
아무튼 RLO와 PDF 사이에 있는 문자들은 거꾸로 출력하고 PDF 이후부터는 뒤집지 않은, 정방향으로 출력하게 됩니다.
그래서 위 예제에서 '닉네임 : 안녕하세요' 가 출력되는 것입니다.
뭐 제가 재현한 버그보다 더 간단한 방식이 있을지는 잘 모르겠으나(아예 그냥 채팅창에 입력하는대로 채팅창에 출력되도록하는 등?)
중요한 것은 사용자가 입력한 유니코드 문자를 sanitize(소독)하지 않는다는 것입니다..
게임을 뜯어보기
이러한 버그를 방지하기 위해서는, 아니 일단 사용자의 입력이 들어가는 모든 시스템은 사용자의 입력을 신뢰해서는 안됩니다. 악의가 있는 입력은 시스템을 붕괴시킬수 있기 때문이죠..
그래서 해당 게임의 채팅창의 경우도 내부적으로 사용자가 입력한 문자에서 한글과 영어, 그리고 특문과 숫자만을 남기고 다른 것들은 모두 없애버리는 방식으로 sanitize를 했어야 했는데 그런 로직이 내부적으로 존재하지 않나봅니다.
그럼 이제 우리가 할 일은.. 실제로 게임을 뜯어서 사용자가 입력한 채팅에서 유니코드를 제거하지 않고 그대로 출력하는 것을 직접 두 눈으로 확인해보는 것입니다.
이것을 위해 일단 apk를 추출합니다.


추출된 apk들을 모두 드래그해서 선택하고 jadx에 넣어보면 아래와 같이 cocos2d 엔진의 메인 게임 로직이 들어있는 .so 파일을 찾을 수 있습니다.

...
이것을 Export 한 뒤, Hopper Disassembler로 열어보겠습......?

Hopper를 얌전히 종료하고 Ghidra로 열어 봅시다..

일단 우리가 찾는것이 채팅 관련이므로 위와 같이 심볼트리에서 'chat'을 검색해봅니다.
그러면 위 사진에 나온 것처럼 'InputChat' 이라는 함수를 발견했는데 이름이 수상하죠. 뭔가 채팅을 입력했을때 실행될 것 같은 함수이지 않습니까? 바로 해당 함수를 까봅시다.
InputChat 함수에서 제가 주목한 부분은 아래와 같습니다.
/* TownScene::InputChat(char*, char const*, unsigned char, unsigned int) */
void __thiscall
TownScene::InputChat(TownScene *this,char *param_1,char *param_2,uchar param_3,uint param_4)
{
...
// 1. 닉네임을 필터링합니다. 닉네임에 욕설이 포함돼있으면 출력시 'NICKNAME' 으로 바뀌는 듯합니다.
// (애초에 닉네임을 바꿀 때 욕설이 들어가면 못 바꾸는게 일반적인 상황이지만,
// 채팅을 송신/수신할 때 한번 더 필터링을 거치는 안전 장치인 것으로 보임)
CMobileStringFilter::ExecuteFilter(pCVar4,&local_58,(Size *)&local_40,2);
...
// 2. '닉네임 : 내용'으로 출력을 포맷합니다.
cocos2d::StringUtils::format("%s : %s",&local_50,local_40,param_1);
FUN_03db469c(&local_68,&local_50); // 대충 두 포인터를 swap하는 함수..
...
// 3. 폰트 렌더링을 준비합니다. 입력 내용을 대기시킵니다.
this_02 = (UtilGUI *)
UtilGUI::CreateHighQualitySystemFont(0x41600000,&local_68,1,(Size *)&local_40,0,1);
...
// 4. 버튼을 세팅하고 버튼에 콜백함수를 세팅합니다.
// 콜백 함수의 코드는 FUN_03408974에 있습니다.
// 채팅창에서 닉네임을 누르면 '[닉네임]유저를 차단하시겠습니까?' 다이얼로그를 띄울수있는 버튼인듯 합니다.
// (현재 목표에서 별로 중요한 부분은 아님. 그냥 '아 이게 그거였구나~' 싶어서 신기했습니다.)
this_03 = (UtilGUI *)cocos2d::ui::Button::create();
local_28[0] = operator.new(8);
*local_28[0] = this;
local_10 = FUN_03408974;
local_18 = FUN_033f4694;
cocos2d::ui::Widget::addClickEventListener((Widget *)this_03,(function *)local_28);
...
// 5. 채팅창에 대기된 위젯을 push 합니다.
cocos2d::ui::ListView::pushBackCustomItem(this_01,(Widget *)pNVar8);
}
위 코드에서 주석을 참고하면 이해하기 훨씬 수월할 것입니다.
...
이제 우리의 목표를 잊지 않는 것이 중요합니다.
우리의 목표는 게임이 사용자의 입력에서의 유니코드 값을 sanitize하지 않는 것을 직접 확인해보는 것입니다.
이제 큰 흐름을 잡았으니 좀 더 세부적으로 들어가서 확인해보죠.
우선 위 코드에서 ExecuteFilter 함수의 이름이 '필터' 이기 때문에 '어 설마 여기에서 유니코드를 sanitize하나?' 라고 생각해볼 수 있기 때문에 그곳으로 들어가서 어떤 일을 하는지 봅시다.
/* CMobileStringFilter::ExecuteFilter(std::string const&, std::string&, FilterType) */
void __thiscall
CMobileStringFilter::ExecuteFilter
(CMobileStringFilter *this,string *param_1,undefined8 param_2,int param_4)
{
// 만약 param_4(필터링 검사 플래그로 추정됨)가 0이고,
// 검사 대상 문자열에 욕설이 포함돼있지 않다면 (CheckFiltering이 0을반환한다면)
// 검사 대상 문자열을 필터링 없이 버퍼에 그대로 복사하고 함수를 종료
if ((param_4 == 0) || (cVar2 = CheckFiltering(this,param_1), cVar2 == '\0')) {
// param_1 의 내용을 param_2로 복사함
// param_2는 버퍼고 param_1는 필터링 검사할 문자열.
// param_1는 닉넴이될수도있고 입력한 내용이될수도 있음
FUN_03db7170(param_2,param_1);
return;
}
...
// 필터링 검사 플래그가 1이고, 위에서 CheckFiltering이 0을 반환하지 않았기 때문에 (욕이 들어있기 때문에)
// 위에서 함수가 종료되지 않았다면 필터링을 실행함.
// 욕설을 하면 채팅창에 '예예, 밀초 맛있쪙!' 이렇게 필터링돼서 출력됨.
// 참고: jadx로 열었을때 assets/language/language_ko.dat 안에 필터링될 텍스트들이 포함돼있음.
if (param_4 == 1) {
iVar3 = rand();
uVar1 = iVar3 % 100;
iVar3 = uVar1 + 0x2711;
if ((((uVar1 - 1 < 2) || (iVar3 == 0x2724)) || (iVar3 == 0x272f)) ||
(((iVar3 == 0x2736 || ((uVar1 & 0xfffffffd) == 0x28)) ||
((iVar3 == 0x2745 || (iVar3 == 0x2750)))))) {
iVar3 = 0x275b;
}
else if ((uVar1 - 0x55 & 0xfffffffb) == 0) {
iVar3 = 0x275b;
}
// 필터링된 텍스트를 버퍼에 복사하고 함수를 종료
__s = (char *)LanguageRef::GetString((LanguageRef *)ReferenceManager::Language,iVar3);
sVar4 = strlen(__s);
FUN_03db5c54(param_2,__s,sVar4);
return;
}
// 이거 param_4가 2면 닉네임 전용 필터링같음..
// 닉네임에 욕이들어있다고해서 '예예, 밀초 맛있쪙!' 같은거로 닉넴을 출력할수는 없으니까..
// 'NICKNAME'으로 바꿔서 출력하는듯
if (param_4 == 2) {
FUN_03db5c54(param_2,"NICKNAME",8);
return;
}
return;
}
주석에 모든 설명을 담아놨습니다.. 이 함수는 사용자의 닉네임이나 사용자가 입력한 내용을 필터링하는 함수입니다. CheckFiltering 함수를 통해 욕설이 들어있는지 확인하고 욕설이 들어있으면 필터링된 텍스트를 버퍼에 복사하고, 욕설이 없다면 원래 텍스트를 버퍼에 바로 복사하고 함수를 종료합니다.
이 부분 역시 유니코드를 sanitize하는 부분은 발견할 수 없군요..
그러면 이제 마지막으로 위의 InputChat 함수에서 호출되는 CreateHighQualitySystemFont 함수를 확인해 봅시다.
아마 더 깊이 내려가면 렌더링과 관련된 로직이 있을 것으로 예상되는데.. 그렇게 하지 말고 조금만 파먹은 뒤 sanitize하지 않음을 빠르게 확인하고 결론을 짓는 것이 정신 건강에 좋아 보입니다.
CreateHighQualitySystemFont 함수에서 주목할 것은 아래와 같습니다.
/* UtilGUI::CreateHighQualitySystemFont(std::string const&, float, bool, cocos2d::Size const&,
cocos2d::TextHAlignment, cocos2d::TextVAlignment) */
void UtilGUI::CreateHighQualitySystemFont
(float param_2,undefined8 param_1,char param_3,Size *param_4,undefined4 param_5,
undefined4 param_6)
{
plVar4 = (long *)cocos2d::Label::createWithSystemFont
(param_2 + param_2,param_1,&local_10,auStack_18,param_5,param_6);
...
if (param_3 != '\0') {
lVar5 = CommonUI::GetStringFilterImpl();
plVar4[0xc0] = lVar5;
if (lVar5 != 0) {
FUN_03db6ff0(&local_10,plVar4 + 0x5a);
if (*(long *)(local_10 + -0x18) != 0) {
/* try { // try from 0369fcdc to 0369fcf3 has its CatchHandler @ 0369fd44 */
FUN_03db5c54(plVar4 + 0x5a,"",0);
(**(code **)(*plVar4 + 0x580))(plVar4,&local_10);
}
...
}
}
...
}
return;
}
예상했던 대로 cocos2d에서 제공하는 createWithSystemFont함수를 호출해서 출력을 준비합니다.
이후 주목해야 할 것은 GetStringFilterImpl 이라고 하는 어떤 필터를 Label에 등록하고 있는데(plVar4[0xc0] = lVar5;) 이때 plVar4의 0xc0번째 멤버를 알아내기 위해서는 좀 많이 허슬해야되기 때문에 빠르게 ㅌㅌ해야 합니다.
일단 그것을 스킵하고 GetStringFilterImpl를 보면 내부 싱글톤 인스턴스에 DoStringFilter라고 하는 함수를 추가하는데 굉장히 수상한 냄새가 납니다. 여기서 유니코드 필터링을 하면 어떡하지? 라는 생각이 들수도 있습니다. (뭐 이미 인게임에서 버그를 재현해보면서 필터링이 안된다는것을 확인해놨기 때문에 결과를 알고있는 셈이지만)
/* MCStringFilterImpl::DoStringFilter(std::string const&, std::string&) */
void __thiscall
MCStringFilterImpl::DoStringFilter(MCStringFilterImpl *this,string *param_1,string *param_2)
{
FUN_03db7170(param_2);
return;
}
FUN_03db7170에 들어가보면
long * FUN_03db7170(long *param_1,long *param_2)
{
lVar4 = *param_1;
lVar6 = *param_2;
...
*param_1 = lVar6;
return param_1;
}
이 함수는 결국 그냥 param_1에 param_2의 내용을 복사하는 것 뿐이었습니다.
아무런 유니코드 sanitize 없이 그냥 내용을 복사하기만 하는 것이죠..
이후 InputChat 함수로 돌아가보면 pushBackCustomItem이 호출되면서 채팅이 채팅창에 push됩니다.
즉, 이때까지 우리가 집어넣은 유니코드들은 사라지지 않으며
나와 다른 사람들의 화면에 그대로 남아서 채팅이 이상하게 보이도록 조작할 수 있는 것입니다.
아아아아 맞다
아 맞다 중요한걸 까먹었습니다. InputChat 함수로 넘어오기 전에 우리가 입력한 내용에 이미 sanitize가 적용돼있을수도 있는 것인데 그걸 확인을 안 했네요. InputChat의 xref를 찾아보면 아래와 같은 함수가 있습니다.
/* TownScene::editBoxReturn(cocos2d::ui::EditBox*) */
void __thiscall TownScene::editBoxReturn(TownScene *this,EditBox *param_1)
{
CommonScene::editBoxReturn((CommonScene *)this,param_1);
...
pcVar5 = (char *)cocos2d::ui::EditBox::getText(param_1);
strncpy(local_48,pcVar5,0x3c);
FUN_03db3680((string *)&local_50,local_48,&local_60);
SystemPacketSend::Chatting('\0',(string *)&local_50);
...
uVar7 = cocos2d::ui::EditBox::getText(param_1);
FUN_03db3680(&local_58,uVar7,auStack_68);
CMobileStringFilter::ExecuteFilter(pCVar3,&local_58,&local_60,1);
...
strncpy(local_48,local_60,0x3c);
lVar8 = Common::GetMyClientData();
InputChat(this,local_48,(char *)(lVar8 + 0x41f),'\0',0);
}
그냥 간단하게 이렇게만 봐도 되는데 저기 ExecuteFilter가 우리가 입력한 내용에서 욕을 검사해서 필터링하는 부분이고..(저 위에서 봤던 ExecuteFilter 호출은 닉네임을 필터링하는 호출이었던 반면, 지금은 InputChat이 호출되기 전에 우리가 입력한 내용을 필터링 하는 호출입니다)
보시다시피 입력 박스에서 입력데이터를 그대로 가져오고 SystemPacketSend::Chatting를 통해 서버로 입력내용을 보낸 뒤, InputChat으로 도달하기까지 유니코드 sanitize와 관련된 부분은 없는 것으로 보입니다.
마치며
분석을 좀 대충 한 느낌이 있습니다.
vtable호출이나 실행될 수 있는 필터들이 더 있을수도 있는데 그걸 꼼꼼히 확인 안해봤습니다. vtable가 뭔지도 제대로 모르고(C++에서 virtual 함수를 써본 적조차 없음) 그리고 위에서 'plVar4[0xc0] = lVar5;' 처럼 클래스 멤버에 저런식으로 접근하는것 자체가 정적분석에서 찾아내기가 초보자 수준에서 쉽지가 않았기에 좀 많이 넘어간 부분이 있긴 하다만
어차피 인게임에서 sanitize하지 않는다는 결과를 이미 알고 정적분석을 하는 것이기에 이렇게 대충 넘어간 점이 있지만 약간 짜칩니다. 🗣️
그냥 대충봤다는 것만 인지하고 넘어가야 될 것 같습니다.. 이제 '정신적 힘듦 > 호기심'이 되는 포인트에 들어온 것 같습니다
진짜 마치며
지금시간은 새벽 3시 56분
졸리다
'정보보안 > 리버싱' 카테고리의 다른 글
| 삼성 T5 포터블 SSD 자동 해제 프로그램 개발 (with SCSI protocol) (0) | 2025.11.10 |
|---|---|
| 추억의 게임 렙업만이살길2 리버싱 일기 - 2편 (완) (4) | 2025.06.03 |
| 추억의 게임 렙업만이살길2 리버싱 일기 - 1편 (1) | 2025.06.01 |
| DLL Injection 기법 - 프로세스에 침투하기 (0) | 2024.02.08 |
| 윈도우 이벤트 메시지 후킹 기법 (0) | 2024.02.06 |
