Introduction

대부분의 사용자는 로그인 시 입력한 비밀번호가 암호화되어 서버로 전송되거나 앱 내부에만 저장된다고 생각합니다. 하지만, 많은 안드로이드 애플리케이션들이 의도치 않게 민감한 데이터를 안드로이드 운영체제의 여러 시스템 구성 요소와 공유하고 있습니다.

이러한 보안 취약점을 Side Channel Data Leakage라고 부릅니다. 개발자는 데이터를 직접 외부로 전송하지 않았지만, 안드로이드 시스템의 일반적인 기능들(키보드, 로그, 클립보드 등)을 통해 데이터가 노출될 수 있습니다.

AndroGoat 앱 내에 Side Channel Data Leakage 메뉴로 이동하면 총 3개의 기능이 존재합니다.

Side Channel Data Leakage - Keyboard Cache

취약점 개요


키보드 캐시 취약점은 안드로이드 키보드의 자동 완성 기능에서 발생합니다. 대부분의 안드로이드 키보드에는 자동 완성을 위해 새로운 단어를 학습하는 사전 기능이 있습니다. 이 기능은 문자 메시지를 작성할 때는 매우 유용하지만, 비밀번호나 신용카드 번호 같은 민감한 정보를 입력할 때는 심각한 보안 위험이 될 수 있습니다.

문제는 키보드가 입력 필드의 성격을 자동으로 구분하지 못한다는 것입니다. 애플리케이션 개발자가 명시적으로 "해당 필드는 비밀번호이므로 자동 완성을 사용하지 마세요"라고 알려주지 않는다면, 키보드는 비밀번호도 일반 텍스트로 학습하여 저장하게 됩니다. 이렇게 저장된 데이터는 기기를 사용하는 다른 사람이나 악성 앱이 접근할 수 있게 됩니다.

취약점의 위험성


키보드 캐시 취약점이 초래할 수 있는 위험은 여러 측면에서 나타납니다.

  1. 물리적 접근을 통한 정보 유출입니다. 만약 타 사용자가 해당 기기를 통해 우연히 비밀번호의 첫 글자를 입력하게 된다면 자동 완성을 통해 전체 비밀번호를 보여줄 수 있습니다. 또한, 기기 분실 시 습득한 누군가는 화면 잠금이 걸려있더라도, 긴급 전화 화면이나 알림 회신 기능을 통해 텍스트를 입력하여 키보드의 자동 완성을 통해 자주 입력했던 민감 정보를 획득할 수 있습니다.
  2. 루팅된 기기 내에서는 악성 앱이 시스템 권한을 획득하면 키보드 캐시 파일에 직접 접근이 가능합니다. Google 키보드의 경우 캐시 데이터는 /data/data/com.google.android.inputmethod.latin/ 디렉터리에 저장됩니다. 공격자는 이 파일을 읽어 과거에 입력했던 모든 민감한 정보를 획득할 수 있습니다.

기능 분석


AndroGoat 앱 내에서 키보드 캐시 취약점이 어떻게 구현되어 있는지 살펴보겠습니다.

앱을 실행하고 "Side Channel Data Leakage" 메뉴로 이동한 뒤 "Keyboard Cache Activity" 버튼을 클릭합니다.

화면에는 사용자 이름과 비밀번호 입력란이 표시됩니다. 이전에 설명한것과 같이 키보드가 다음 단어를 예측하거나 철자를 자동으로 수정해주기 위해 "사용자 사전"이라는 기능을 사용합니다.

이 기능 자체는 편리하지만, 문제는 키보드가 일반 텍스트와 비밀번호를 구별하지 못한다는 점입니다. 앱 자체적으로 "이곳은 민감한 정보를 입력하는 곳입니다."라고 알려주지 않는다면, 키보드는 민감한 정보도 일반 단어처럼 학습하게 됩니다.

취약점 원인 분석

키보드 캐시 취약점의 근본 원인을 AndroGoat의 실제 코드를 통해 분석해보겠습니다.

PS C:\Users\WIN11> adb -d shell dumpsys window | findstr "mCurrentFocus"
  mCurrentFocus=Window{e459d80 u0 owasp.sat.agoat/owasp.sat.agoat.KeyboardCacheActivity}

먼저 해당하는 기능의 액티비티명을 확인해보겠습니다. 액티비티명인 KeyboardCacheActivity를 확인하고 전체 코드를 살펴보겠습니다.

