Situation

We have multiple air conditioner devices all over the office, but in our corporate culture, we want to automate everything. For example, if everyone left the office from a specific room and the last one forgot to turn off the AC device, who cares? Our system will turn it off for you. The sad news is that we can't do this with these devices. We found an extra attachable device to eliminate this disability. Lucky for us, they have an Android application as well. But, there is no API or any documentation.

We have a lot of AC devices on the wall, we have a few small devices connected to our AC devices and an Android application. We have a cool office, but it can be way more cooler.

Oh, we have one more thing, a lot of smart employees with good hacker mentality.

Mission

Take over the control of the corporate air conditioning system.

Execution

The Android application is our only entry point. It can communicate with all the AC devices, and we can observe its communication and try to forge our own packets.

The first plan: capture all the TCP packets between the Android application and the Internet. Let's configure our computer as a WiFi Hotspot; connect our phone to this network and try to caption everything with tcpdump. There we are, buy we have a very noisy dump because of all the applications on the phone that send and receive packets.

Once we remove all known destinations, we can identify our target; alas, it has a fancy HTTPS connection.

HTTPS

No problem, we can build our own MitM attack with a self-created SSL certificate. Let's generate one:

  1. openssl req -new -x509 \
  2.     -keyout test-new-key.pem \
  3.     -out test-new-cert.pem

Now we can build the proxy application to listen on a given port with our own SSL certificate.

  1. cert = OpenSSL::X509::Certificate.new(
  2.     File.read('cert/test-cert.pem')
  3. )
  4. pkey = OpenSSL::PKey::RSA.new(
  5.     File.read('cert/test-key.pem')
  6. )
  7.  
  8. server = WEBrick::HTTPServer.new(
  9.   :Port => 443,
  10.   :SSLEnable => true,
  11.   :SSLCertificate => cert,
  12.   :SSLPrivateKey => pkey
  13. )
  14.  
  15. SERVER_URL = 'https://mapp.appsmb.com'
  16.  
  17. def proxy_request(path, payload)
  18.     payload = "" if payload.nil?
  19.     payload = Hash[URI.decode_www_form(payload)]
  20.  
  21.     uri = URI("#{SERVER_URL}#{path}")
  22.     http = Net::HTTP.new(uri.host, uri.port)
  23.     http.use_ssl = (uri.scheme == 'https')
  24.     http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  25.  
  26.     request = Net::HTTP::Post.new(uri)
  27.     request.set_form_data(payload)
  28.     response = http.request(request)
  29.  
  30.     puts "<- Requested path: #{path}"
  31.     puts "<- Payload: #{payload}"
  32.     puts "-> Response body: #{response.body}"
  33.  
  34.     puts '-------------------------'
  35.  
  36.     response.body
  37. end
  38.  
  39. server.mount_proc '/' do |req, res|
  40.     res.body = proxy_request(
  41.         req.request_line.split(' ')[1],
  42.         req.body
  43.     )
  44. end
  45.  
  46. trap 'INT' do server.shutdown end
  47.  
  48. server.start

Note: I think now you can figure out our target form this URL: mapp.appsmb.com. Yes, it's a Midea device. It’s not a secret, but I was trying to avoid revealing it for a while..

Next, redirect all traffic to our laptop with a specific destination. Easy one with dnsmasq:

  1. address=/.mapp.appsmb.com/192.168.2.1

At this point, we don't have to keep our WiFi Hotspot alive, because we can simply change the DNS server on our phone.

Sad news again: The application is not happy. It can't connect to the server because we have an invalid certificate. So the client side of their certificate is embedded into the application. We have to patch the Android application.

  1. # Generate the client cert
  2. openssl x509 \
  3.     -in test-cert.pem \
  4.     -out mapp.appsmb.com.crt
  5.  
  6. # Make a copy
  7. cp NetHomePlus.apk NetHomePlus-ssl-injected.apk
  8.  
  9. # Figure out where is the cert inside the .apk
  10. unzip -l NetHomePlus-ssl-injected.apk | grep crt
  11. # -> assets/mapp.appsmb.com.crt
  12.  
  13. # Create structure for the zip file
  14. mkdir assets && cp mapp.appsmb.com.crt assets
  15.  
  16. # Add our own SSL cert
  17. zip -ur NetHomePlus-ssl-injected.apk assets
  18.  
  19. # Delete all META-INF from the .apk
  20. zip -d NetHomePlus-ssl-injected.apk META-INF/\*
  21.  
  22. # Generate a keystore to sign the .apk
  23. keytool -genkey -v \
  24.     -keystore my-release-key.keystore \
  25.     -alias alias_name \
  26.     -keyalg RSA -keysize 2048 \
  27.     -validity 10000
  28.  
  29. # Sign the .apk
  30. jarsigner -verbose \
  31.     -sigalg SHA1withRSA \
  32.     -digestalg SHA1 \
  33.     -keystore my-release-key.keystore \
  34.     NetHomePlus-ssl-injected.apk \
  35.     alias_name

