Making Wireless@SGx Work on Linux

zerotypic
11 min readAug 2, 2019

Wireless@SG is Singapore’s publicly-available WiFi network. There are hotspots everywhere, and you can use it as long as you have a SIM card and can receive SMSes. To login to the captive portal, you just need to provide a phone number and enter the OTP sent to that number.

Using Wireless@SG on my laptop when I’m outside is great, but needing to login using an OTP every single time is really annoying. The connection is also unencrypted so my traffic is potentially sniffable. There exists an encrypted version of the network, Wireless@SGx, which uses Enterprise WPA for authentication, and only requires you to register once. Wireless@SG refers to this as Seamless and Secure Access (SSA).

Unfortunately, to use Wireless@SGx SSA on a laptop you need the Wireless@SG app, which is only available for Mac and Windows; no love for Linux.

Searching online, I found somebody else who tried to solve the same problem a while back. Unfortunately it seems like his instructions only work if you already have a username/password for Wireless@SGx, probably a holdover from the earlier system where you had to manually register an account with your ISP as opposed to use the app. If you don’t have an account, all official sources seem to indicate the only thing you can do is install the app which will do some magic for you.

After doing some research, I figured out that the app’s main purpose was to register a new account with the server. After registering, you get a username and password, equivalent to the old manual ones mentioned above, and those credentials can then be used to login to the Wireless@SGx network using standard 802.1X.

So, how does the app perform the registration process? I didn’t have a Windows or Mac machine handy to actually test it out, so I decided to statically reverse engineer the Windows app to see what it was doing. (I also installed the Android version of the app just to get an idea of what the registration process was.)

The Wireless@SG Windows app can be downloaded from the Infocomm Media Development Authority (IMDA) here. Inside the downloaded zip file is an MSI package, which when extracted contains (among other things) the main executable, Wireless@SG.exe, and a bunch of DLLs. If you’re following along, the SHA1 of the zip file I downloaded is a5b88383cce5525e4487dd3a466b536de712c447.

Loading Wireless@SG.exe in IDA Pro revealed it to be a .NET assembly. I’d never REed .NET code before, so I wasn’t sure how hard it was going to be. The program was probably going to be quite straightforward though, so I just started poking around to see what I could find out.

Fortunately, there was no obfuscation applied to the binary (I assume .NET can be obfuscated in the same way Java can?), so the symbols were all visible. Looking through the symbols and strings, I found that most of the core logic was in the DLL WSG.Common.dll. In particular, the function WSG.Common.AMS.CommonAms::RegisterUser appears to make the initial registration request, and the function WSG.Common.AMS.CommonAms::ValidateOTP appears to perform a validation of an OTP, which I assume would have been sent to the requestor’s mobile phone, just like in the captive portal login.

The following is a description of the registration protocol, as reversed engineered from the Windows Wireless@SG app.

Wireless@SGx SSA Registration Protocol

To get credentials on the Wireless@SGx nework, you need to create an account. More specifically, you need to create an account with one of the Wireless@SG operators, who are essentially ISPs. Note that you don’t actually need an existing mobile or internet account with those ISPs; as far as I can tell they just serve as the authenticators of the network. You can just choose one of the available operators and begin registration by connecting to their server. The server provides a web API; clients make HTTP GET requests and the server returns JSON.

The Wireless@SGx registration protocol consists of 2 phases. The first phase is the registration phase, which involves sending a request to the server to create an account. This request must contain, among other things, a valid mobile phone number. Once the request has been made, an SMS is sent to the mobile phone containing an OTP. The server also returns a success code to the requestor.

The second phase of registration is the validation phase. In this phase, a request must be sent to the server containing (among other things) both the OTP and the success code. If the OTP and success code are correct, the server responds with a new user ID, the encrypted form of that same user ID, and an encrypted password. The password can then be decrypted using a key, which can be generated using the OTP.

Registration

The registration phase consists of sending a HTTP GET request to the server with a bunch of parameters. The logic for this phase was found in WSG.Common.AMS.CommonAms::RegisterUser, in WSG.Common.dll.

Here’s an example of what the HTTP GET request for this phase looks like:

https://[operator url]/essa?api=create_user_r1a&salutation=XXX&name=XXX&uid=XXX&mobile=XXX&nationality=XXX&dob=XXX&email=XXX&tid=XXX

The API being accessed is create_user_r1a, and the other parameters are information needed for registration. 3 of the fields are important:

  • uid is the user ID, and in the Windows app appears to be the user’s NRIC (based on the variable name used). I’m not certain if it is ever validated, so it might work if you use some other random value (if not, non-citizens wouldn’t be able to create an account).
    [30 Aug 2019: I’ve since found that the server does validate the UID; invalid NRIC numbers don’t work. The server response seems to indicate that passport numbers should work, but I have no idea how they would validate those since no standard format exists. Perhaps there’s some other API that needs to be used. In the meantime though, I’m afraid this only works for Singapore citizens, and you’ll need to use your own NRIC number.]
  • mobile is a mobile phone number. This is the phone number that the OTP will be SMSed to, so it must be valid.
  • tid is the transaction ID, which I discuss more below.

Transaction ID

The transaction ID is most likely a unique session ID. In the Windows app, it appears to be based on the WiFi interface’s MAC address (see function WSG.Common.Portable.DeviceManager::GetTransId, which calls WSG.Common.Portable.IDeviceManager::GetDeviceId. An implementation of this interface in Wireless@SG.exe ultimately calls SimpleWifi.Win32.WlanInterface::get_InterfaceGuid.) The GetTransId function also appears to fall back to a default value for generating the transaction ID: 0537866545. In my tests, using the default value is sufficient for successfully obtaining Wireless@SGx credentials.

Whatever is used to generate the transaction ID is right-padded with zeroes until its length is 0x18 (24) bytes (this happens in the GetTransId function). So for the default value, the transaction ID ends up becoming 053786654500000000000000.

Note that while the transaction ID is mainly used as a session ID, it is also used to generate the decryption key. Its value is treated as a Base16-encoded (aka hex) string. Thus the transaction ID must be limited to Base16 characters (0–9, A-F).

Note: For the remainder of this article we will refer to Base16 encoding as hex encoding.

Server Response

If the request is successful, the server responds with a JSON object, like the following:

{
"api": "create_user_r1a",
"version": "2.1",
"status": {
"result": "ok",
"resultcode": 1100
},
"body": {
"success_code": "XXX"
}
}

body.success_code is the success code we need for the next phase. Successful registration will also cause an SMS to be sent to the mobile number provided, containing the OTP.

Validation

The validation phase also consists of a HTTP GET request to the server. The logic for this phase was found in WSG.Common.AMS.CommonAms::ValidateOTP, in WSG.Common.dll. The request looks like this:

https://[operator url]/essa?api=create_user_r1b&uid=XXX&mobile=XXX&otp=XXX&success_code=XXX&tid=XXX

The API being accessed for this phase is create_user_r1b, and the fields uid, mobile and tid are the same as in the registration phase. The otp field contains the OTP sent to the mobile phone, and the success_code field contains the success code returned by the server during the registration phase.

Server Response

If the validation succeeds, the server responds with a JSON object like the following:

{
"api": "create_user_r1b",
"version": "2.3",
"status": {
"result": "ok",
"resultcode": 1100
},
"body": {
"userid": "XXX",
"enc_userid": "XXX",
"enc_password": "XXX"
}
}

This response contains the credentials we need to access our newly created account. body.userid is the 802.1X username, body.enc_userid is the encrypted version of the same username, and body.enc_password is the encrypted 802.1X password. Both encrypted values are encoded as hex strings. All that is left to do is to decrypt the password.

Decrypting the Password

The server response during the validate phase contains an encrypted username and password. Both are encrypted using the same encryption/decryption key (symmetric encryption is being used), and successful decryption can be validated by comparing the supplied plaintext username (body.userid) with the result of decrypting the encrypted username (body.enc_userid).

