
신규 유형 부고장 앱 분석
2024년 월 이후로 부고장 유형의 악성 앱이 지속적으로 탐지되지 않도록 고도화되고 있는 것으로 확인되고 있다. 현재 유포되고 있는 악성 앱은 1부에서 분석된 악성 앱과 동일한 유형이지만 악성 행위 분석 방지 목적으로 newobfs가 적용된 부분과 별도의 DEX 파일을 동적 로딩하는 차이점이 존재한다. 이번 분석 대상 앱은 ‘부고장 알림 서비스’라는 이름으로 유포되고 있었으며 24년 3월 27일에 발견되었다.
여느 때와 같이 수집한 APK 파일의 내부를 살펴보기 위해 압축해제를 시도했습니다. 그런데, 패스워드 입력을 요구하는 메시지가 나타났습니다.
APK 파일이 패스워드로 보호되어 있다면 정상적인 안드로이드 기기에 설치 및 실행이 안 돼야 합니다. 하지만 이 APK 파일은 기기에서 설치 및 실행이 아주 잘 됩니다. 이상하다고 생각하여 좀 더 살펴보기로 했습니다. 패스워드가 설정된 APK 파일이 안드로이드 기기에서 잘 작동하는 이유가 뭔지 APK 내부 구조를 파악하며 이유를 알아보겠습니다.
최근 APK 파일을 분석할 때 암호화되어 있다고 오인하게 만드는 기법이 늘어나고 있습니다.
JADX와 같은 APK 분석 도구나 반디집/알집 같은 압축 프로그램으로 APK 파일을 오픈하면 디컴파일 에러나 패스워드를 입력하라는 메시지가 발생하지만 정작 안드로이드 기기에서는 정상적으로 설치되고 실행도 잘 됩니다.
위에서 설명한 트릭은 Central Directory 구조체의 encrypted file 플래그만 설정(enabled)해 두었을 뿐 실제 파일 데이터가 암호화되어 있지 않습니다. 일반 PC용 압축 프로그램(반디집, 알집 등)은 Central Directory의 flags & 0x0001
비트를 확인하고 해당 비트가 설정된 것을 확인하면 압축 해제 패스워드를 요구합니다.
반면, 안드로이드 기기의 패키지 매니저(PM)나 내부 ZIP 파서 라이브러리는 파일 해석 과정에서 암호화되어 있는지를 확인하고 복호화를 진행합니다. 간단하게 요약하면 아래와 같습니다.
즉, 단순히 가짜 암호화 플래그만 설정하는 방법으로는 실제 안드로이드 기기에서 앱 설치를 막을 수 없습니다. 그 결과로 사용자는 PC에서는 압축 프로그램이 암호를 요구하지만, 안드로이드 기기에서는 아무런 문제없이 설치되는 이상한 상황을 경험하게 됩니다.
안드로이드 앱을 담는 APK 파일은 내부적으로 ZIP 포맷 구조를 갖습니다. ZIP 파일은 대략 아래와 같은 구조로 이루어집니다.
ZIP 포맷에서 암호화 여부를 표시하는 위치는 Local File Header와 Central Directory Header입니다.
반디집이나 알집 같은 압축 해제 도구는 Local File Header의 암호화 비트와 상관없이 Central Directory에 기록된 플래그를 우선 검증해 암호화 여부를 판단합니다. 그렇기 때문에 실제로 Local File Header의 암호화 플래그가 1로 설정되어 있어도 Central Directory에 설정된 값에 따라 패스워드 입력 창이 나타나지 않을 수 있습니다.
아래 파이썬 스크립트는 End of Central Directory를 이용해 Central Directory 오프셋을 파싱하고, 해당 위치에서 암호화 플래그(비트 0x0001)를 해제하는 코드입니다. 주요 동작은 다음과 같습니다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import struct
import sys
import os
ZIP_EOCD_SIGNATURE = b'\x50\x4b\x05\x06' # End of Central Directory 시그니처 (0x06054B50)
CENTRAL_DIRECTORY_SIGNATURE = b'\x50\x4b\x01\x02' # Central Directory 시그니처 (0x02014B50)
CENTRAL_DIRECTORY_BASE_SIZE = 46 # FileName 제외한 Central Directory 구조체 크기 (고정)
def find_eocd_offset(data: bytes) -> int:
max_comment_size = 65535
max_eocd_search = max_comment_size + 22
filesize = len(data)
search_size = min(filesize, max_eocd_search)
# 파일 끝에서부터 search_size만큼 잘라내어 End of Central Directory 시그니처를 rfind
window = data[filesize - search_size:]
pos = window.rfind(ZIP_EOCD_SIGNATURE)
if pos == -1:
raise ValueError("End of Central Directory 시그니처(0x06054B50)를 찾을 수 없습니다.")
return (filesize - search_size) + pos
def patch_entry_encrypted(input_file: str) -> list:
# 파일 전체를 메모리에 로드
with open(input_file, "rb") as f:
data = f.read()
# End of Central Directory 오프셋 찾기
eocd_offset = find_eocd_offset(data)
with io.BytesIO(bytearray(data)) as zf:
# End of Central Directory 시그니처 확인
zf.seek(eocd_offset, 0)
eocd_signature = zf.read(4)
if eocd_signature != ZIP_EOCD_SIGNATURE:
raise ValueError("End of Central Directory 시그니처가 올바르지 않습니다.")
# End of Central Directory 구조체 파싱 (최소 22바이트)
zf.seek(eocd_offset, 0)
eocd_data = zf.read(22)
eocd_struct = struct.unpack("<IHHHHLLH", eocd_data)
# 필요한 End of Central Directory 필드만 변수에 매핑
_, _, _, disk_entries, total_entries, central_directory_size, central_directory_offset, comment_len = eocd_struct
# 중앙 디렉터리(Central Directory)로 이동
zf.seek(central_directory_offset, 0)
patched_info = []
for _ in range(total_entries):
cd_offset = zf.tell()
cd_data = zf.read(CENTRAL_DIRECTORY_BASE_SIZE)
# Central Directory 구조체(46바이트 고정) 파싱
cd_struct = struct.unpack("<IHHHHHHIIIHHHHHII", cd_data)
signature_cd = cd_struct[0]
version_by = cd_struct[1]
version_needed = cd_struct[2]
flags = cd_struct[3]
compression = cd_struct[4]
time = cd_struct[5]
date = cd_struct[6]
crc32 = cd_struct[7]
comp_size = cd_struct[8]
uncomp_size = cd_struct[9]
filename_length = cd_struct[10]
extra_length = cd_struct[11]
comment_length = cd_struct[12]
disk_num_start = cd_struct[13]
internal_attr = cd_struct[14]
external_attr = cd_struct[15]
local_header_offset = cd_struct[16]
# 파일명 추출
filename = zf.read(filename_length).decode(errors='replace')
# Extra, Comment는 길이만큼 Skip
zf.seek(extra_length + comment_length, 1)
# 암호화 비트가 켜져 있다면 해제
if (flags & 0x0001) != 0:
old_flags = flags
new_flags_value = flags & ~0x0001 # 비트 0 제거
new_flags = struct.pack("<H", new_flags_value)
# CD 상에서 flags 오프셋은 cd_offset + 8 바이트 지점
flags_offset = cd_offset + 8
# 현재 지점을 저장 후 flags 오프셋에 new_flags 기록
cur_pos = zf.tell()
zf.seek(flags_offset)
zf.write(new_flags)
zf.seek(cur_pos)
patched_info.append({
"FileName": filename,
"PatchOffset": flags_offset,
"OldFlags": old_flags,
"NewFlags": new_flags_value
})
# 패치된 파일 쓰기
output_file = os.path.splitext(input_file)[0] + "_patched.apk"
with open(output_file, "wb") as out:
out.write(zf.getvalue())
return patched_info
def main():
if len(sys.argv) < 2:
print(f"Usage: {os.path.basename(sys.argv[0])} <target_apk_file>")
sys.exit(1)
input_file = sys.argv[1]
if not os.path.exists(input_file):
print(f"[!] 파일이 존재하지 않습니다: {input_file}")
sys.exit(1)
try:
patched_files = patch_entry_encrypted(input_file)
except Exception as e:
print(f"[!] 패치 중 오류가 발생했습니다: {e}")
sys.exit(1)
# 결과 로그 출력 및 파일 기록
log_file = input_file + ".patch.log"
with open(log_file, "w", encoding="utf-8") as lf:
for info in patched_files:
log_line = (f"FileName: {info['FileName']}, "
f"PatchOffset: 0x{info['PatchOffset']:08X}, "
f"OldFlags: 0x{info['OldFlags']:04X}, "
f"NewFlags: 0x{info['NewFlags']:04X}")
print(log_line)
lf.write(log_line + "\n")
print(f"\n[+] 총 {len(patched_files)}개 파일의 암호화 플래그를 해제했습니다.")
print(f"[+] 패치된 APK: {os.path.splitext(input_file)[0]}_patched.apk")
print(f"[+] 패치 로그 파일: {log_file}")
if __name__ == "__main__":
main()
실행하면 아래와 같은 파일과 로그가 생성됩니다.
원본 파일 malware.apk
에 있는 암호화 비트가 해제되어 이제는 압축 툴에서 더 이상 암호를 묻지 않고 정상적으로 압축 해제가 가능합니다.
위 그림처럼 일괄적으로 암호화(encrypted file) 비트를 제거한 ZIP(APK) 파일은 압축 해제 도구에서 패스워드를 요구하지 않습니다. JADX 도구도 패스워드 때문에 분석 못하겠다고 고통스러워하지 않습니다. 이렇게 암호화 비트를 제거하는 방식으로 트릭을 우회하면 정상적으로 APK 내부 파일과 AndroidManifest.xml 등을 확인할 수 있게 됩니다.
악성 앱들이 쉽게 분석 되지 않도록 많은 방법을 동원하고 있습니다. 난독화는 기본이고 이번 포스팅에서 살펴본 암호화 비트를 적용한 트릭 등 다양한 방법을 생각하고 적용하고 있습니다. 그 외에도 OS 환경의 특징, 분석 도구의 버그를 활용한 케이스도 존재합니다.
어차피 분석을 피할 수 없다면 그 시간이라도 최대한 끌어보자는 이러한 전략은 앞으로도 계속 나올 것입니다. 앞으로도 이런 사례들을 수집하고 분석하여 공유하도록 하겠습니다.
이번 포스팅에서는 Central Directory 구조체 암호화(encrypted file) 비트를 패치하는 방법으로 문제를 해결 할 수 있었습니다. 하지만, 이것만으로는 다소 부족한 면이 있습니다. 다음 포스팅에서는 Local File Header도 고려해야 하는 상황을 다뤄보겠습니다.
2024년 월 이후로 부고장 유형의 악성 앱이 지속적으로 탐지되지 않도록 고도화되고 있는 것으로 확인되고 있다. 현재 유포되고 있는 악성 앱은 1부에서 분석된 악성 앱과 동일한 유형이지만 악성 행위 분석 방지 목적으로 newobfs가 적용된 부분과 별도의 DEX 파일을 동적 로딩하는 차이점이 존재한다. 이번 분석 대상 앱은 ‘부고장 알림 서비스’라는 이름으로 유포되고 있었으며 24년 3월 27일에 발견되었다.
문자 메시지를 통해 개인정보를 탈취하는 스미싱의 수법이 갈수록 고도화되고 있다. 최근에는 지인을 사칭하여 부고장, 청첩장 등의 문자를 보내는 스미싱 수법이 성행하고 있다. 부고장 관련 스미싱의 경우 문자 메시지에는 부고장의 인터넷 주소가 첨부되어 있으며, 장례식장 위치와 발인일 등을 확인하려 클릭하면 부고장을 사칭한 악성 앱이 다운로드된다. 다운로드 된 부고장 사칭 앱은 MQTT 통해 C2 서버에서 명령을 전달받고 정의된 명령에 따라 개인 정보를 탈취한다.