AndroGoat KeyboardCacheActivity 전체 코드:

package owasp.sat.agoat;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;

/* compiled from: KeyboardCacheActivity.kt */
@Metadata(m131d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u0012\u0010\u0003\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006H\u0014¨\u0006\u0007"}, m132d2 = {"Lowasp/sat/agoat/KeyboardCacheActivity;", "Landroidx/appcompat/app/AppCompatActivity;", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "app_debug"}, m133k = 1, m134mv = {1, 8, 0}, m136xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
/* loaded from: classes2.dex */
public final class KeyboardCacheActivity extends AppCompatActivity {
    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(C1278R.layout.activity_keyboard_cache);
        Button loginButton = (Button) findViewById(C1278R.id.Logging1);
        loginButton.setOnClickListener(new View.OnClickListener() { // from class: owasp.sat.agoat.KeyboardCacheActivity$$ExternalSyntheticLambda0
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) {
                KeyboardCacheActivity.onCreate$lambda$0(this.f$0, view);
            }
        });
    }

    /* JADX INFO: Access modifiers changed from: private */
    public static final void onCreate$lambda$0(KeyboardCacheActivity this$0, View it) {
        Intrinsics.checkNotNullParameter(this$0, "this$0");
        Toast.makeText(this$0.getApplicationContext(), "Please wait while verifying your credentials", 1).show();
    }
}

액티비티 코드 자체에는 특별히 취약한 부분이 존재하지 않습니다. 단순히 레이아웃을 로드하고 로그인 버튼을 설정하는 일반적인 코드입니다.

취약한 부분은 바로 레이아웃 XML 파일에 존재합니다.

핵심: XML 레이아웃 파일

<EditText
        android:id="@+id/userName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="15dp"
        android:layout_marginTop="5dp"
        android:layout_marginRight="15dp"/>

<EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="15dp"
        android:layout_marginTop="5dp"
        android:layout_marginRight="15dp"/>
 

안드로이드 앱의 UI는 XML 레이아웃 파일로 정의됩니다. 이전 디컴파일 코드 내 일부분을 살펴보겠습니다.

setContentView(C1278R.layout.activity_keyboard_cache);

이 코드는 activity_keyboard_cache.xml파일을 로드합니다. 이 XML 파일에 입력 필드가 정의되어 있는데 여기서 문제가 발생합니다.

왜 이것이 문제인가?

먼저, inputType 속성이 누락되어 있습니다. 이 속성이 존재하지 않으면 안드로이드는 기본값인 inputType="text"를 사용합니다. 이는 키보드에게 일반 텍스트로 판단하고 자유롭게 학습이 가능해 자동 완성 제안에 사용할 수 있게 됩니다.

두번째로, 비밀번호 필드조차 특별한 표시가 없습니다. 필드 ID가 password라고 해서 안드로이드가 자동으로 비밀번호로 인식하지 않습니다. 명시적으로 inputTpye="textPassword"를 설정해야만 키보드가 이것을 비밀번호 필드로 인식하게 됩니다.

inputType 속성의 중요성

inputType 속성은 키보드에게 매우 중요한 정보를 전달합니다. 이 속성을 통해 키보드는 다음을 결정합니다:

  1. 어떤 키보드 레이아웃을 표시할 것인가 (일반 키보드, 숫자 키패드, 이메일 전용 키보드 등)
  2. 입력된 텍스트를 어떻게 표시할 것인가 (평문, 마스킹된 점 등)
  3. 자동 완성 제안을 제공할 것인가
  4. 입력된 내용을 사용자 사전에 저장할 것인가

AndroGoat의 경우 이러한 지시가 없기 때문에, 키보드는 비밀번호를 일반 단어처럼 취급하여 사용자 사전에 저장합니다.

데이터 흐름 관점에서의 취약점

이 취약점을 데이터 흐름 관점에서 보면 다음과 같습니다:

  1. 입력 단계: 사용자가 비밀번호를 입력합니다
  2. 캐싱 단계: 키보드가 입력된 단어를 사용자 사전에 저장합니다. (민감 정보가 사용자 사전에 저장)
  3. 저장 위치: /data/data/com.google.android.inputmethod.latin/ 경로에 평문으로 저장
  4. 노출 경로:
    • 같은 기기를 사용하는 다른 사람이 자동 완성 제안으로 확인
    • ADB 접근 권한이 있는 공격자가 파일 시스템에서 직접 읽기
    • 루팅된 기기의 악성 앱이 캐시 파일 접근