The function WSG.Common.AMS.AmsHelper::RetrieveDecryptionKey retrieves the decryption key, as is obvious from its name. You can see it being called in the function whose name in IDA Pro is <>c__DisplayClass4_0::<ValidateOTPAndGetWifiConfig>b__0 (which I think is actually a lambda expression; not familiar enough with C# and .NET disassembly to be sure):

// Note that I've edit this and removed the class names to make 
// this easier to read
.
ldloc.0 // WifiConfigModel returned by
// ValidateOTP.
ldarg.0
ldfld registration
ldfld transid // Transaction ID
ldarg.0
ldfld otp // OTP
call WSG.Common.AMS.AmsHelper::RetrieveDecryptionKey(...)

RetrieveDecryptionKey takes as parameters the validation phase response, the transaction ID, and the OTP. It uses this information to build the decryption key, and also verify that it is correct.

RetrieveDecryptionKey

A snippet of the start of the RetrieveDecryptionKey function is shown below. You can read the comments, or read my description of what it does after.

ldarg.2                                        // otp as string
call System.Int32::Parse(string)
stloc.0 // otp as integer
ldarg.1 // transid
ldloca.s 0 // otp as integer
ldstr aX5 // "X5"
// Formats OTP, similar to sprintf("%05x", otp).
call System.Int32::ToString(string)
// Concatenate transid with formatted otp string.
call System.String::Concat(string, string)
stloc.1
call System.DateTime::get_Now() // Current date/time
stloc.2
ldloca.s 2
ldstr aDdmm // "ddMM"
// Get a string of the date in DDMM format, then convert to int.
call System.DateTime::ToString(string)
call System.Int32::Parse(string)
stloc.3
ldloca.s 3
ldstr aX3 // "X3"
// Similar to sprintf("%03x", date).
call System.Int32::ToString(string)
ldloc.1
// Concatenate previous transid+otp string with the formatted
// date string. This yields the candidate decryption key as a
// hex string.
call System.String::Concat(string, string)
stloc.s 4
ldloc.s 4
ldarg.0 // Validation phase response
ldfld enc_userid // Retrieve encrypted username
// Attempt to decrypt the encrypted username using our candidate
// key. Note that the two parameters to the function are expected
// to be hex strings.
call WSG.Common.Helpers.SecurityHelper::Decrypt(string key, string cipherText)
ldarg.0
ldfld userid // Plaintext username from response
// Compare decrypted username with plaintext username.
call System.String::op_Inequality(string, string)
brfalse loc_7469
// ... try again with different dates if failed.

The function builds the decryption key from the following components: the transaction ID, the OTP, and the current date. The pseudocode below shows how the key is built:

date = current date in DDMM format, treated as an integer
date_hex = sprintf("%03x", date)
otp_hex = sprintf("%05x", otp)
key_hex = concat(date_hex, transid, otp_hex)
key = hexdecode(key_hex)

The current date, in DDMM format, is formatted into a hex string. This is then concatenated with the transaction ID, and finally with the hex string representation of the OTP. The resultant string is a hex string, which when decoded yields the key.

RetrieveDecryptionKey checks if the decryption key is correct by retrieving the encrypted username and decrypting it using the key, and then comparing the decrypted username with the known plaintext username. If the names match, then the key is correct.

If the key is incorrect, RetrieveDecryptionKey tries to build a decryption key again, still using the same OTP and transaction ID, but this time with a date one day in the past and one day in the future. This is probably to account for cases where the validation phase and the decryption phase happen close to a date boundary, or if there’s some kind of timezone difference between server and client. Once a working decryption key is found, the function returns it. If not, it returns an empty string.

Note that the above also means the Wireless@SG app will not work if your system date is set to something too far off from the server’s date.

Decryption

Actual decryption happens in the function WSG.Common.Helpers.SecurityHelper::Decrypt_0, and is pretty straightforward. It takes in the key and ciphertext as hex strings, decodes the hex strings, and then uses AES in ECB mode to decrypt the decoded ciphertext. The IV is 16 null bytes, and padding is PKCS#7.

Once the password is decrypted using this function, we finally have all the information we need to log into Wireless@SGx.

Logging into Wireless@SGx

Once you have the username and password, instructions for logging into Wireless@SGx are the same as those you might find at other sites. For example you could use the Linux one I mentioned above, or this guide from M1 for OS X. Basically, you connect to the AP using WPA2 Enterprise, Protected EAP (PEAP), MSCHAPv2.

I’ve managed to use the steps described here to register a new Wireless@SG account, retrieve the credentials, and successfully login to Wireless@SGx; in fact that’s what I’m using now to type this article.

Python Implementation

I’ve coded up an implementation of a Wireless@SGx registration client in Python, which I’ve used successfully to get my credentials. It works in Linux, and is probably simple enough to be cross-platform. You can find it here.

The script is pretty easy to use. By default you can use it in an interactive mode, where it will prompt for the OTP that was sent to the mobile phone in between phases.

# Basic usage: ./wasg-register.py <mobile number> <nric>
$ ./wasg-register.py 659XXXXXXX SXXXXXXXX
OTP will be sent to mobile phone number 659XXXXXXX
Enter OTP to continue: XXXXXX
Credentials:
userid = 'XXX'
password = 'XXX'

Alternatively, you can run each phase separately, which might be handy if you want to automate things somewhow:

# Registration phase only with flag "-1".
$ ./wasg-register.py 659XXXXXXX SXXXXXXXX -1
Success code: 12345678
# Provide OTP using "-O" and success code above using "-S"
$ ./wasg-register.py 659XXXXXXX SXXXXXXXX -O XXXXXX -S 12345678
Credentials:
userid = 'XXX'
password = 'XXX'

You can choose which ISP to request the account from:

$ ./wasg-register.py 659XXXXXXX SXXXXXXXX -I myrepublic

Currently the script supports Singtel (the default), Starhub and MyRepublic. Configuration for these ISPs were found inside the Wireless@SG app. Personally I have only tested Singtel and MyRepublic, as I didn’t want to send out too many requests tied to my mobile number. Do let me know if the Starhub configuration isn’t working.

The full list of options are shown below:

$ ./wasg-register.py --help
usage: wasg-register.py [-h] [-I {test,starhub,myrepublic,singtel}]
[-s SALUTATION] [-n NAME] [-c COUNTRY] [-d DOB]
[-e EMAIL] [-t TRANSID] [-1] [-O OTP]
[-S SUCCESS_CODE] [-D DECRYPTION_DATE] [-v]
mobile nric
Wireless@SG registration utility.positional arguments:
mobile Mobile phone number
nric NRIC or equivalent ID number
optional arguments:
-h, --help show this help message and exit
-I {test,starhub,myrepublic,singtel}, --isp {test,starhub,myrepublic,singtel}
ISP to register with
-s SALUTATION, --salutation SALUTATION
Salutation
-n NAME, --name NAME Full name
-c COUNTRY, --country COUNTRY
Nationality country code
-d DOB, --dob DOB Date of Birth
-e EMAIL, --email EMAIL
Email address
-t TRANSID, --transid TRANSID
Transaction ID
-1, --registration-phase-only
Terminate after registration phase, returns
success code.
-O OTP, --otp OTP OTP received on mobile. Note that if this is
set, then wasg-register will skip the
registration phase and move immediately to
OTP validation. success-code must also be
provided.
-S SUCCESS_CODE, --success-code SUCCESS_CODE
Success code received during registration
phase. Note that if this is set, then
wasg-register will skip the registration
phase and move immediately to OTP
validation. OTP must also be provided.
-D DECRYPTION_DATE, --decryption-date DECRYPTION_DATE
Date the OTP was generated, for use in
decryption, in YYMMDD format.
-v, --verbose Be verbose.

Caveats

Some research I’ve done seems to suggest that the registered account is only valid for a period of time, maybe 6 months or so. That means that once your account expires, you would need to re-register. I’m not sure if the re-registration process is identical to the initial registration process, i.e. you just need to register a second time. It might be that there’s a separate set of APIs you need to use; I haven’t yet bothered to RE enough to determine this. In any case, thus far the credentials are working fine for me. If and when I lose access due to expiry, I’ll start looking into this again.

Final Word of Warning

Even if you’re logged in to Wireless@SGx, you should still go through a VPN for additional security. Don’t assume that using WPA means you are safe; there are other risks in using a public network that can only be mitigated with a trusted VPN connection.

--

--