Insecure Data Storage란 무엇인가?

Insecure Data Storage는 애플리케이션이 개발 과정에서 중요한 정보를 암호화하지 않거나 보안이 취약한 방식으로 기기에 저장하는 것을 의미합니다. 여기서 중요한 정보란 비밀번호, 개인정보, 인증 토큰, API 키, 금융 정보 등 유출되었을 때 사용자에게 직접적인 피해를 줄 수 있는 모든 데이터를 포함합니다.

이러한 취약점이 위험한 이유는 명확합니다. 기기를 분실하거나 멀웨어에 감염되면 공격자가 손쉽게 개인정보를 탈취할 수 있고, 이는 신원 도용, 금융 사기, 계정 탈취 등의 2차 피해로 이어집니다. 더욱이 많은 개발자들이 이러한 위험성을 충분히 인지하지 못한 채 편의성을 우선시하여 데이터를 평문으로 저장하는 경우가 많습니다.

Android의 데이터 저장 메커니즘

Android 시스템은 개발자에게 여러 선택지를 제공하는데, 각각의 저장 방식은 서로 다른 목적과 보안 특성을 가지고 있습니다.

내부 저장소는 앱 전용 공간으로 다른 앱이 접근할 수 없도록 기본적으로 보호됩니다. 하지만 루팅된 기기에서는 이러한 보호가 무력화됩니다. 외부 저장소는 모든 앱이 접근 가능한 공유 공간으로, SD 카드나 공유 디렉토리를 의미합니다. SharedPreferences는 간단한 키-값 쌍을 XML 형태로 저장하는 메커니즘이며, SQLite 데이터베이스는 구조화된 대량의 데이터를 관리하기 위한 관계형 데이터베이스입니다.

각 저장 방식은 고유한 장점이 있지만, 암호화나 접근 제어 없이 사용하면 모두 취약점이 될 수 있습니다. 이제 실제 취약한 앱을 분석하면서 각 저장 방식의 문제점을 직접 확인해보겠습니다.

AndroGoat - Insecure Data Storage

해당 메뉴로 들어가게되면 총 5개의 기능이 존재합니다.

시나리오 1: Shared Preferences - PART 1

Android에서 널리 사용되는 저장 방식인 Shared Preferences의 취약점을 살펴봅니다. SharedPreferences는 앱의 설정값이나 간단한 사용자 정보를 저장하기 위해 설계되었지만, 인증 토큰이나 비밀번호까지 여기에 저장하는 실수가 발생합니다.

  • 일반적인 예: "자동 로그인 여부 = True", "알림 설정 = On", "사용자 ID = user123"

/data/data/<패키지이름>/shared_prefs/ 경로에 XML 파일 형태로 내용이 저장됩니다. 루팅된 단말기나 에뮬레이터에서 이 XML 파일을 열었을 때, 비밀번호나 인증 토큰 같은 중요 정보가 들어있는지 확인하는 과정입니다.

SHARED PREFERENCES - PART1 버튼 클릭 후 들어간 화면에서 사용자명과 비밀번호를 입력할 수 있는 기능이 존재합니다. 먼저 현재 페이지의 액티비티명을 다음과 같은 명령어로 찾아보겠습니다.

PS C:\Users\WIN11> adb shell dumpsys activity activities | findstr "mResumedActivity"
    mResumedActivity: ActivityRecord{d05bc42 u0 owasp.sat.agoat/.InsecureStorageSharedPrefs t62}

현재 액티비티는 InsecureStorageSharedPrefs로 확인했습니다. 이제 JADX 도구를 활용해 디컴파일한 코드를 확인해보겠습니다.

코드에서 취약한 부분만 따로 보겠습니다.

// SharedPreferences 인스턴스 생성 (파일 이름: "users", 모드: 0 -> MODE_PRIVATE)
SharedPreferences sharedPreference = this$0.getSharedPreferences("users", 0);
SharedPreferences.Editor editor = sharedPreference.edit();

// 사용자가 입력한 Username을 그대로 저장
editor.putString(ContentProviderActivity.USERNAME, $username.getText().toString());

// 사용자가 입력한 Password를 암호화 없이 그대로 저장
editor.putString("password", $password.getText().toString());

editor.commit(); // 파일에 쓰기 실행

