Skip to content

Instantly share code, notes, and snippets.

@tiberiucorbu
Last active December 28, 2023 10:46
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tiberiucorbu/a51c81b82b5196ac002c52ac6f39987f to your computer and use it in GitHub Desktop.
Save tiberiucorbu/a51c81b82b5196ac002c52ac6f39987f to your computer and use it in GitHub Desktop.
Retrive active devices on the network form the vodafone router : CGA4233DE

Retrive active devices on the network form the vodafone kabelbox (CGA4233DE)

In order to automate the network activity like getting the list of connected devices, notify the presensce of a device, log and debug wifi issues, it would be nice to have the list provided by the router of the current status.

In this aricle I am going to explore this posibility, reverse engenier the web app from the router and try to authentificate and retrive the active data table from the router.

The first step is to analyze the web console into the network tab and login to see which requests are beeing made.

Security Chanlenges

From the first glance the router hosts a PHP application and serves a couple of assest via a lighttpd server, if we look at the about page (http://routerip/#/status/about) the version of these software is revealed, this is a major security flaw as one can track the security issues and exploit the machine using the issues found for a specific version narowing down the guessing. Anyway, the WebUI is based on JQuery and JQuery UI. What I note is that the javascripts are obfuscaded and ran using eval(). Unfortunately none of these measures stops an experienced programmer to debug and trace the login flow :)

Login Flow

By looking at the network tab for the login flow I have reversed engenierd the following diagram:

@startuml component

actor user
node Router Server
node WebUI

Router Sever -> WebUI -> create php session 
user -> WebUI: enters username and password
WebUI -> Router Server : Get encryption stalts from the login endpoint
WebUI -> WebUI : Compute pbkdf2 of the password
WebUI -> Router Server : Post username and encrypted password to the login endpoint
@endum

When the webUI is opened the php app creates a session and tracks it with a cookie named by default : PHPSESSID This session holds the authentification, once the login is succesfull all the preceding calls don't require an authentification token.

In the headers I've also noted that the app uses a crsf protection, however for the login flow the crsf is not required but the header has to be set.

So lets break it down in to smaller steps, for the sake of simplicity and because it has the batteries included, I've used python 3.8 to code the calls :

Creating a session

In python that is simple, just require the Session from the request package and initialize it

from requests import Session
session = Session()

Now every cookie that is going to be set by the server will be held in our session.

For the whole flow there are needed the following variables :

username = 'admin' # this seems to be constant as there is no way to change the username in the ui
password = 'the admin password' # used to generate the authentification token
router_address = 'http://kabelbox.local' # change with the gateway ip if the local dns is not working

Also for each request a couple of header variables are validated, I'm not completly sure if all of them has to be send but I had success with the following dictionary :

headers = {'User-Agent': 'Mozilla/5.0', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
           'X-CSRF-TOKEN': '', 'X-Requested-With': 'XMLHttpRequest', 'Referer': router_address}

If your router has a diffrent Ip, change tit.

Getting the encryption salts

the encription salts are provided by the same endpoint as the login but in the payload sets the password as seeksalthash

salt_response = session.post(router_address+'/api/v1/session/login', headers=headers, data={'username': username, 'password': 'seeksalthash'}).json()

if there is no other active session, the endpoint would return the following payload:

{
  "error": "ok",
  "salt": "some random string",
  "saltwebui": "some other random string"
}

The salts are a bit wierd from the security point of view, the salt field seems to be constant between requests and only the saltwebui is newly generated on each request.

Boths salts are going to be used to hash/encrypt the password, and funny the security guy/girl that implemented all of this thougt that It would confuse someone to encrypt the password two times.

The hashing algoritm used on the client side is pbkdf2 as I've traced it with the debuger. The ui uses a javascript library called sjcl https://bitwiseshiftleft.github.io/sjcl/doc/sjcl.misc.html, it encrypts the password and also cuts the first 32 chars of the resulted hash.

In python there is the same algorithm implementesd in the hashlib package

import hashlib

a = hashlib.pbkdf2_hmac('sha256', bytes(password, 'utf-8'), bytes(salt_response['salt'], 'utf-8'), 1000).hex()[:32]
b = hashlib.pbkdf2_hmac('sha256', bytes(a, 'utf-8'), bytes(salt_response['saltwebui'], 'utf-8'), 1000).hex()[:32]

That was easy!

Logging in

Once we have the token we can call the login endpoint again but now with the token as the password

response = session.post(router_address+'/api/v1/session/login', headers=headers, data={'username': username, 'password': b})

If the response contains {error: 'ok'} it means it has been authentificated :)