Huh. Let's try again. Transfer the new .apk onto the phone and start. It works. Now we can see everything. Signing in is a simple POST request with the username and the password after which the response is a JSON object with a sessionId, userId and accessToken. But wait... The password in the POST request is not our password. It's something strange, looks like a hash.

Sign in

Right before the user/login request, there is a different one that hits user/login/id/get with only one parameter and it's our email address (username) and it returns with an ID.

The password in the login request seems like a hash. If we look at it closer, it's maybe a SHA hash, but simply hashing our password is not enough. We can try to append our userId at the beginning and at the end of our password before hashing, but it does not work.

Seems a dead end.

Decode an Android app

No clue what we can do, but can we decode the .apk? Yes, of course.

Note: If you don't have radare2 installed on your system, I can only recommend you to install it. It's a very handy tool. We will use dex2jar and it's easy to install with the package manager of radare2: r2pm -i dex2jar. Another mentionable part of radare2 is rax2, it helped me a lot during this mission.

  1. # Extract classes.dex (Dalvik dex file version)
  2. unzip -p NetHomePlus-ssl-injected.apk classes.dex
  3.  
  4. # Convert dex to jar
  5. dex2jar classes.dex

Now we have a .jar file. With JD-GUI we can open and browse the source code. Ok, not really, but close enough to understand the basic logic behind the scenes.

With newer java we have to tweak a bit to start JD-GUI:

  1. java \
  2.     --add-opens java.base/jdk.internal.loader=ALL-UNNAMED \
  3.     --add-opens jdk.zipfs/jdk.nio.zipfs=ALL-UNNAMED \
  4.     -jar jd-gui-1.4.0.jar \
  5.     classes-dex2jar.jar

In the BaseAPI.class file we can see a lot of interesting constants like appKey, appId and src. They will be important later, but back to the sign in password hashing. With Search, we can look up where we can find a string for the login URL.

JD-GUI: Search User Login

In IDataPush.class we can see a reference on a package: com.midea.msmartsdk.common.datas.DataPushUserLogin, but after checking the class, it's not so helpful.

Note: It's basically a guessing game. Now I try to describe (somehow) how to jump around and find information crumbs.

Adventure Time! We can do nothing, but walk around and check files with interesting names like EncodeAndDecodeUtils.class. It has a few very promising functions like encodeAES, encodeMD5, encodeSHA and decodeAES. We can assume that we have to use most of them to make proper packets. Let's start with encodeSHA because the sent password was not an md5 hash (it was too long). It can be AES or SHA, but we have no secretKey yet, so there is nothing in our hands that can be a secret to mask the password.

In DataAccount.class we use encodeSHA inside a setUserPwd function. Oh wow, it seems we found something. And... no, useless, we can't find where we use setUserPwd, but it strengthens the thesis: It's a SHA hash.

In DeviceRequest.class we can see how it appends sign= into the URL, this can be useful later.

Oh, an interesting class again: UserRequest.class. Quick scroll and we can see there is more logic on how to sign in. Let's check one with loginAccount and password because we saw those in the request. The getUserLogin function seems a good one, it calls Util.encodeSHA256Ex(paramString2).

  1. public static String encodeSHA256Ex(String paramString)
  2.   {
  3.     String str1 = (String)SharedPreferencesUtils.getParam(MSmartSDK.getInstance().getAppContext(), Const.SP_KEY_LOGIN_ID, "");
  4.     String str2 = MSmartSDK.getInstance().getAppKey();
  5.     return EncodeAndDecodeUtils.getInstance().esha(paramString, str1, str2);
  6.   }