이 코드는 사용자가 입력한 아이디와 비밀번호를 아무런 암호화 없이 그대로 XML 파일에 저장합니다. 공격자는 루팅된 기기에서 경로로 이동하여 이 파일을 열어볼 수 있습니다.

먼저 해당 페이지에서 임의의 계정 정보를 입력하고 버튼을 누르면 메시지와 함께 증명되었다는 메시지가 나옵니다.

그런 다음 /data/data/owasp.sat.agoat/shared_prefs 경로로 이동한 뒤 생성된 users.xml파일을 열어 확인하면 이전에 입력했던 계정 정보가 평문으로 저장되어 확인할 수 있었습니다.

시나리오 2: Shared Preferences - PART 2

두 번째 메뉴인 SHARED PREFERENCES - PART2로 이동했습니다.

마찬가지로, 현재 페이지의 액티비티명을 우선 확인했습니다.

PS C:\Users\WIN11> adb shell dumpsys activity activities | findstr "mResumedActivity"
    mResumedActivity: ActivityRecord{2107460 u0 owasp.sat.agoat/.InsecureStorageSharedPrefs1Activity t62}

기능을 간단히 살펴보면 SCORE버튼을 누르면 점수가 올라가고 목표는 10000점을 얻는 것입니다.

    private final int getScoreFromSP() {
        int score = 0;
        int level = 1;
        SharedPreferences sharedPreferences = getSharedPreferences("score", 0);
        if (sharedPreferences.getInt("score", 0) != 0 && sharedPreferences.getInt("level", 0) != 0) {
            score = sharedPreferences.getInt("score", 0);
            level = sharedPreferences.getInt("level", 0);
        }
        System.out.println((Object) ("Score is " + score + " and Level is " + level));
        return score;
    }

코드를 살펴보면 getScoreFormSP 메서드에서 shared_prefs 폴더 내에 저장된 score.xml 파일을 읽어 점수와 레벨을 가져오고 있습니다.

몇 번의 클릭 후 점수를 5로 올린 다음, 폴더 내 저장된 score.xml 파일을 확인해보겠습니다.

/data/data/owasp.sat.agoat/shared_prefs/score.xml 파일 내 값을 확인하면 상승한 점수로 표기되어 있음을 확인할 수 있습니다. 이는 앱이 실행되거나 버튼을 누를 때, 파일에 적혀 있는 숫자를 그대로 읽어와서 로직에 반영하고 있습니다. 이 파일은 외부에 의해 변조되었는지 여부는 확인하지 않습니다.

정상적인 방법으로 10000번을 누르는 대신, 저장된 파일의 숫자를 변경함으로써 이 단계를 통과할 수 있습니다.

beyondx:/data/data/owasp.sat.agoat/shared_prefs # sed -i 's/value="5"/value="99999"/g' score.xml
beyondx:/data/data/owasp.sat.agoat/shared_prefs #
beyondx:/data/data/owasp.sat.agoat/shared_prefs # cat score.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <int name="score" value="99999" />
    <int name="level" value="1" />
</map>

sed 명령어를 통해 파일에 적힌 599999로 변경합니다. 앱을 재실행 후 동일 메뉴에 들어가면 새로운 score 값으로 변경된 것을 확인할 수 있습니다. 한번더 SCORE 버튼을 클릭한다면 게임에서 이겼다는 메시지가 나옵니다.

만약 이 값이 게임 내 포인트, 프리미엄 기능의 활성화 여부 등을 저장하는 것이라면 간단하게 값 수정을 통해 공격자가 이용할 수 있습니다. 중요 정보의 경우 서버 측에서 검증이 필요합니다.

시나리오 3: SQLite 데이터베이스

SQLite 데이터베이스는 더 복잡한 데이터 구조를 저장할 때 사용됩니다. 관계형 데이터를 효율적으로 관리할 수 있지만, 암호화 없이 사용하면 민감한 정보가 노출될 수 있습니다.

동일하게 SQLite 메뉴로 이동합니다.

이전과 동일하게, 아이디와 비밀번호를 입력할 수 있는 기능이 존재합니다.