취약점 검증 (Proof of Concept)

이제 AndroGoat 앱에서 키보드 캐시 취약점을 직접 확인해보겠습니다. 실습을 통해 이론적인 취약점이 실제로 어떻게 나타나는지 체험할 수 있습니다.

테스트 #1: 사용자 사전에 저장된 비밀번호

STEP 1. 취약한 화면으로 이동

AndroGoat 앱을 실행하고 메인 메뉴에서 "Side Channel Data Leakage"를 선택합니다. 그 다음 "Keyboard Cache Activity"를 탭하여 테스트 화면으로 이동합니다.

화면에는 두 개의 입력 필드가 보입니다. 하나는 사용자 이름용이고 다른 하나는 비밀번호용입니다.

STEP2. 민감한 데이터 입력

사용자 이름 및 비밀번호 칸에 테스트용 계정 정보를 입력합니다. 키보드가 단어를 사전에 등록하려면 단어가 완성되었다는 신호를 받아야 합니다. 이 신호는 스페이스 키나 엔터 키를 누를 때 전송됩니다.

STEP 3. 취약점 확인

이제 앱을 재실행 하고 게정명 부분의 비밀번호의 시작 단어인 c를 입력하면 키보드 상단의 자동 완성에 입력한 비밀번호 전체가 노출되는 것을 볼 수 있습니다.

이것으로 방금 전 입력한 비밀번호 영역의 문자열이 키보드의 사용자 사전에 저장되었습니다.

Side Channel Data Leakage - Insecure Logging

취약점 개요

안전하지 않은 로깅 취약점은 개발 과정에서 사용하는 로그 기능이 프로덕션 환경에 그대로 존재할 때 발생합니다. 개발자들은 애플리케이션을 디버깅하기 위해 Log.d(), Log.e(), System.out.println() 같은 로그 함수를 사용합니다.

문제는 이러한 로그가 민감 정보를 포함하고 있을 때, 그리고 앱이 출시된 후에도 로그 출력 함수가 제거되지 않았을 때 발생합니다.

안드로이드의 시스템 로그는 Logcat이라는 중앙 집중식 로깅 시스템으로 관리됩니다. Logcat에는 기기에서 실행 중인 모든 앱의 로그가 모입니다.

과거 안드로이드 버전에서는 READ_LOGS 권한을 가진 앱이 다른 앱의 로그까지 읽을 수 있었습니다만 현재는 이 권한이 제한되었지만 ADB를 통한 접근이나 루팅된 기기에서는 여전히 모든 로그를 읽을 수 있습니다.

취약점의 위험성

안전하지 않은 로깅을 통해 발생할 수 있는 위험은 매우 다양합니다.

첫째, 인증 정보 유출입니다. 개발자가 디버깅 목적으로 사용자 이름과 비밀번호를 로그에 기록했다면, 이 정보는 시스템 로그에 평문으로 남게 됩니다. 악의적인 앱이나 ADB 접근 권한을 가진 공격자는 이 정보를 수집하여 사용자 계정에 무단으로 접근할 수 있습니다.

둘째, API 키와 토큰 노출입니다. 많은 앱이 백엔드 서버와 통신할 때 API 키나 인증 토큰을 사용합니다. 개발 중에 이러한 값들을 로그로 출력하여 확인하는 경우가 많은데, 이것이 프로덕션 빌드에 남아있으면 공격자가 이 키를 훔쳐 서버에 무단으로 접근할 수 있습니다.

셋째, 개인 식별 정보(PII) 유출입니다. 사용자의 이름, 전화번호, 이메일 주소, 위치 정보 같은 개인정보가 로그에 기록되면 개인정보 침해가 발생합니다.

넷째, 비즈니스 로직 노출입니다. 로그에는 애플리케이션의 내부 동작 방식, 알고리즘, 비즈니스 규칙 등이 기록될 수 있습니다. 공격자는 이 정보를 분석하여 앱의 취약점을 찾거나 보안 메커니즘을 우회하는 방법을 찾아낼 수 있습니다.