Ok, we don't know what esha does, but no problem, it has to be a SHA hash and we have 3 string components. While we walked around we found a function named encodePasswordAfterSHA256. It looks the same as encodeSHA256Ex, but it calls eshaWithoutEncode instead of esha. So maybe password can be SHA-hashed before as well. Brute-force time! It’s a possible dead end again, but we have to try everything. If it does not work, we can read those ugly classes and functions again.

  1. require 'digest/sha2'
  2.  
  3. # We know it from BaseAPI.class
  4. app_key = '3742e9e5842d4ad59c2db887e12449f9'
  5. # we know it from the user/login/id/get response
  6. login_id = '111111'
  7. # We know it... because we know it
  8. password = 'asdfghjkl'
  9.  
  10. # We want to generate this hash somehow
  11. target = '5199c3678177a450c364e4472d290ae4a96d04e603c23c38a6f939da31946924'
  12.  
  13. [app_key, login_id, password].permutation do |current|
  14.     str = "#{current[0]}#{current[1]}#{current[2]}"
  15.     if (::Digest::SHA2.new << str).to_s == target
  16.       puts "FOUND: #{current[0]} + #{current[1]} + #{current[2]}"
  17.       break
  18.     else
  19.       puts "NOT: #{current[0]} + #{current[1]} + #{current[2]}"
  20.     end
  21. end
  22.  
  23. password = (::Digest::SHA2.new << password).to_s
  24. [app_key, login_id, password].permutation do |current|
  25.     str = "#{current[0]}#{current[1]}#{current[2]}"
  26.     if (::Digest::SHA2.new << str).to_s == target
  27.       puts "FOUND: #{current[0]} + #{current[1]} + #{current[2]}"
  28.       break
  29.     else
  30.       puts "NOT: #{current[0]} + #{current[1]} + #{current[2]}"
  31.     end
  32. end

"Modify, Customize, Rubify"

  1. NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + asdfghjkl
  2. NOT: 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl + 111111
  3. NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl
  4. NOT: 111111 + asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9
  5. NOT: asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9 + 111111
  6. NOT: asdfghjkl + 111111 + 3742e9e5842d4ad59c2db887e12449f9
  7. NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1
  8. NOT: 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 + 111111
  9. NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1
  10. FOUND: 111111 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 + 3742e9e5842d4ad59c2db887e12449f9

It seems our guess was correct, so go and reproduce the same logic in Ruby:

  1. def encrypt_password(password, login_id, app_key)
  2.     pass = (::Digest::SHA2.new << password).to_s
  3.     (::Digest::SHA2.new << "#{login_id}#{pass}#{app_key}").to_s
  4. end

If we analyze all the queries, we can see there are a few parameters in common:

  • appId = 1017 (we found it as a constant as well)
  • format = 2 (we found it as a constant as well: JSON)
  • clientType = 1 (we found it as a constant as well: Android)
  • src = 17 (we found it as a constant as well)
  • language (obvious)
  • stamp (obvious: timestamp)
  • sign (sign, but unknown logic yet)

Now we know a sequence of queries (plus the common parameters):

  1. https://mapp.appsmb.com/v1/user/login/id/get
    1. {
    2.     :loginAccount=>"username",
    3. }
  2. https://mapp.appsmb.com/v1/user/login
    1. {
    2.     :loginAccount=>"username",
    3.     :password=>"long hash"
    4. }
  3. https://mapp.appsmb.com/v1/homegroup/list/get
    1. {
    2.     :sessionId=>"long hash",
    3. }
  4. https://mapp.appsmb.com/v1/appliance/list/get
    1. {
    2.     :homegroupId=>"group id from homegroup/list/get",
    3.     :sessionId=>"long hash"
    4. }

After these steps, we get a full device list. The only unknown part is the sign on our request, but we already found some clues in DeviceRequest.class.

Sign

What do we have in DeviceRequest.class? A function: getRequestUrl.

Ok, it's ugly. Let's clean it up a bit (no, I will not copy the whole function here):

  1. str1 = '/v1/' + 'user/login/id/get'
  2. # key1=value&key2=value
  3. localObject1 = str1 + params.join('&')
  4. paramList = localObject1.to_s
  5. localObject1 += app_key
  6. localObject1 = sha256(localObject1)

Still ugly, but basically the whole signing procedure is like this:

  1. sha256(path + params + app_key)

"Modify, Customize, Rubify"

  1. def sign(path, args, app_key)
  2.     query = args.map { |k, v| "#{k}=#{v}" }.to_a.sort.join('&')
  3.     content = "#{path}#{query}#{app_key}"
  4.     (::Digest::SHA2.new << content).to_s
  5. end