protected void onCreate(Bundle savedInstanceState) throws SQLException {
        super.onCreate(savedInstanceState);
        try {
            this.mDB = openOrCreateDatabase("aGoat", 0, null);
            SQLiteDatabase sQLiteDatabase = this.mDB;
            if (sQLiteDatabase != null) {
                sQLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS users (ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, username VARCHAR, password VARCHAR)");
            }
        } catch (Exception e) {
            Log.e("Error:", "Error occurred while creating database: " + e.getMessage());
        }
        setContentView(C1278R.layout.activity_insecure_storage_sqlite);
        Button saveButton = (Button) findViewById(C1278R.id.SQLButton);
        final EditText username = (EditText) findViewById(C1278R.id.userName);
        final EditText password = (EditText) findViewById(C1278R.id.password);
        saveButton.setOnClickListener(new View.OnClickListener() { // from class: owasp.sat.agoat.InsecureStorageSQLiteActivity$$ExternalSyntheticLambda0
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) throws SQLException {
                InsecureStorageSQLiteActivity.onCreate$lambda$1(this.f$0, username, password, view);
            }
        });
    }

해당 코드를 확인해보겠습니다. 코드 확인 시 데이터베이스명은 aGoat이며 테이블명은 users임을 먼저 확인할 수 있습니다. 중요한 점은 이 데이터베이스가 암호화되지 않고 데이터가 저장되고 있다는 것입니다.

앱에서 먼저 사용자 이름과 비밀번호를 입력하고 버튼을 클릭합니다.

앱에서 사용자 정보를 저장하고 있다면, 다음 경로에 저장됩니다.

/data/data/owasp.sat.agoat/databases

실제로 해당 폴더에 접근하게되면 aGoat라는 파일이 생성된 것을 확인할 수 있습니다. 이 파일을 로컬 컴퓨터로 복사하여 SQLite Browser 같은 도구로 열어보겠습니다.

이는 이전에 입력한 계정 정보가 데이터베이스에 평문으로 저장되어 있음을 확인할 수 있습니다. 이는 단 한명의 사용자 정보뿐만 아니라, 데이터베이스 내 저장된 모든 사용자의 정보가 노출될 수 있다는 것을 의미합니다.

시나리오 4: TEMP File

개발자들은 종종 임시 데이터를 저장하기 위해 임시 파일을 생성합니다. 디버깅, 로깅, 또는 일시적인 데이터 처리를 위해 만들어진 이러한 파일들이 제대로 삭제되지 않으면 심각한 보안 문제가 됩니다.

TEMP FILE 버튼을 클릭하고 이동하면 계정 정보를 입력할 수 있는 기능이 존재합니다.

먼저 사전에 임의의 계정 정보를 입력합니다. InsecureStorageTempActivity 코드로 이동해 살펴봅니다.

val userinfo = File.createTempFile("users", "tmp", File(applicationInfo.dataDir))
userinfo.setReadable(true)
userinfo.setWritable(true)
val fw = FileWriter(userinfo)
fw.write("username is ${username.text}\n")
fw.write("password is ${password.text}\n")
fw.close()

이 코드는 앱의 데이터 디렉토리에 임시 파일을 생성하고, 사용자의 계정 정보를 평문으로 기록합니다. 먼저, 데이터는 암호화되지 않았습니다. 다음으로 파일을 명시적으로 삭제하는 코드가 없고 권한 또한 느슨하게 설정되어 있습니다.

/data/data/owasp.sat.agoat로 이동 후 users로 시작하는 임시 파일이 생성되었음을 확인할 수 있습니다. 해당 파일을 실제로 읽었을 때 이전에 입력한 계정 정보가 평문으로 저장되어있음을 확인했습니다.

이 파일을 삭제하는 코드는 없기 때문에 그대로 발견되기 전까지 남아있을 것입니다.

시나리오 5: External Storage (SDCARD)

외부 저장소는 다른 저장 방식들과는 본질적으로 다른 보안 특성을 가지고 있습니다. SD 카드나 공유 저장 공간은 모든 앱이 읽기 권한만 획득하면 접근할 수 있는 공개된 영역입니다.

EXTERNAL STORAGE - SDCARD 버튼 클릭 후 이동하면 동일하게 계정 정보를 입력할 수 있는 기능이 있습니다.

PS C:\Users\WIN11> adb shell dumpsys activity activities | findstr "mResumedActivity"
    mResumedActivity: ActivityRecord{b857aa6 u0 owasp.sat.agoat/.InsecureStorageSDCardActivity t65}

현재 페이지의 액티비티명은 InsecureStorageSDCardActivity입니다. 해당하는 코드부터 확인해보겠습니다.

