Operation AC: CTF Without a Flag (Part I)

Nádasdi Balázs
Nádasdi Balázs
Head of Engineering
2018 szep 24
Operation AC



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.




Take over the control of the corporate air conditioning system.




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.




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



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 openssl x509 \
  2.     -in test-cert.pem \
  3.     -out mapp.appsmb.com.crt
  4. # Make a copy cp NetHomePlus.apk NetHomePlus-ssl-injected.apk
  5. # Figure out where is the cert inside the .apk unzip -l NetHomePlus-ssl-injected.apk | grep crt # -> assets/mapp.appsmb.com.crt
  6. # Create structure for the zip file mkdir assets && cp mapp.appsmb.com.crt assets
  7. # Add our own SSL cert zip -ur NetHomePlus-ssl-injected.apk assets
  8. # Delete all META-INF from the .apk zip -d NetHomePlus-ssl-injected.apk META-INF/\*
  9. # Generate a keystore to sign the .apk keytool -genkey -v \
  10.     -keystore my-release-key.keystore \
  11.     -alias alias_name \
  12.     -keyalg RSA -keysize 2048 \
  13.     -validity 10000
  14. # Sign the .apk jarsigner -verbose \
  15.     -sigalg SHA1withRSA \
  16.     -digestalg SHA1 \
  17.     -keystore my-release-key.keystore \
  18.     NetHomePlus-ssl-injected.apk \
  19.     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) unzip -p NetHomePlus-ssl-injected.apk classes.dex
  2. # Convert dex to jar 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.

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. # We know it from BaseAPI.class app_key = '3742e9e5842d4ad59c2db887e12449f9' # we know it from the user/login/id/get response login_id = '111111' # We know it... because we know it password = 'asdfghjkl'
  3. # We want to generate this hash somehow target = '5199c3678177a450c364e4472d290ae4a96d04e603c23c38a6f939da31946924'
  4. [app_key, login_id, password].permutation do |current|
  5.     str = "#{current[0]}#{current[1]}#{current[2]}"
  6.     if (::Digest::SHA2.new << str).to_s == target
  7.       puts "FOUND: #{current[0]} + #{current[1]} + #{current[2]}"
  8.       break
  9.     else
  10.       puts "NOT: #{current[0]} + #{current[1]} + #{current[2]}"
  11.     end
  12. end
  13. password = (::Digest::SHA2.new << password).to_s [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 


"Modify, Customize, Rubify"


 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + asdfghjkl NOT: 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl + 111111 NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl NOT: 111111 + asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9 NOT: asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9 + 111111 NOT: asdfghjkl + 111111 + 3742e9e5842d4ad59c2db887e12449f9 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 + 111111 NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 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.



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):


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


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


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


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




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.