Reverse Engineering Sodexo’s API
Abstract
This is documenting my journey to reverse engineering Sodexo’s meal pass card API.
The idea is to understand and describe their API to later develop clients for it. One way to achieve it is by decompiling the Sodexo Android app APK and look into it. The APK in its version 2.4.4 (released the October 3rd 2019) was used.
Motivations
- it’s fun & challenging
- for the sake of open source
- I’d like to be able to know my Sodexo card balance from command line before leaving for lunch
$ mysodexo
€123.45
Download APK from device
To have access to the APK on our host, we can download it from the device with adb
. We first need to know where the APK is stored on device.
adb shell pm list packages -f | grep sodexo
Output:
package:/data/app/com.sodexo.app-1/base.apk=com.sodexo.app
For some reason we cannot download directly from the /data/
dir to our host. So we need to copy it over the device /sdcard/
beforehand.
adb shell cp /data/app/com.sodexo.app-1/base.apk /sdcard/
adb pull /sdcard/base.apk .
Decompile
There’re various tools even online ones. I first gave Apktool a try, but the result code was hard to follow so I switched to jadx (version 1.0.0) and was happy with it.
jadx --output-dir base base.apk
One of the first things we do then is to send a couple of grep
commands to find URLs in the source code. We quickly come across the resources/res/values/strings.xml
file that contains the following extract:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="HOST">https://sodexows.mo2o.com/</string>
...
<string name="clientCertP12">sodexows.mo2o.com_client-android.p12</string>
<string name="clientCertP12Password">android</string>
<string name="clientCertPEM">master-cacert.pem</string>
...
<resources>
First try with the base URL
Since we already have what looks like our base API URL, we want to curl
it.
curl https://sodexows.mo2o.com/
Response:
curl: (56) OpenSSL SSL_read: error:14094410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure, errno 0
Looking deeper with --verbose
flag we see the server side certificate is OK as signed by GoDaddy.com, but the server is most likely checking client side certificate. The good thing is we have access to the client certificate from the APK.
Certs
Remember we have the assets/sodexows.mo2o.com_client-android.p12
file which seems like a client certificate in PKCS 12 format.
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
https://sodexows.mo2o.com/
Response:
{
"code": 499,
"msg": "No route found for \"GET /\"",
"response": {}
}
It works! See how we could pass it the password android
as defined in resources/res/values/strings.xml
.
Until recently curl
didn’t support P12
. Thankfully it’s possible to convert it to PEM via openssl
.
openssl pkcs12 -in assets/sodexows.mo2o.com_client-android.p12 \
-out sodexows.mo2o.com_client-android.key.pem -nocerts -nodes
openssl pkcs12 -in assets/sodexows.mo2o.com_client-android.p12 \
-out sodexows.mo2o.com_client-android.crt.pem -clcerts -nokeys
And use it with:
curl --cert ./sodexows.mo2o.com_client-android.crt.pem \
--key ./sodexows.mo2o.com_client-android.key.pem \
https://sodexows.mo2o.com/
Endpoints
Now that we can access our host, we need to find the endpoints and play with it.
Unfortunately we cannot sniff network traffic with a Man-in-the-middle (e.g. via mitmproxy
) because the app is expecting a specific server certificate. Hence we can’t use a self-signed one and decrypt on the fly, see HTTP Public Key Pinning.
We go back to the source code and throw some more grep
to come across sources/com/sodexo/app/data/api/service/Mo2oApiService.java
. It contains 40+ endpoints, here’s a couple:
public interface Mo2oApiService {@POST("v3/connect/login")
Call<LoginResponse> login(@Body LoginRequest loginRequest);@POST("v3/card/getCards")
Call<GetCardsResponse> getCards(@Body GetCardsRequest getCardsRequest);
}
v3/connect/login
First dumb try:
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
https://sodexows.mo2o.com/v3/connect/login
Response:
{
"code": 499,
"msg": "No route found for \"GET /v3/connect/login\"",
"response": {}
}
No luck, we need to look into the code again for some kind of prefix. Luckily we find sources/com/sodexo/app/data/api/service/C2707a.java
.
public class C2707a { @NonNull
/* renamed from: a */
private static Retrofit m8480a(OkHttpClient okHttpClient) {
Retrofit.Builder builder = new Retrofit.Builder();
StringBuilder sb = new StringBuilder();
sb.append(f5094c);
sb.append(Locale.getDefault().getLanguage());
sb.append("/");
return builder.baseUrl(
sb.toString()).client(
okHttpClient).addConverterFactory(
GsonConverterFactory.create(
new C2549g().mo18711a(
16, 128, 8).mo18710a())
).build();
}
}
The important one is sb.append(Locale.getDefault().getLanguage());
, see Locale#getLanguage() documentation. So the language (ISO 639
format) is added to the base URL, e.g. https://sodexows.mo2o.com/en
.
We can also see they’re using the OkHttp client.
Back to our endpoint call.
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
https://sodexows.mo2o.com/en/v3/connect/login
Response:
{
"code": 499,
"msg": "No route found for \"GET /en/v3/connect/login\": Method Not Allowed (Allow: POST, OPTIONS)",
"response": {}
}
The error message changed slightly to Method Not Allowed (Allow: POST, OPTIONS)
. We try again with a POST
this time. Passing --data
to curl
would implicitly turn it to a POST
.
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
--data '' \
https://sodexows.mo2o.com/en/v3/connect/login
Response:
{
"code": 499,
"msg": "Only JSON requests are allowed for this service set.",
"response": {}
}
Another new error message, let’s JSON then.
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data '{}' \
https://sodexows.mo2o.com/en/v3/connect/login
Response:
{
"code": 999,
"msg": "Se ha producido un error al guardar los datos, por favor int\u00e9ntalo de nuevo pasados unos minutos.",
"response": {
"errors": {
"deviceUid": "This value should not be blank.",
"os": "This value should not be blank.",
"pass": "This value should not be blank.",
"username": "This value should not be blank."
}
}
}
Bingo! Well I think that’s it. This same info about fields can be found in sources/com/sodexo/app/data/api/request/LoginRequest.java
.
public class LoginRequest {
private String deviceUid;/* renamed from: os */
private int f5086os;
private String pass;
private String username;public LoginRequest(String str, String str2, String str3, int i) {
this.username = str;
this.pass = str2;
this.deviceUid = str3;
this.f5086os = i;
}
}
Pretty explicit, but looking deeper in the source we can find out more about each parameters. For instance for deviceUid
, see sources/com/sodexo/app/p083a/p084a/C2687a.java
file extract:
Secure.getString(this.f5065a.getContentResolver(), "android_id");
It seems to be using Secure.ANDROID_ID
. Now we can try to perform another call putting it all together.
deviceUid=whatever
os=0
username=foo@bar.com
pass=password
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data '{"deviceUid": "'$deviceUid'", "os": '$os', "username": "'$username'", "pass": "'$pass'"}' \
https://sodexows.mo2o.com/en/v3/connect/login
Response:
{
"code": 100,
"msg": "OK",
"response": {
"acepto_terminos": 1,
"activated": 1,
"beneficiaryCode": 12345,
"cardCode4": "1234",
"changePassword": 0,
"cityJob": 0,
"companyId": 12345,
"complementaryDataJob": "",
"dateBorn": "1987-06-05",
"dateUp": "2017-05-22",
"dni": "*HIDDEN*",
"email": "*HIDDEN*",
"gender": 0,
"interestCollection": [
{
"idInteres": 0
}
],
"internalCode": 2,
"matricula": "",
"mobile": "*HIDDEN*",
"name": "Andre",
"nameAddressJob": "",
"newsletter": 0,
"password": "",
"postalCodeJob": "*HIDDEN*",
"securityDate": "*HIDDEN*",
"stateJob": 0,
"surname1": "Miras",
"surname2": "Miras",
"typeAddressJob": 0,
"userData": {
"city": 0,
"complementaryData": "",
"departmentId": 0,
"functionId": 0,
"hasChildren": 0,
"nameAddress": "",
"netIncomeId": 0,
"postalCode": "",
"state": 0,
"typeAddress": 0,
"typeWorkDay": 0,
"userId": 12345
}
}
}
And voilà! Also note that a cookie is set (seen with --verbose
flag):
Set-Cookie: PHPSESSID=deadbeef2827f09616128fe1d11fa5b7; path=/; secure
v3/card/getCards
In a similar fashion we can reverse the other endpoints.
PHPSESSID=deadbeef2827f09616128fe1d11fa5b7
dni=*HIDDEN*
curl --cert-type P12 \
--cert ./sodexows.mo2o.com_client-android.p12:android \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--cookie "PHPSESSID=$PHPSESSID" \
--data '{"dni": "'$dni'"}' \
https://sodexows.mo2o.com/en/v3/card/getCards
Response:
{
"code": 100,
"msg": "OK",
"response": {
"listCard": [
{
"arrFisToChange": [
{
"key": "BLOCKED",
"value": "60"
}
],
"caducityDateCard": "",
"cardNumber": "*HIDDEN*",
"cardStatus": "ACTIVA",
"fisToChangeState": "BLOCKED",
"hasChip": 1,
"idCard": 123456,
"idCardStatus": "30",
"idCompany": 12345,
"idFisToChange": "60",
"idProduct": 33,
"pan": "*HIDDEN*",
"programFis": "",
"service": "Restaurante Pass"
}
]
}
}
Conclusion
So we seem to have covered point 1 & 2 of our motivations. How about point 3 then? I’ll let you find out:
pip install mysodexo
mysodexo --balance
Or if you prefer JavaScript over Python:
npm install mysodexo
mysodexo --balance