if (Environment.getExternalStorageState() == "mounted") {
    val data = "Username - ${username.text} Password - ${password.text}\n"
    val userinfo = File.createTempFile("users", "_tmp", getExternalFilesDir(null))
    userinfo.setReadable(true)
    userinfo.setWritable(true)
    val fw = FileWriter(userinfo)
    fw.write(data)
    fw.close()
}

Environment.getExternalStorageState()"mounted"인지 확인합니다. 즉, SD 카드가 장착되어 있거나 에뮬레이트된 외부 저장소가 쓰기 가능한 상태인지 체크합니다.

getExternalFilesDir(null)/storage/emulated/0/Android/data/owasp.sat.agoat/files/와 같은 앱 전용 외부 디렉토리를 반환합니다. 이 위치는 외부 저장소 위에 있기 때문에,

  • 사용자가 USB로 PC에 연결하면 일반 폴더처럼 파일 내용을 직접 볼 수 있고,
  • 루팅된 기기나 광범위 파일 접근 권한(SAF, MANAGE_EXTERNAL_STORAGE 등)을 가진 앱, 포렌식/백업 도구도 내용을 쉽게 읽을 수 있습니다.
  • 즉 “앱 전용”일 뿐 OS 레벨에서 강하게 보호되는 영역은 아니기 때문에, 여기에는 비밀번호나 토큰 같은 민감 정보를 평문으로 저장하면 안 됩니다.

먼저 앱이 미디어 액세스가 가능하도록 권한 설정을 해줍니다.

권한을 획득한 다음, 앱 내에서 계정 정보 입력 후 버튼을 클릭합니다.

PS C:\Users\WIN11> adb shell
beyondx:/ $
beyondx:/ $ su
beyondx:/ #
beyondx:/ # ls -l /storage/emulated/0/Android/data/owasp.sat.agoat/files/
total 8
-rw-rw---- 1 u0_a296 sdcard_rw 114 2026-01-13 21:08 users6893894229112840279_tmp
beyondx:/ #
beyondx:/ # cat /storage/emulated/0/Android/data/owasp.sat.agoat/files/users6893894229112840279_tmp
This data is stored in SdCard on Tue Jan 13 21:08:16 GMT+09:00 2026:
 Username - sdcard_user Password -P@ssw0rd!

그런 다음, /storage/emulated/0/Android/data/owasp.sat.agoat/files/ 경로로 이동하게 되면 users로 시작하는 임시 파일이 생성되어 있습니다. 이 파일을 읽으면 이전에 입력한 계정 정보가 평문으로 저장되어 있습니다.

Mitigation

SharedPreferences의 평문 저장 문제

민감한 정보를 평문으로 SharedPreferences에 저장하는 것입니다. SharedPreferences는 Android에서 가장 간단하게 키-값 쌍을 저장할 수 있는 방법이지만, 기본적으로 데이터를 암호화하지 않고 XML 파일 형태로 저장합니다. 이는 디바이스에 접근할 수 있는 공격자가 쉽게 데이터를 읽을 수 있다는 의미입니다.

  • EncryptedSharedPreferences 사용 (Jetpack Security):
    • 표준 SharedPreferences 대신 Android Jetpack Security 라이브러리의 EncryptedSharedPreferences를 사용해야 합니다.
    • 이 API는 Key와 Value를 자동으로 암호화하여 저장하며, 암호화 키는 Android Keystore System을 통해 안전하게 관리됩니다.
  • 민감 정보 저장 최소화:
    • 가능하면 비밀번호를 로컬에 직접 저장하지 않습니다. 대신 서버로부터 발급받은 Session Token이나 Access Token을 저장하고, 이 또한 암호화하여 관리해야 합니다.

SharedPreferences 데이터 변조 위협