At this point, we know everything about how to sign-in and how to list devices.

  1. require 'digest/sha2'
  2. require 'json'
  3. require 'net/http'
  4. require 'openssl'
  5.  
  6. class Client
  7.     SERVER_URL  = 'https://mapp.appsmb.com/v1'
  8.     APP_ID      = '1017'
  9.     SRC         = '17'
  10.     APP_KEY     = '3742e9e5842d4ad59c2db887e12449f9'
  11.     LANGUAGE    = 'en_US'
  12.     CLIENT_TYPE = 1       # Android
  13.     FORMAT      = 2       # JSON
  14.  
  15.     def initialize(email, password)
  16.         @email    = email
  17.         @password = password
  18.  
  19.         @current = nil
  20.     end
  21.  
  22.     def login
  23.         login_id = user_login_id_get['loginId']
  24.  
  25.         @current = api_request(
  26.             'user/login',
  27.             loginAccount: @email,
  28.             password: encrypt_password(login_id)
  29.         )
  30.     end
  31.  
  32.     def appliance_list
  33.         response = api_request(
  34.             'appliance/list/get',
  35.             homegroupId: default_home['id']
  36.         )
  37.         response['list']
  38.     end
  39.  
  40.  
  41.     def user_login_id_get
  42.         api_request('user/login/id/get', loginAccount: @email)
  43.     end
  44.  
  45.     def default_home
  46.         @default_home ||= api_home_list['list'].select do |h|
  47.             h['isDefault'].to_i == 1
  48.         end.first
  49.     end
  50.  
  51.     def api_home_list
  52.         api_request('homegroup/list/get')
  53.     end
  54.  
  55.     def encrypt_password(login_id)
  56.         pass = (::Digest::SHA2.new << @password).to_s
  57.         (::Digest::SHA2.new << "#{login_id}#{pass}#{APP_KEY}").to_s
  58.     end
  59.  
  60.     def sign(path, args)
  61.         query = args.map { |k, v| "#{k}=#{v}" }.to_a.sort.join('&')
  62.         content = "#{path}#{query}#{APP_KEY}"
  63.         (::Digest::SHA2.new << content).to_s
  64.     end
  65.  
  66.     def api_request(endpoint, **args)
  67.         args = {
  68.             appId: APP_ID, format: FORMAT, clientType: CLIENT_TYPE,
  69.             language: LANGUAGE, src: SRC,
  70.             stamp: Time.now.strftime('%Y%m%d%H%M%S')
  71.         }.merge(args)
  72.  
  73.         args[:sessionId] = @current['sessionId'] unless @current.nil?
  74.  
  75.         path = "/#{SERVER_URL.split('/').last}/#{endpoint}"
  76.         args[:sign] = sign(path, args)
  77.  
  78.         result = send_api_request(URI("#{SERVER_URL}/#{endpoint}"), args)
  79.  
  80.         result['result']
  81.     end
  82.  
  83.     def send_api_request(uri, args)
  84.         Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  85.             request = Net::HTTP::Post.new(uri)
  86.             request.set_form_data(args)
  87.  
  88.             result = JSON.parse(http.request(request).body)
  89.             raise result['msg'] unless result['errorCode'] == '0'
  90.  
  91.             result
  92.         end
  93.     end
  94. end
  95.  
  96. client = Client.new('username', 'password')
  97. client.login
  98. devices = client.appliance_list
  99. devices.each do |device|
  100.   print "[id=#{device['id']} type=#{device['type']}]"
  101.   print " #{device['name']} is "
  102.   print 'not ' if device['onlineStatus'] != '1'
  103.   print 'online and '
  104.   print 'not ' if device['activeStatus'] != '1'
  105.   puts 'active.'
  106. end

And finally, we have a basic client with a "simple" login mechanism and we can list all our devices.

Conclusion

If you read this post, it seems easy, but if you try to do it without a guide, it's much longer. I can't say it's harder but much longer.

OK, we can't manage our air conditioning system yet at this point. But, with this Client class, we are close to accomplishing our mission. We have to track back how to fetch information about a specific device, how we can turn them on/off, and how to set its target temperature.

Sounds easy, but it will be much longer with more gripping twists than what you just read.

To give you a sneak peek, we have to implement our own block AES encoder and decoder, and we have to do a lot of bit-shifting.