SekaiCTF 2025
TL;DR
pjsk主题高难度CTF,邀请了第一次打CTF的朋友一起来玩,但显然这个比赛并不适合初学者。就做出来半道安卓逆向题,500+名开外了
Sekai Bank - Signature
给了一个apk,没有更多的信息了。
环境配置
本地调试环境如下:
- Mumu模拟器12:PC端模拟安卓环境。
- 设置中心打开Root权限
- 网络部分不要启用桥接模式,否则无法连接adb。不启用桥接模式的话,就类似Docker NAT模式,模拟器自己有内网IP。
- 需要在网络部分开启代理服务器,指向宿主机抓包工具的非回环(即不能是127.0.0.1或localhost)IP地址和端口。
- Reqable: 抓包工具。安装在PC端。
- 设置好抓包监听IP和端口,开启系统代理
- 安装抓包用的根证书。Mumu里不用安装。
- dex2jar:将dex字节码转换为jar文件。由一系列脚本文件构成。
- JADX:JAR逆向工具
- 需要一个JRE环境。我本地是OPENJDK 21。
- apksigner:SDK Build Tools的一部分,用于检查APP的签名。
- 注意JAVA安装后的
keytool只能用于提取JAR包的签名,而整个APK的签名无法提取。 - 可以了解一下安卓V1, V2, V3, V4签名
- 注意JAVA安装后的
准备好环境后,可以打开APP开始抓包了。
手玩
先随意玩一段时间 ,这是一个银行APP,首先可以注册/登录,登录后需要设置一个PIN码,然后进入账户页面,可以转账/收钱。

同时在Reqable中可以抓到对应的包:

可以看到请求的API域名,请求体和响应体。请求和响应都是标准JSON格式。
解包分析
把APK作为ZIP解压,找到里面的两个dex文件,用dex2jar转换为JAR文件,然后用JADX打开。
第一步是要找到非库函数的包名,通常会在com包下,最终找到了com.sekai.bank这个包。
这个包非常大,应该注意里面功能模块都分了子包,我们应该重点关注网络通信、数据结构相关模块。
- models: 定义了整个程序使用的数据模型,包括请求体和响应体。如果和抓包到的字段进行对比,可以发现抓包到的JSON字段都是这些类的private属性。
- network: 定义了网络请求的API

其中对于网络部分,重点关注两个接口:
- ApiService接口:包含了大量的路由信息:
@PUT("auth/pin/change")
Call<ApiResponse<Void>> changePin(@Body PinRequest pinRequest);
@GET("user/search/{username}")
Call<ApiResponse<User>> findUserByUsername(@Path("username") String str);
@GET("user/balance")
Call<ApiResponse<BalanceResponse>> getBalance();
@POST("flag")
Call<String> getFlag(@Body FlagRequest flagRequest);
@GET("user/profile")
Call<ApiResponse<User>> getProfile();
@GET("transactions/recent")
Call<ApiResponse<List<Transaction>>> getRecentTransactions();
有个flag相关的API非常显眼。我们可以尝试给远端发一个POST包看看(我在使用curl时遇到了证书问题,这个题用的很可能是自签名证书,需要-k忽略错误)
$ curl -X POST -k 'https://sekaibank-api.chals.sekai.team/api/flag'
{"success":false,"error":"X-Signature header is required"}
看来需要一个X-Signature签名头。
- ApiClient类:包含了网络请求的实现逻辑。
首先在常量部分,看到了BASE_URL:
private static final String BASE_URL = "https://sekaibank-api.chals.sekai.team/api/";
然后重点寻找发包的部分,直接搜索X-Signature字符串:
@Override // okhttp3.Interceptor
public okhttp3.Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
try {
return chain.proceed(request.newBuilder().header("X-Signature", generateSignature(request)).build());
} catch (Exception e) {
Log.e(ApiClient.TAG, "Failed to generate signature: " + e.getMessage());
return chain.proceed(request);
}
}
继续看generateSignature函数:
private String generateSignature(Request request) throws GeneralSecurityException, PackageManager.NameNotFoundException, IOException {
Signature[] apkContentsSigners;
String str = request.method() + "/api".concat(getEndpointPath(request)) + getRequestBodyAsString(request);
SekaiApplication sekaiApplication = SekaiApplication.getInstance();
PackageManager packageManager = sekaiApplication.getPackageManager();
String packageName = sekaiApplication.getPackageName();
try {
if (Build.VERSION.SDK_INT >= 28) {
PackageInfo packageInfo = packageManager.getPackageInfo(packageName, 134217728);
SigningInfo signingInfo = packageInfo.signingInfo;
apkContentsSigners = signingInfo != null ? signingInfo.hasMultipleSigners() ? signingInfo.getApkContentsSigners() : signingInfo.getSigningCertificateHistory() : packageInfo.signatures;
} else {
apkContentsSigners = packageManager.getPackageInfo(packageName, 64).signatures;
}
if (apkContentsSigners == null || apkContentsSigners.length <= 0) {
throw new GeneralSecurityException("No app signature found");
}
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
for (Signature signature : apkContentsSigners) {
messageDigest.update(signature.toByteArray());
}
return calculateHMAC(str, messageDigest.digest());
} catch (PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
throw new GeneralSecurityException("Unable to extract app signature", e);
}
}
可以看到最终是做了一个HMAC计算(里面是HMAC-SHA256,就不展开看了),两个参数分别是被签名信息和密钥。
被签名信息主要是这句:
String str = request.method() + "/api".concat(getEndpointPath(request)) + getRequestBodyAsString(request);
其实就是拼接了api的方法、路径和请求体(对于空请求体的情况,要替换成{})
密钥部分相对复杂,不过似乎是获取了APP本身的某种签名,并且获得了它的SHA-256摘要。
这里有一个坑,网上查的很 多信息都是用keytool提取JAR的签名,但是无效。我最终是用apksigner获得APK整体的签名(属于V2类型,就是在ZIP中间插了一段,binwalk可以看出来,就是DER二进制格式,可以openssl转换为PEM格式)
# Get Signature
apksigner verify --print-certs --verbose SekaiBank.apk
# DER to PEM
openssl x509 -inform DER -in ./sig.der >sig.pem
另外就是apksigner可以直接获得签名的常见哈希值,我们可以直接拿来用。
$ apksigner verify --print-certs ./SekaiBank.apk (ctf)
Signer #1 certificate DN: C=ID, ST=Bali, L=Indonesia, O=HYPERHUG, OU=Development, CN=Aimar S. Adhitya
Signer #1 certificate SHA-256 digest: 3f3cf8830acc96530d5564317fe480ab581dfc55ec8fe55e67dddbe1fdb605be
Signer #1 certificate SHA-1 digest: 2c9760ee9615adabdee0e228aed91e3d4ebdebdf
Signer #1 certificate MD5 digest: fcab4af1f7411b4ba70ec2fa915dee8e
签名算法实现:
import hmac
import hashlib
def get_sig(method, endpoint, body=None):
if body is None:
body = "{}"
msg = f"{method}/api/{endpoint}{body}".encode()
sig = hmac.new(
# hashlib.sha256(bytes.fromhex("6d09eec2")).digest(),
bytes.fromhex(
"3f3cf8830acc96530d5564317fe480ab581dfc55ec8fe55e67dddbe1fdb605be"
),
msg,
hashlib.sha256,
)
return sig.hexdigest()
通过一个APP原生的请求的签名验证了我们得到的签名算法是正确的,于是可以发包了(用Python requests写的):
200 SEKAI{*************************************}
是不是忘了什么?没错,Models部分提示了FlagRequest类包括一个unmask布尔参数,加上{"unmask":true}到请求体(并注意空格正确)
public class FlagRequest {
private boolean unmask_flag;
public FlagRequest(boolean z) {
this.unmask_flag = z;
}
public boolean getUnmaskFlag() {
return this.unmask_flag;
}
public void setUnmaskFlag(boolean z) {
this.unmask_flag = z;
}
}
200 SEKAI{are-you-ready-for-the-real-challenge?}
后面第二问涉及到APK修改后重打包,而且是5星题,打扰了
adb使用(未使用)
虽然没有用到,但是记录一下。
关于安卓相关的工具链可以参考神奇的Archlinux Wiki,ADB属于Android Platform Tools。
连接ADB弹出Shell:
adb connect <IP>:<port>
adb root
adb shell
My flask app
比赛没做出来的题,20多行的flask应用,送了一个LFI,要求获得文件名随机的flag内容(其实就是RCE)
唯一学到的知识点是:debug模式的flask可以访问/console直接打开控制台,不需要报错。之后获取PIN码的部分就是很简单的LFI转RCE了不再赘述。