두 번째 시나리오는 게임 점수와 같은 중요한 로직 변수에 대한 무결성 검증이 부재한 경우입니다. 공격자는 루팅된 디바이스나 간단한 툴을 사용하여 SharedPreferences 파일을 직접 수정할 수 있습니다. 예를 들어, 게임 점수를 저장하는 변수 값을 직접 변경하여 부정한 이득을 취할 수 있습니다.

  • 서버 사이드 검증 (Server-Side Validation):
    • 게임 점수, 결제 정보, 아이템 획득 여부 등 신뢰성이 필요한 데이터는 클라이언트(앱)가 아닌 서버에서 관리하고 검증해야 합니다. 앱은 단순히 요청만 보내고, 로직 처리는 서버에서 수행하는 것이 가장 안전합니다.
  • 데이터 무결성 검증 (HMAC):
    • 부득이하게 로컬에 저장해야 한다면, 데이터의 해시값(HMAC)을 함께 저장합니다. 데이터를 불러올 때 저장된 해시값과 현재 데이터의 해시값을 비교하여, 데이터가 외부(공격자)에 의해 변조되었는지 확인합니다.
  • 난독화 및 암호화:
    • 변수명을 score와 같이 유추하기 쉬운 이름 대신 난독화하고, EncryptedSharedPreferences를 사용하여 값을 암호화하면 sed 등을 이용한 단순 조작을 어렵게 만들 수 있습니다.

SQLite 데이터베이스 암호화 누락

세 번째 문제는 SQLite 데이터베이스 파일 자체가 암호화되지 않아, 공격자가 파일을 복사한 후 외부에서 열람할 수 있는 경우입니다. Android의 기본 SQLite는 암호화 기능을 제공하지 않으므로, 데이터베이스 파일에 접근만 할 수 있다면 모든 내용을 확인할 수 있습니다.

  • SQLCipher 적용:
    • 오픈소스 라이브러리인 SQLCipher를 사용하여 데이터베이스 파일 전체를 암호화합니다. 이를 통해 공격자가 DB 파일을 탈취하더라도 내용을 열람할 수 없게 만듭니다. (256-bit AES 암호화 제공)
  • 비밀번호 해싱 (Hashing):
    • 비밀번호는 데이터베이스가 암호화되어 있더라도 평문으로 저장해서는 안 됩니다. PBKDF2, bcrypt, Argon2와 같은 강력한 해시 함수를 사용하고 Salt를 첨가하여 해시값 형태로 저장해야 합니다.

임시 파일 관리 소홀

네 번째 시나리오는 임시 파일의 불필요한 생성, 느슨한 권한 설정, 그리고 사용 후 삭제 로직의 부재와 관련된 문제입니다. 많은 앱들이 처리 과정에서 임시 파일을 생성하지만, 이러한 파일들이 적절히 삭제되지 않거나 누구나 읽을 수 있는 권한으로 설정되어 있으면 심각한 보안 위협이 됩니다.

  • 메모리 내 처리:
    • 민감한 데이터는 파일 시스템(디스크)에 쓰지 않고, 가능한 메모리(RAM) 내에서만 처리한 뒤 사용이 끝나면 변수를 초기화하여 소멸시키는 것이 원칙입니다.
  • 내부 캐시 사용 및 즉시 삭제:
    • 파일 생성이 필수적이라면 Context.getCacheDir() (내부 캐시)를 사용하고, 파일 사용이 끝나는 즉시 file.delete()를 호출하거나 file.deleteOnExit()를 설정하여 잔존하지 않도록 합니다.
  • 권한 설정 강화:
    • 파일 생성 시 다른 앱이나 사용자가 접근할 수 없도록 권한을 엄격하게 제한해야 합니다.

외부 저장소 사용의 위험성

마지막 다섯 번째 문제는 SD카드와 같은 외부 저장소에 민감한 정보를 저장하는 것입니다. 외부 저장소는 모든 앱과 사용자가 접근할 수 있는 공유 공간이므로, 여기에 저장된 데이터는 보안이 전혀 보장되지 않습니다.

  • 내부 저장소 사용 원칙 (Internal Storage):
    • 보안이 필요한 데이터는 반드시 앱의 샌드박스가 적용되는 **내부 저장소 (Context.getFilesDir() 또는 getDatabasePath())**에 저장해야 합니다. 이곳은 루팅되지 않은 기기에서 다른 앱의 접근이 차단됩니다.
  • 외부 저장소 사용 시 암호화:
    • 용량 문제 등으로 불가피하게 외부 저장소를 사용해야 한다면, 파일 자체를 강력하게 암호화한 뒤 저장해야 합니다. 하지만 이때 암호화 키 관리에 각별한 주의가 필요합니다.
  • Content Provider 활용:
    • 다른 앱과 데이터를 공유해야 하는 경우, 파일을 직접 공유하기보다는 적절한 권한 제어가 구현된 Content Provider를 사용하는 것이 안전합니다.