Getting the list of active devices

After login the request are plain simple, to get the lan status just call the following endpoint:

response = session.get('http://192.168.0.1/api/v1/sta_lan_status', headers=headers)
print(response.json());

the response has the following format :

    export interface DhcpipStaticTbl {
        __id: string;
        static_mac: string;
        dhcpip_static: string;
    }

    export interface DhcpTbl {
        __id: string;
        dhcpmac: string;
        wifi_type: string;
        dhcphost: string;
        dhcpstatus: string;
        dhcpexpires: string;
        dhcptype: string;
        dhcpip_dhcp: string;
    }

    export interface Data {
        interface: string;
        DHCP_addrpool_start: string;
        DHCP_addrpool_end: string;
        RadioEnable2: string;
        RadioEnable5: string;
        dhcpip_staticTbl: DhcpipStaticTbl[];
        dhcpTbl: DhcpTbl[];
        IPAddressRT: string[];
        DNSTblRT: string[];
        IPAddressGW: string;
    }

    export interface RootObject {
        error: string;
        message: string;
        data: Data;
    }

Terminating the session

To terminate the session the logout endpoint is available, this one needs however the crsf token. The sta_lan_status endpoint doens't require or returns a token. For the token a request to any other endpoint has to be made, for some reason the endpoints are called with the timestamp of the client on the _ query param :

import time
now = calendar.timegm(time.gmtime())
response = session.get(router_address+'/api/v1/host/AssociatedDevices5?_=' + str(now), headers=headers)
headers['X-CSRF-TOKEN'] = response.json()['token']
response = session.post(router_address+'/api/v1/session/logout', headers=headers)

Some end words

The security of this device is medium, the login endpoint is not protected by crsf neither the sta_lan_status, in some scenarios an attacker can exploit these via a browser extension or a crypeld browser and open ports to your local devices. Therefor is strongly recomended to change the default password

So there you have it, attached you can find the whole script

