Obsess over every detail. Ask why it works. Ask why it isn’t built another way.

Prelude

So, why did I suddenly post something about an Android app? I’m preparing for something and I won’t disclose it for now. Don’t mind me, and also beware that you’re learning alongside me, I’m nowhere near an expert in this subject matter.

Today we’re going to reverse engineer a Weather app. Credits to HexTree for the resource.

Objectives

Finding the Custom API

Finding the API isn’t that hard since we can just search for the http/https keyword, because interacting with an API almost always requires a URL. Note that I leveraged Claude to rename the obfuscated function names throughout this writeup.

1
2
3
4
5
6
7
8
9
public abstract class WeatherFetchThread extends Thread {

    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm");

    private Location location;
    private String zipCode;
    public Context context;
    // ...
}

Focusing on the run() method, we can confirm it’s performing an HTTP request that accepts three parameters, a URL, a User-Agent, and an API key.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Override
public void run() {
    String url = "https://ht-api-mocks-lcfc4kr5oa-uc.a.run.app/xml/SOAP_server/ndfdXMLclient.php";
    if (this.location == null) {
        if (this.zipCode != null) {
            // build zip-based query
        }
        onFetchComplete(HttpClient.fetchWithApiKey(url, "HextreeForecastUSA/v4.x", this.context.getString(R.string.ApiKey)));
    }
    // build location-based query
    onFetchComplete(HttpClient.fetchWithApiKey(url, "HextreeForecastUSA/v4.x", this.context.getString(R.string.ApiKey)));
}

Reading the source of HttpClient.fetchWithApiKey() confirms this is a standard GET request. The second parameter maps to the User-Agent header, while the third maps to X-API-KEY.

1
2
3
4
5
6
public static String fetchWithApiKey(String url, String userAgent, String apiKey) throws Throwable {
    // ...
    httpURLConnection2.setRequestProperty("User-Agent", userAgent);
    httpURLConnection2.setRequestProperty("X-API-KEY", apiKey);
    // ...
}

Searching for ApiKey leads us to strings.xml, the Android resource file where string constants are stored.

1
<string name="ApiKey">HXT{android-api-key-b1872g}</string>

With all three pieces gathered, we can now replicate the request outside the app.

1
2
3
4
5
6
set BEGIN (python3 -c "from datetime import datetime; import urllib.parse; print(urllib.parse.quote(datetime.now().strftime('%Y-%m-%dT%H:%M')))")

curl -X GET \
  "https://ht-api-mocks-lcfc4kr5oa-uc.a.run.app/xml/SOAP_server/ndfdXMLclient.php?whichClient=NDFDgen&zipCodeList=13337&product=time-series&begin=$BEGIN&maxt=maxt&mint=mint&dew=dew&appt=appt&wx=wx&icons=icons&wwa=wwa&Submit=Submit" \
  -H "User-Agent: HextreeForecastUSA/v4.x" \
  -H "X-API-KEY: HXT{android-api-key-b1872g}"

Why Are Weather Updates Disabled?

Weather Updates Disabled

Tracing the “Weather Updates Disabled” string leads us to refreshForecast().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private void refreshForecast() {
    setForecastButtonEnabled(false);
    boolean hasLocation = m.c(this);
    String zipCode = m.b(this);
    if (!zipCode.equals("13337") && !zipCode.equals("42")) {
        Toast.makeText(this, "Weather Updates Disabled", 0).show();
        return;
    }
    if (hasLocation) {
        this.forecastManager.i(this.locationManager.b());
    } else if (zipCode.length() == 5) {
        this.forecastManager.fetchForecastByZip(zipCode);
    }
}

The logic is straightforward. The app will show “Weather Updates Disabled” unless the zip code is exactly 13337 or 42. However, there’s a catch even if you enter 42 to bypass the first check, it will never actually fetch data because of a secondary validation inside fetchForecastByZip().

1
2
3
4
5
6
public void fetchForecastByZip(String zipCode) {
    if (TextUtils.isEmpty(zipCode) || zipCode.length() != 5) {
        return;
    }
    new ZipFetchThread(zipCode, this.f402a).start();
}

42 is only 2 characters so it gets rejected here. The only zip code that satisfies both conditions is 13337, which passes the first check and is exactly 5 digits.

Bypassing the Client-Side Restriction

This length check only exists in the Android app, not on the server. This is a classic broken access control pattern, client-side validation that can be trivially bypassed by going directly to the API. So we can just send 42 via curl and the server will happily accept it.

1
2
3
4
curl -X GET \
  "https://ht-api-mocks-lcfc4kr5oa-uc.a.run.app/xml/SOAP_server/ndfdXMLclient.php?whichClient=NDFDgen&zipCodeList=42&product=time-series&begin=$BEGIN&maxt=maxt&mint=mint&dew=dew&appt=appt&wx=wx&icons=icons&wwa=wwa&Submit=Submit" \
  -H "User-Agent: HextreeForecastUSA/v4.x" \
  -H "X-API-KEY: HXT{android-api-key-b1872g}" | grep "HXT"
1
<value coverage="chance" intensity="light" weather-type="HXT{android-api-h192gsa0}" qualifier="none">

Flag retrieved. The server never validated the zip code length, trusting that the client would enforce it, which it clearly doesn’t when you go around it.