다섯째, 데이터 지속성 문제입니다. 로그는 즉시 사라지지 않고 기기에 일정 기간 저장됩니다. 사용자가 몇 주 전에 입력한 민감한 정보가 여전히 로그 파일에 남아있을 수 있습니다. 기기를 판매하거나 폐기할 때 이러한 로그가 완전히 삭제되지 않으면 새 소유자가 과거 데이터에 접근할 수 있습니다.

기능 분석

AndroGoat 앱의 안전하지 않은 로깅 기능을 분석해보겠습니다. 앱의 "Side Channel Data Leakage" 메뉴에서 "Insecure Logging Activity"를 선택하면 로그인 화면이 나타납니다.

일반적인 로그인 폼과 다를 바 없지만, 내부적으로는 보안 문제가 존재합니다.

소프트웨어 개발에서 로깅은 필수적인 도구입니다. 로그는 애플리케이션이 실행되는 동안 발생하는 이벤트, 오류, 디버그 정보를 기록하는 것입니다. 로그 파일은 프로그램에 문제가 생겼을 때 무엇이 잘못되었는지 추적하는 데 도움을 줍니다.

안드로이드에서 개발자들은 주로 다음과 같은 로그 메서드를 사용합니다. Log.v()는 Verbose 레벨로 가장 상세한 정보를 기록하고, Log.d()는 Debug 레벨로 디버깅 정보를 기록합니다. Log.i()는 Info 레벨로 일반적인 정보를, Log.w()는 Warning 레벨로 경고를, Log.e()는 Error 레벨로 오류를 기록합니다.

취약점 원인 분석

AndroGoat의 안전하지 않은 로깅 취약점이 발생하는 원인을 실제 코드를 통해 상세히 분석해보겠습니다. 먼저 해당 기능의 액티비티명을 확인해보겠습니다.

InsecureLoggingActivity로 확인 후 전체 코드를 살펴보겠습니다.

AndroGoat InsecureLoggingActivity 전체 코드:

package owasp.sat.agoat;

import android.content.DialogInterface;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;

/* compiled from: InsecureLoggingActivity.kt */
@Metadata(m131d1 = {"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u0012\u0010\u0003\u001a\u00020\u00042\b\u0010\u0005\u001a\u0004\u0018\u00010\u0006H\u0014¨\u0006\u0007"}, m132d2 = {"Lowasp/sat/agoat/InsecureLoggingActivity;", "Landroidx/appcompat/app/AppCompatActivity;", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "app_debug"}, m133k = 1, m134mv = {1, 8, 0}, m136xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
/* loaded from: classes2.dex */
public final class InsecureLoggingActivity extends AppCompatActivity {
    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(C1278R.layout.activity_insecure_logging);
        final EditText username = (EditText) findViewById(C1278R.id.userName);
        final EditText password = (EditText) findViewById(C1278R.id.password);
        Button loggingButton = (Button) findViewById(C1278R.id.Logging1);
        final AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("Login");
        loggingButton.setOnClickListener(new View.OnClickListener() { // from class: owasp.sat.agoat.InsecureLoggingActivity$$ExternalSyntheticLambda0
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) {
                InsecureLoggingActivity.onCreate$lambda$1(username, password, builder, this, view);
            }
        });
    }

    /* JADX INFO: Access modifiers changed from: private */
    public static final void onCreate$lambda$1(EditText $username, EditText $password, AlertDialog.Builder builder, InsecureLoggingActivity this$0, View it) {
        Intrinsics.checkNotNullParameter(builder, "$builder");
        Intrinsics.checkNotNullParameter(this$0, "this$0");
        String logMessage = "Username: " + ((Object) $username.getText()) + " and Password: " + ((Object) $password.getText()) + " are verified";
        Log.i("Info:", logMessage);
        System.out.println(logMessage);
        builder.setMessage("Username and Password are verified");
        builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: owasp.sat.agoat.InsecureLoggingActivity$$ExternalSyntheticLambda1
            @Override // android.content.DialogInterface.OnClickListener
            public final void onClick(DialogInterface dialogInterface, int i) {
                dialogInterface.dismiss();
            }
        });
        AlertDialog dialog = builder.create();
        Intrinsics.checkNotNullExpressionValue(dialog, "builder.create()");
        dialog.show();
        Toast.makeText(this$0, "Username and Password are verified", 1).show();
    }
}

일반적인 로그인 기능만 존재하는 것처럼 보일 수 있습니다. 하지만 자세히 보면 여러 치명적인 보안 문제가 존재합니다.

문제점 1: 민감한 데이터를 직접 로그에 기록

가장 심각한 문제는 다음 세 줄입니다:

String logMessage = "Username: " + username.getText() + " and Password: " + password.getText() + " are verified";
Log.i("Info:", logMessage);
System.out.println(logMessage);

이 코드에서는 입력한 사용자 이름과 비밀번호를 그대로 logMessage 문자열에 포함시킵니다. 여기서 어떠한 마스킹이나 암호화, 필터링을 거치지 않습니다.

이 민감한 정보를 담은 문자열이 그대로 시스템 로그에 기록됩니다.

문제점 2: 두 가지 방법으로 중복 로깅

두 번째 문제점은 이 민감 정보를 두 가지 다른 방법으로 로그에 기록한다는 점입니다.

Log.i("Info:", logMessage);      // 방법 1: Android Log API
System.out.println(logMessage);  // 방법 2: 표준 출력

첫 번째 줄의 Log.i()는 안드로이드의 공식 로깅 API입니다. "Info:" 태그와 함께 INFO 레벨로 로그를 기록합니다. 이 로그는 Logcat에 다음과 같이 나타납니다:

I/Info:: Username: USERNAME and Password: PASSWORD are verified

두 번째 줄의 System.out.println()은 자바의 표준 출력 스트림에 데이터를 기록합니다. 안드로이드에서는 이 출력도 Logcat으로 리다이렉트되어 다음과 같이 나타납니다.

I/System.out: Username: USERNAME and Password: PASSWORD are verified

같은 민감한 정보가 두 번 로그에 기록되는 것입니다. 이는 공격자에게 더 많은 기회를 제공합니다.

문제점 3: 로그 레벨의 오해

일부 개발자는 "Log.i()는 INFO 레벨이니까 디버그 빌드에서만 나타나지 않을까?"라고 생각할 수 있습니다. 하지만 이것은 잘못된 생각입니다. 안드로이드의 로그 레벨은 다음과 같습니다:

  • Log.v() - VERBOSE (가장 상세함)
  • Log.d() - DEBUG
  • Log.i() - INFO
  • Log.w() - WARNING
  • Log.e() - ERROR

이 레벨들은 단지 로그의 중요도를 나타낼 뿐, 자동으로 필터링되지 않습니다. 릴리스 빌드에서도 Log.i()는 여전히 실행되며, ADB로 연결하면 모든 레벨의 로그를 볼 수 있습니다.

문제점 4: 로그의 지속성

로그는 즉시 사라지지 않습니다. 안드로이드는 순환 버퍼에 로그를 저장하며, 버퍼 크기에 따라 수백에서 수천 줄의 로그가 메모리에 남아있습니다. 기본적으로 안드로이드는 다음과 같은 로그 버퍼를 유지합니다:

  • main 버퍼: 256KB
  • system 버퍼: 256KB
  • radio 버퍼: 256KB
  • events 버퍼: 256KB

이는 사용자가 몇 분 전, 심지어 몇 시간 전에 입력한 비밀번호가 여전히 로그에 남아있을 수 있다는 의미입니다.

공격 시나리오

이 취약점을 악용하는 실제 공격 시나리오를 살펴보겠습니다:

  • 시나리오 1: 악성 앱의 로그 수집
    • 구형 안드로이드(4.1 이하)에서 READ_LOGS 권한을 가진 악성 앱이 실행됨
    • 앱이 백그라운드에서 지속적으로 Logcat을 모니터링
    • "Password:" 키워드를 포함하는 로그를 필터링
    • 수집된 자격 증명을 공격자의 서버로 전송
  • 시나리오 2: USB 디버깅을 통한 로그 수집
    • 사용자가 공공 장소(공항, 카페)에서 USB 포트를 사용하여 기기 충전
    • 충전 스테이션에 숨겨진 소형 컴퓨터가 ADB로 연결
    • 실시간으로 로그를 수집하여 외부로 전송
    • 사용자는 단순히 충전 중이라고 생각하지만 데이터가 유출됨
  • 시나리오 3: 기기 분실 후 로그 추출
    • 사용자가 기기를 분실하거나 중고로 판매
    • 새 소유자가 기술적 지식이 있어 ADB를 활성화
    • adb logcat -d > logs.txt 명령으로 저장된 로그를 추출
    • 과거 사용자의 로그인 정보가 여전히 버퍼에 남아있어 유출됨

취약점 검증 (Proof of Concept)

테스트 #1: 중요정보 로그 내 노출

이제 AndroGoat 앱에서 안전하지 않은 로깅 취약점을 직접 확인해보겠습니다.

1단계: Logcat 모니터링 시작

다양한 방법으로 시스템 로그를 모니터링할 준비를 합니다.

2단계: 앱에서 로그인 시도

이제 AndroGoat 앱 내에서 테스트로 자격 증명을 입력합니다. 그리고 버튼을 클릭합니다.

버튼 클릭 시 정상적으로 증명되었다는 메시지가 나옵니다. 이제 수집하고 있는 로그를 분석해보겠습니다.

3단계: 로그 분석

이제 수집하고 있는 Log를 확인합니다.

입력한 자격 증명이 Logcat 내에 두 부분에 기록되는 것을 볼 수 있습니다. 이 로그는 기기에 저장되어 있으며, ADB 접근 권한이 있는 누구나 읽을 수 있습니다.

더 심각한 것은 이 로그가 즉시 사라지지 않아, 버퍼가 가득 차기 전까지는 과거 로그가 계속 남아있습니다.

Side Channel Data Leakage - Clipboard

취약점 개요

클립보드 유출 취약점은 안드로이드의 시스템 클립보드가 전역적으로 접근 가능하다는 특성에서 발생합니다. 클립보드는 사용자가 텍스트나 데이터를 복사할 때 임시로 저장하는 공간입니다. 문제는 안드로이드의 클립보드가 시스템 전체에서 공유된다는 점입니다. 한 앱에서 무언가를 복사하면 기기의 다른 모든 앱이 그 내용을 읽을 수 있습니다.

많은 앱이 사용자 편의를 위해 OTP(일회용 비밀번호), 신용카드 번호, 비밀번호 같은 민감한 정보를 자동으로 클립보드에 복사합니다. 예를 들어, 인증 코드를 받았을 때 "복사하기" 버튼을 제공하거나, 비밀번호 관리자가 비밀번호를 클립보드에 복사하여 다른 앱에 붙여넣을 수 있게 합니다. 이런 편의 기능이 오히려 심각한 보안 위험을 만들어냅니다.

취약점의 위험성

클립보드 유출 취약점의 위험성은 매우 광범위하고 실제적입니다.

첫째, OTP 탈취 위험입니다. 여러분이 은행 앱에 로그인하려고 SMS로 받은 6자리 OTP를 복사한다고 가정해봅시다. 백그라운드에서 실행 중인 악성 앱이 클립보드를 모니터링하고 있다면, 이 OTP를 즉시 가로채서 공격자의 서버로 전송할 수 있습니다. OTP는 시간 제한이 있지만, 공격자가 빠르게 행동하면 여러분의 계정에 접근할 수 있습니다.

둘째, 비밀번호 관리자의 역설입니다. 보안을 강화하기 위해 비밀번호 관리자를 사용하는 사용자가 늘고 있습니다. 이런 앱들은 강력한 무작위 비밀번호를 생성하고 저장하여, 사용자는 복사/붙여넣기로 비밀번호를 입력합니다. 하지만 복사하는 순간 그 비밀번호가 클립보드를 통해 다른 앱에 노출됩니다. 보안을 강화하려던 노력이 오히려 취약점을 만드는 아이러니한 상황입니다.

셋째, 신용카드 정보 유출입니다. 온라인 쇼핑을 할 때 사용자는 때때로 신용카드 번호를 메모장이나 다른 앱에서 복사하여 결제 양식에 붙여넣습니다. 이 과정에서 16자리 카드 번호, CVV, 유효기간 등이 클립보드에 남게 되고, 악성 앱이 이를 수집할 수 있습니다.

넷째, 지속성 문제입니다. 클립보드의 내용은 다른 내용을 복사하거나 기기를 재부팅할 때까지 계속 남아있습니다. 사용자가 30분 전에 복사한 비밀번호가 여전히 클립보드에 남아있을 수 있고, 그 시간 동안 설치된 악성 앱이 이를 읽을 수 있습니다.

다섯째, 권한이 필요 없다는 점입니다. 안드로이드에서 클립보드를 읽는 데는 특별한 권한이 필요하지 않습니다. 어떤 앱이든 설치되자마자 클립보드 내용을 읽을 수 있습니다. 사용자는 이 앱이 클립보드를 모니터링하고 있다는 사실을 전혀 알 수 없습니다.