#!/usr/bin/python3
import calendar
import hashlib
import sys
import time
from requests import Session
router_address = 'http://kabelbox.local' # change acordingly
username = 'admin'
password = 'some password'
headers = {'User-Agent': 'Mozilla/5.0', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-CSRF-TOKEN': '', 'X-Requested-With': 'XMLHttpRequest', 'Referer': 'http://192.168.0.1/'}
session = Session()
salt_response = session.post(router_address+'/api/v1/session/login', headers=headers, data={'username': username, 'password': 'seeksalthash'}).json()
# print(saltResponse)
if (salt_response['error'] != 'ok'):
print('{}');
sys.exit(0)
a = hashlib.pbkdf2_hmac('sha256', bytes(password, 'utf-8'), bytes(salt_response['salt'], 'utf-8'), 1000).hex()[
:32]
b = hashlib.pbkdf2_hmac('sha256', bytes(a, 'utf-8'), bytes(salt_response['saltwebui'], 'utf-8'), 1000).hex()[
:32]
# print(b);
response = session.post(router_address+'/api/v1/session/login', headers=headers,
data={'username': 'admin', 'password': b})
# print(response.json());
response = session.get(router_address+'/api/v1/sta_lan_status', headers=headers)
print(response.json())
now = calendar.timegm(time.gmtime())
response = session.get(router_address+'/api/v1/host/AssociatedDevices5?_=' + str(now), headers=headers)
headers['X-CSRF-TOKEN'] = response.json()['token']
response = session.post(router_address+'/api/v1/session/logout', headers=headers)
@F4bsi
Copy link

F4bsi commented Dec 19, 2021

Seems like Vodafone changed something for the endpoints where you add the timestamp in firmware version 3. I always get "Unauthorized User" for the "AssociatedDevices5" endpoint (same result on calls). Also tried using a more precise timestamp, like the browser UI seems to be using, but same result. Maybe someone has another idea?

@TwizzyDizzy
Copy link

TwizzyDizzy commented Feb 6, 2022

When having a look at the actual requests happening when using the web-interface, I realized, that they now seem to be sending the timestamp in microseconds, not in seconds (referring to line 36).

Also that page (line 37) seems to not be available on devices that are run in bridge-mode.

That being said, I am currently also stuck with your observation. My goal is to reboot the modem without having to actually physically turn it off and on again (and obviously also without having to login into the web interface manually).

Here are 2 screenshots. The token seems to now be requested by a JS function, see the function initData in [1] (which is the request in the second screenshot)

screen1

screen2

Cheers
Thomas

[1]

(function () {

    $("#restart_page-content-container").on('click','#reset_btn',function(e) {
        e.preventDefault();
        reset_popModal();
    });



    $("#reset_modal_container").on('click', '#apply_reset_btn', function(e) {
        e.preventDefault();
        reset_pogress_popModal();
    });

    translator.translateDom('#reset_modal_container');
    preprocesser.initChosen();


    $("#restart_modal_container").on('click', '#apply_btn', function (e) {
        e.preventDefault();
        var data = $(this).data('value');

        $("#restart_pogress_container").html(tmpl('restart_pogress_tmpl')).modal({
            showClose: false
        });

        translator.translateDom('#restart_pogress_container');
        preprocesser.initChosen();


        $.ajax({
            url: 'api/sta_restart',
            type: 'POST',
            data: data
        })
            .done(function(res) {

            });



        setTimeout(function() {

            var newHost = location.origin;
            $.checkSite({
                URL: newHost+"/image/spinner.gif",
                timeout: 3*60*1000,
                onSuccess: function(){
                    $.modal.close();
                    location.href = newHost+"/#/logout";
                },
                onFail: function(){
                    location.href = newHost+"/#/logout";
                }
            });

        },5000);



    });


    function reset_popModal(){

        $("#reset_modal_container").html(tmpl('reset_tmpl')).modal({

            showClose: false

        });
        translator.translateDom('#reset_modal_container');
        preprocesser.initChosen();


    }

    function reset_pogress_popModal(){

        reset_call_function();

        $("#reset_pogress_container").html(tmpl('reset_pogress_tmpl')).modal({

            showClose: false

        });

        translator.translateDom('#reset_pogress_container');
        preprocesser.initChosen();

    }

    function reset_call_function() {
        setTimeout(function(){reset_callajax_function(); }, 2000);
    }

    function reset_callajax_function(){

        $.ajax({
            url: 'api/sta_restart',
            type: 'POST',
            data: {	reset:'Router,Wifi,Docsis,VoIP,Firewall','ui_access':'factory_reset' }
        })
            .done(function(res) {

            });


        setTimeout(function() {

            var newHost = location.origin;
            $.checkSite({
                URL: newHost+"/image/spinner.gif",
                timeout: 3*60*1000,
                onSuccess: function(){
                    $("#reset_config_success_container").html(tmpl('reset_config_success_tmpl')).modal({
                        showClose: false
                    });
                    translator.translateDom('#reset_config_success_container');
                    preprocesser.initChosen();
                    $('#reset_ok').on('click',function(){
                        location.reload();
                    });
                    $('#reset_ok_cross').on('click',function(){
                        location.reload();
                    });

                },
                onFail: function(){
                    location.href = newHost;
                }
            });

        }, 5000);



    }


    function restart_popModal() {

        $("#restart_modal_container").html(tmpl('restart_tmpl')).modal({
            showClose: false
        });
        translator.translateDom('#restart_modal_container');
        preprocesser.initChosen();
    }



    function initData() {

        $.ajax({
            url: 'api/session/init_page',
            type: 'GET'
        })
            .done(function(res) {
                app.setToken(res.token);
            });

        $("#restart_page-content-container").html(tmpl('restart_page-content-tmpl'));
        preprocesser.initRwdTable();
        preprocesser.initChosen();
        translator.translateDom('#restart_page-content');


        $("#restart_page-content-container").on('click', '#restart_btn', function () {
            restart_popModal();
        })


    }
    initData();


})();

@F4bsi
Copy link

F4bsi commented Feb 20, 2022

@TwizzyDizzy I found a solution in a different project commit: gmk6351/vodafone-station-exporter@9e985c6
Seems like it you just need to add a request to the "/api/v1/session/menu" endpoint directly after login and some cookies or session variables will be set, which allow you do go on with the next request.
So adding session.get(router_address+'/api/v1/session/menu', headers=headers) in line [32] should do the trick. My request to get the Call log is now working again (even without adding the time thing session.get(router_address+'/api/v1/phone_calllog/0,1/CallTbl', headers=headers). So I guess your restart endpoint should work too.

@TwizzyDizzy
Copy link

Hi @F4bsi!

thanks for getting back. While that seems to have fixed your issue, mine is not solved by this, as the request to reboot the device needs an XSRF-Token token which is not returned by the call to /api/v1/session/menu (though a lot of JSON is returned, indeed).

Cheers
Thomas

@F4bsi
Copy link

F4bsi commented Feb 20, 2022

Hey @TwizzyDizzy, the CallTbl call acutally returns a token too, as this should also be available in the bridge mode you could try that?
This is the way I'm currently doing it:

 response = session.post(router_address+'/api/v1/session/login', headers=headers, data={'username': 'admin', 'password': b})
#print(response.text[:200] );
response = session.get(router_address+'/api/v1/session/menu', headers=headers)

response = session.get(router_address+'/api/v1/phone_calllog/0,1/CallTbl', headers=headers)
response_json = response.json()
print(response_json)
token = response_json['token']
print(token)

I haven't acually tried actually rebooting with that token, but maybe its the same as the one from AssociatedDevices5.
Good luck!

@TwizzyDizzy
Copy link

You are absolutely right, the API call to /api/v1/phone_calllog/0,1/CallTbl indeed returns a token which I can use to reboot the device! Thanks for pointing that out!

Here's the code (everything up until that is identical to the original gist, so basically until line 30):

session.get(router_address+'/api/v1/session/menu', headers=headers)
get_call_log = session.get(router_address+'/api/v1/phone_calllog/0,1/CallTbl', headers=headers)
headers['X-CSRF-TOKEN'] = get_call_log.json()['token']
reboot = session.post(router_address+'/api/v1/sta_restart', headers=headers,data={'restart': 'Router,Wifi,VoIP,Dect,MoCA', 'ui_access': 'reboot_device'})

Above code successfully reboots the device (on firmware version 3.0.41-IMS-KDG that is).

Rejoice!

Cheers
Thomas

@domessina
Copy link

domessina commented Apr 14, 2022

Thank you for sharing your scripts. The technicolor router is also used by my ISP. But the CSRF token is communicated by the second /login response. The /menu request is still required tough. Easy regex and hf:

response = session.post(router_address+'/api/v1/session/login', headers=headers, data={'username': 'voo', 'password': b})
token = re.search("[a-f0-9]{32}", response.headers['Set-Cookie']).group()
headers['X-CSRF-TOKEN'] = token

I scheduled a task on my synology to enable / disable Wi-Fi

@tiberiucorbu
Copy link
Author

Hey, I cannot maintain this script anymore because I don't own a technicolor router anymore.

@maxux
Copy link

maxux commented Dec 24, 2022

Hello,

My ISP (voo) latest CM they provide is a Technicolor CGA as well.
Here is a script I maintain to fetch/request stuff to the CM:
https://github.com/maxux/dashboard-home/blob/master/slaves/modules/voocga.py

I exclusively use my modem in bridge mode (using 192.168.100.1 address), didn't tested in router mode. That one can request DOCSIS Signal Level (the main reason why I wrote it), request system information and (fully) reboot the modem.

Here is my device (via system call):

{
  "CMMACAddress": "3c:82:c0:0d:xx:xx",
  "CoreVersion": "1.0",
  "MACAddressRT": "3c:82:c0:0d:xx:xx",
  "LocalTime": "2022-12-23 12:05:13",
  "LanMode": "bridge-static",
  "HardwareVersion": "1.0.0\n",
  "FirmwareName": "CGA4233VOO-19.1.B39-019", 
  "UpTime": "233",
  "ModelName": "CGA4233VOO",
  "CMStatus": "OPERATIONAL",
  "Manufacturer": "Technicolor",
  "SerialNumber": "CP2231xxxxxx",
  "SoftwareVersion": "CGA4233VOO-19.1.B39-019",
  "BootloaderVersion": "3.62.19.22", 
  "FirmwareBuildTime": "2022-03-25 17:05:12", 
  "ProcessorSpeed": "1503", 
  "Hardware": "512", 
  "MemTotal": "350212", 
  "MemFree": "180232"
}

@guerda
Copy link

guerda commented Jan 24, 2023

I fiddled around and your code is great, @tiberiuscorbu.
What is missing for you @TwizzyDizzy with the recent version of Vodafone station is a call to /api/v1/session/menu. This needs to be performed before AssociatedDevices5 can be called.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment