跳到主要内容

SekaiCTF 2025

· 阅读需 7 分钟
Lysithea
CTF enthusiastist. Usual teamname: Lysithea, Ribom, RibomBalt

TL;DR

pjsk主题高难度CTF,邀请了初见的朋友一起来玩,但显然这个比赛并不适合初学者。就做出来半道安卓逆向题,500+名开外了

☆-Mobile 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签名

准备好环境后,可以打开APP开始抓包了。

手玩

先随意玩一段时间,这是一个银行APP,首先可以注册/登录,登录后需要设置一个PIN码,然后进入账户页面,可以转账/收钱。

登录界面

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

Reqable抓包

可以看到请求的API域名,请求体和响应体。请求和响应都是标准JSON格式。

解包分析

把APK作为ZIP解压,找到里面的两个dex文件,用dex2jar转换为JAR文件,然后用JADX打开。

第一步是要找到非库函数的包名,通常会在com包下,最终找到了com.sekai.bank这个包。

这个包非常大,应该注意里面功能模块都分了子包,我们应该重点关注网络通信、数据结构相关模块。

  • models: 定义了整个程序使用的数据模型,包括请求体和响应体。如果和抓包到的字段进行对比,可以发现抓包到的JSON字段都是这些类的private属性。
  • network: 定义了网络请求的API

jadx

其中对于网络部分,重点关注两个接口:

  • 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

☆-Web My flask app

比赛没做出来的题,20多行的flask应用,送了一个LFI,要求获得文件名随机的flag内容(其实就是RCE)

唯一学到的知识点是:debug模式的flask可以访问/console直接打开控制台,不需要报错。之后获取PIN码的部分就是很简单的LFI转RCE了不再赘述。