기능 분석

AndroGoat 앱의 클립보드 유출 기능을 분석해보겠습니다. "Side Channel Data Leakage" 메뉴에서 "Clipboard Activity"를 선택하면 OTP를 생성하고 복사하는 기능이 있는 화면이 나타납니다.

클립보드가 어떻게 작동하는지 이해하는 것이 중요합니다. 클립보드는 운영체제가 제공하는 시스템 서비스입니다. 사용자나 앱이 텍스트를 "복사"하면 그 데이터는 메모리의 특별한 영역에 저장됩니다. 다른 앱에서 "붙여넣기"를 하면 이 영역에서 데이터를 읽어옵니다.

여러분이 웹 브라우저에서 URL을 복사하고 메시지 앱에 붙여넣을 수 있는 이유가 바로 클립보드가 전역 리소스이기 때문입니다. 이것은 매우 편리한 기능이지만, 보안 관점에서는 문제입니다. 클립보드에 접근하는 데 특별한 권한이 필요하지 않기 때문에, 모든 앱이 클립보드의 내용을 읽을 수 있습니다.

AndroGoat 앱은 이 취약점을 시연하기 위해 OTP를 자동으로 클립보드에 복사하는 기능을 구현했습니다. 실제 많은 앱들이 사용자 편의를 위해 이런 기능을 제공하지만, 적절한 보안 조치 없이 구현하면 위험합니다.

취약점 원인 분석

AndroGoat의 클립보드 유출 취약점이 발생하는 원인을 실제 코드를 통해 상세히 분석해보겠습니다.

현재 액티비티인 ClipboardActivity의 전체 코드를 먼저 살펴보겠습니다.

AndroGoat ClipboardActivity 전체 코드:

package owasp.sat.agoat;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import kotlin.collections.CollectionsKt;
import kotlin.ranges.IntRange;

public final class ClipboardActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_clipboard);
        
        final EditText ccValue = (EditText) findViewById(R.id.cc);
        Button verifyCC = (Button) findViewById(R.id.verifyCC);
        final AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("Alert!");
        
        verifyCC.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // 신용카드 번호 입력값 가져오기
                String ccValue1 = ccValue.getText().toString().trim();
                
                if (ccValue1.length() > 0) {
                    // 1. 랜덤 OTP 생성 (1000~9999 사이의 숫자)
                    int otp = ((Number) CollectionsKt.first(
                        CollectionsKt.shuffled(new IntRange(1000, 9999))
                    )).intValue();
                    
                    // 2. 클립보드 매니저 가져오기
                    Object systemService = getSystemService("clipboard");
                    ClipboardManager clipboard = (ClipboardManager) systemService;
                    
                    // 3. OTP를 클립보드에 복사 (취약점)
                    ClipData clip = ClipData.newPlainText("CC Card", String.valueOf(otp));
                    clipboard.setPrimaryClip(clip);
                    
                    // 4. 사용자에게 알림
                    builder.setMessage("OTP Generated and Copied: " + otp);
                    Toast.makeText(getApplicationContext(), 
                        "OTP Generated and Copied: " + otp, 
                        Toast.LENGTH_SHORT).show();
                } else {
                    builder.setMessage("Credit Card shouldn't be blank");
                    Toast.makeText(getApplicationContext(), 
                        "Credit Card shouldn't be blank", 
                        Toast.LENGTH_SHORT).show();
                }
                
                builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialogInterface, int i) {
                        dialogInterface.dismiss();
                    }
                });
                
                AlertDialog dialog = builder.create();
                dialog.show();
            }
        });
    }
}

이 코드는 신용카드 검증을 위한 OTP(일회용 비밀번호)를 생성하고 자동으로 클립보드에 복사하는 기능을 구현합니다. 실제 많은 앱에서 사용자 편의를 위해 제공하는 기능이지만, 여러 보안 문제가 숨어있습니다. 단계별로 분석해보겠습니다.

문제점 1: 자동 복사 - 사용자 동의 없는 클립보드 접근

코드의 핵심 부분을 보겠습니다

// OTP 생성
int otp = ((Number) CollectionsKt.first(
    CollectionsKt.shuffled(new IntRange(1000, 9999))
)).intValue();

