Hacking Huawei E3372 HiLink.

Posted on by Idorobots

Here's a quick post showing how I solved an issue with an unreliable LTE backup connection. Depending on your hardware, firmware & UI version YMMV.

The problem

It appears that Huawei E3372 frequently disconnects from the LTE network for no apparent reason. It wouldn't be much of a problem if it managed to quickly and reliably reconnect, but that isn't the case it would seem. Whenever that happens packets get lost and websites don't load properly, and overall internet experience is well below acceptable. After some googling it turned out that it might be caused by E3372 automatically disconnecting from the network, but fortunately enough, automatic disconnects when idling seem to be a configurable feature [sic] in the HiLink version of the modem. Less fortunately though, the default interval is set to 5 minutes, and obviously needs to be changed, potentially even disabled altogether. Let's see what the WebUI has to offer in this regard:

Y u no cooperate

Oh...

(ノ `⌒´)ノ︵ ┻━┻

Reverse-engineering the API

Let's figure this thing out, shall we? Here are my device's versions:

  • Hardware version: CL2E3372HM
  • Software version: 22.315.01.00.1080
  • Web UI version: 17.100.13.02.1080

So, when applying the settings with auto-disconnect interval set to 120 minutes we're making a request to /api/dialup/connection endpoint on the device (parts of the request were truncated for brevity):

curl 'http://192.168.8.1/api/dialup/connection' \
  -H 'Host: 192.168.8.1' \
  -H 'User-Agent: ...' \
  -H 'Accept: */*' \
  -H 'Accept-Language: en-US,en;q=0.5' \
  -H 'Referer: http://192.168.8.1/html/mobileconnection.html' \
  -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
  -H '__RequestVerificationToken: qJh5xzt1Bdf5TbE5TSDjXHfOtU5bVWND' \
  -H 'X-Requested-With: XMLHttpRequest' \
  -H 'Cookie: SessionID=...' \
  -H 'DNT: 1' \
  -H 'Connection: keep-alive' \
  -H 'Pragma: no-cache' \
  -H 'Cache-Control: no-cache' \
  --data ...

And the data:

<?xml version="1.0" encoding="UTF-8"?>
<request>
  <RoamAutoConnectEnable>0</RoamAutoConnectEnable>
  <MaxIdelTime>7200</MaxIdelTime>
  <ConnectMode>0</ConnectMode>
  <MTU>1500</MTU>
  <auto_dial_switch>1</auto_dial_switch>
  <pdp_always_on>0</pdp_always_on>
</request>

As we can see, the actual time, MaxIdelTime [sic], is represented as seconds instead of minutes, so 7200 seconds instead of 120 minutes. Other than that, there's not much more we need to spoof - just a SessionID and __RequestVerificationToken.

SessionID is pretty easy, if your device has user login disabled you've already got it as a Set-Cookie header when fetching any of the HTML pages, but that the crap is __RequestVerificationToken? Well, of course it's a way of authorizing the requests, if we omit it the request will be rejected with an error, but where do we get it from?

Looking through the dozens upon dozens of requests that the WebUI constantly makes, we can find two promising leads:

  • /api/user/hilink_login
  • /api/webserver/publickey

Unfortunately, both of these are a dead end, the first one does, well, nothing and the second one retrieves an RSA public key from the backend - perhaps it's being used for signing something so that backend knows that the request is valid?

Looking through the dozens upon dozens of global JavaScript variables that the WebUI constantly uses, we can find several gems:

function base64encode(str) {
    var out, i, len;
    var c1, c2, c3;
    len = str.length;
    i = 0;
    out = '';
    while (i < len) {
        c1 = str.charCodeAt(i++) & 0xff;
        if (i == len) {
            out += g_base64EncodeChars.charAt(c1 >> 2);
            out += g_base64EncodeChars.charAt((c1 & 0x3) << 4);
            out += '==';
            break;
        }
        c2 = str.charCodeAt(i++);
        if (i == len) {
            out += g_base64EncodeChars.charAt(c1 >> 2);
            out += g_base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
            out += g_base64EncodeChars.charAt((c2 & 0xF) << 2);
            out += '=';
            break;
        }
        c3 = str.charCodeAt(i++);
        out += g_base64EncodeChars.charAt(c1 >> 2);
        out += g_base64EncodeChars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4));
        out += g_base64EncodeChars.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6));
        out += g_base64EncodeChars.charAt(c3 & 0x3F);
    }
    return out;
}

function base64_encode (input) {
    _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    var output = "";
    var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
    var i = 0;
    input = _utf8_encode(input);
    while (i < input.length) {
        chr1 = input.charCodeAt(i++);
        chr2 = input.charCodeAt(i++);
        chr3 = input.charCodeAt(i++);
        enc1 = chr1 >> 2;
        enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
        enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
        enc4 = chr3 & 63;
        if (isNaN(chr2)) {
            enc3 = enc4 = 64;
        } else if (isNaN(chr3)) {
            enc4 = 64;
        }
        output = output +
        this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) +
        this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);
    }
    return output;
}

Not just one, but TWO implementations of base64 encoding. Redundancy is key. ( ͡° ͜ʖ ͡°)

function doRSAEncrypt(encstring) {
    if(encstring == '') {
        return '';
    }

    if(typeof(g_moduleswitch.encrypt_enabled) == 'undefined' || g_moduleswitch.encrypt_enabled != 1){
        return encstring;
    }

    if(g_encPublickey.e == '') {
        getEncpubkey();
    }
    var rsa = new RSAKey();
    rsa.setPublic(g_encPublickey.n, g_encPublickey.e);
    encstring = base64_encode(encstring);
    var num = encstring.length / 245;
    var restotal = '';
    for (i = 0; i < num; i++) {
        var encdata = encstring.substr(i * 245, 245);
        var res = rsa.encrypt(encdata);
        restotal += res;
    }
    return restotal;
}

It appears that /api/webserver/publickey is used for some sort of custom encryption. It's always nice to roll your own, custom crypto on the frontend while connecting via unencrypted HTTP. ( ͡° ͜ʖ ͡°) But fear not! Even though g_moduleswitch.encrypt_enabled is set to "1", this custom encryption appears to never be used anyway, since the only callee of doRSAEncrypt() checks if options.enc passed via arguments is set, which obviously isn't. Never. Ever. ( ͡° ͜ʖ ͡°)

if($.isArray(g_requestVerificationToken)) {
  if(g_requestVerificationToken.length > 0) {
    if(g_password_type == '4') {
      psd = base64encode(SHA256(name + base64encode(SHA256($('#password').val())) + g_requestVerificationToken[0]));
    } else {
      psd = base64encode($('#password').val());
    }

  } else {
    setTimeout( function () {
      if(g_requestVerificationToken.length > 0) {
        login(destnation, callback, redirectDes);
      }
    }, 50)
    return;
  }
} else {
  psd = base64encode($('#password').val());
}

var request = {
  Username: name,
  Password: psd,
  password_type: g_password_type
};

There are some shenanigans going on with passwords as well. Is that... Is that salting? Good thing it's on frontend, on an unencrypted connection with g_password_type set to 0, instead of 4. ( ͡° ͜ʖ ͡°)

OK, let's stop right here, cause I'm getting carried away. Looking though the sources we can find several more dead-end leads such as the /api/webserver/token endpoint which sounds promising but appears to require preexisting __RequestVerificationToken in addition to never being used in the first place...

We must be missing something, since the g_requestVerificationToken can't just appear from thin air, right?

function getAjaxToken() {
    var meta = $("meta[name=csrf_token]");
    var i = 0;

    if(meta.length > 0) {
        g_requestVerificationToken = [];
        for(i; i < meta.length; i++) {
            g_requestVerificationToken.push(meta[i].content);
        }
    } else {
        getAjaxData('api/webserver/token', function($xml) {
            var ret = xml2object($xml);
            if ('response' == ret.type) {
                g_requestVerificationToken = ret.response.token;
            }
        }, {
            sync: true
        });
    }
}

( ͡° ͜ʖ ͡°)

So... It was on the page all along... By retrieving any of the HTML pages we get both "security" values - one in the response headers and the other in the response body, and that's all that is needed to properly perform all the requests we care about. There's just one thing remaining though...

It's mighty inconvenient to have to parse the body looking for meta tags with certain name, and having to look for the Set-Cookie header in the response. Fortunately, the same conclusions came to the authors of the WebUI, so they kindly provided /api/webserver/SesTokInfo endpoint, which returns both of these values in a convenient fashion. ( ͡° ͜ʖ ͡°)

curl 'http://192.168.8.1/api/webserver/SesTokInfo'

Response (again, long values omitted for brevity):

<?xml version="1.0" encoding="UTF-8"?>
<response>
  <SesInfo>SessionID=...</SesInfo>
  <TokInfo>N6aUzSFsKRXnrTJgcL482NaKqsO+PRF7</TokInfo>
</response>

I. Kid. You. Not.

All that is left is to spoof the /api/dialup/connection request using these conveniently obtained values:

curl 'http://192.168.8.1/api/dialup/connection' \
  -H 'Cookie: SessionID=...' \
  -H '__RequestVerificationToken: N6aUzSFsKRXnrTJgcL482NaKqsO+PRF7' \
  --data '<?xml version="1.0" encoding="UTF-8"?><request><RoamAutoConnectEnable>0</RoamAutoConnectEnable><MaxIdelTime>86400</MaxIdelTime><ConnectMode>0</ConnectMode><MTU>1500</MTU><auto_dial_switch>1</auto_dial_switch><pdp_always_on>0</pdp_always_on></request>'

This will set the auto-disconnect interval to 24 hours. To verify that it actually worked we can check the /api/dialup/connection request that the WebUI itself performs:

hacked

Neat.

While we're at it...

The same hackaround can be used to programatically send SMS from the LAN using the E3372 API, here's a simple script that does just that:

#!/bin/bash

DATA=`curl http://192.168.8.1/api/webserver/SesTokInfo`
SESSION_ID=`echo "$DATA" | grep "SessionID=" | cut -b 10-147`
TOKEN=`echo "$DATA" | grep "TokInfo" | cut -b 10-41`

curl http://192.168.8.1/api/sms/send-sms -H "Cookie: $SESSION_ID" -H "__RequestVerificationToken: $TOKEN" --data "<?xml version='1.0' encoding='UTF-8'?><request><Index>-1</Index><Phones><Phone>$1</Phone></Phones><Sca></Sca><Content>$2</Content><Length>-1</Length><Reserved>1</Reserved><Date>-1</Date></request>"

Use it like this:

./send_sms.sh +1234567890 "Hello world!"