// 클립보드 매니저 가져오기
Object systemService = getSystemService("clipboard");
ClipboardManager clipboard = (ClipboardManager) systemService;

// OTP를 자동으로 클립보드에 복사
ClipData clip = ClipData.newPlainText("CC Card", String.valueOf(otp));
clipboard.setPrimaryClip(clip);

이 코드는 사용자가 "복사" 버튼을 명시적으로 클릭하지 않았는데도 OTP를 자동으로 클립보드에 복사합니다. 버튼 이름이 "verifyCC"(신용카드 검증)이므로, 사용자는 단순히 신용카드 번호를 확인하는 것으로 생각할 수 있습니다. 하지만 내부적으로는 민감한 OTP가 시스템 클립보드에 저장되고 있습니다.

이것은 사용자의 인지 없이 데이터를 노출시킨다는 점에서 문제가 있습니다. 보안의 기본 원칙 중 하나는 "최소 권한의 원칙"입니다. 즉, 꼭 필요한 경우에만, 사용자가 명시적으로 허용한 경우에만 민감한 작업을 수행해야 합니다.

문제점 2: 평문 저장 - 암호화 없는 클립보드 데이터

ClipData clip = ClipData.newPlainText("CC Card", String.valueOf(otp));

ClipData.newPlainText() 메서드는 텍스트를 평문 그대로 클립보드에 저장합니다. "PlainText"라는 이름에서 알 수 있듯이, 어떠한 암호화나 보호 조치도 적용하지 않습니다. 이는 클립보드에 접근할 수 있는 모든 앱이 이 OTP를 즉시 읽을 수 있다는 의미입니다.

안드로이드에서 클립보드는 시스템 전역 리소스입니다. 한 앱에서 클립보드에 복사한 내용을 다른 모든 앱이 읽을 수 있습니다. 더 나쁜 것은, 클립보드를 읽는 데 특별한 권한이 필요하지 않다는 점입니다. 어떤 앱이든 다음 코드만으로 클립보드 내용을 읽을 수 있습니다:

ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = clipboard.getPrimaryClip();
if (clipData != null && clipData.itemCount > 0) {
    String text = clipData.getItemAt(0).getText().toString();
    // 읽은 데이터를 마음대로 사용 가능
}

문제점 3: 클립보드 지속성 - 자동 삭제 메커니즘 부재

클립보드에 복사된 OTP는 다음 중 하나가 발생할 때까지 계속 남아있습니다:

  • 사용자가 다른 내용을 복사할 때
  • 기기를 재부팅할 때
  • 앱이 명시적으로 클립보드를 지울 때

AndroGoat 코드에는 세 번째 옵션, 즉 자동으로 클립보드를 지우는 메커니즘이 전혀 없습니다. 사용자가 OTP를 사용한 후에도 그 OTP는 계속 클립보드에 남아있습니다. 5분 후에, 심지어 1시간 후에도 여전히 접근 가능할 수 있습니다.

이는 공격 창구를 불필요하게 오래 열어두는 것입니다. 시간이 지날수록 악성 앱이 설치될 가능성도, 다른 사람이 기기에 접근할 가능성도 높아집니다.

취약점 검증 (Proof of Concept)

테스트 #1: 타 앱에서 클립보드내용 공유

이제 AndroGoat 앱에서 클립보드 유출 취약점을 직접 확인해보겠습니다. 이 실습을 통해 클립보드를 통한 데이터 유출이 얼마나 쉽게 일어날 수 있는지 체험할 수 있습니다.

1단계: OTP 생성 및 자동 복사

임의의 Credit Card번호 입력 후 OTP를 생성합니다. 앱은 4자리 숫자로된 OTP를 생성하고 자동으로 클립보드에 복사합니다. 여기서 주목할 점은 따로 복사 버튼을 누르지 않았다는 점입니다.

OTP가 생성되자마자 자동으로 클립보드에 저장되었습니다.

2단계: 다른 앱에서 클립보드 내용 확인

메모장 앱이나 웹 브라우저를 엽니다. "붙여넣기" 옵션을 통해 붙여넣게 되면 이전에 생성한 OTP 번호가 그대로 붙여지게 됩니다. 이는 클립보드를 통한 데이터가 공유된 부분을 확인할 수 있습니다.

클립보드는 시스템 전역 리소스로 다른 모든 앱이 읽을 수 있습니다.