This guide seeks to provide a guide to setting up a Tor hidden service with a moderate-to-high level of safety. It was compiled on, and was accurate as of, 2019-11-19.
While browsing the dark web, as one is to do on occasion, I noticed a trend of poorly configured onion services. They were leaking all sorts of information through their misconfigurations. I started to look for guides on setting up onion services, and noticed that a lot of those were substandard and frequently lacked a date, making it hard to know if you’re setting up something that’s considered good today or if it was considered good 10 years ago. Like any good computer nerd, I saw this opportunity to develop my own competing standard.
This guide DOES NOT cover:
This guide walks through setting up a Tor hidden service for both server administration as well as for hosting your website. It uses the following technologies:
Throughout the guide, I reference the contents of some of the values you generate during setup. These are:
<private_key>
is the “private” value of the x25519-gen.py
output<public_key>
is the “public” value of the x25519-gen.py
output<server_ipaddr>
is the IP address of the server you’re setting this up on.</var/lib/tor/hidden_ssh/hostname>
which means to fill in the contents of the hidden_ssh hostname file</var/lib/tor/hidden_http/hostname>
which means to fill in the contents of the hidden_http hostname fileThe context of commands switches periodically throughout this guide. I’ve tried to make them as consistent as possible, however.
user@local:~$
indicates your local user on your local machineroot@remote:~$
indicates your root account on the remote machine that you’re putting the hidden service on. This is only used until we setup the user account, and all subsequent access uses the user account.user@remote:~$
indicates your user account on the remote machine that you’re putting the hidden service onWhenever I enter a text editor session, the following lines represent what goes in the edited file. At the line that begins with user@
, this is your indication to save the edited file and exit the editor. If you see [...]
immediately after a text editor opening line, this is an indication that there will be plenty of other content in there that you (probably) don’t need to worry about.
Okay enough preface. Let’s begin.
As part of our effort to set up a safe hidden service, we’re going to separate the management address from the customer address. This means we’re going to setup an onion address specifically for ssh access, and we’re never going to share that onion address with anyone. We’ll set up a separate “public” onion address that can be shared for the web server later.
Before we begin setting up the hidden service server, we need to do some preparation on our local machine. We’ll install tor, download a python script that generates x25519 certificates for us, and prepare ourselves for Hidden Service V3 Client Authorization. In order to use the x25519 generator, we need to install the pynacl
package, which we’ll install to our user environment to avoid contaminating the system level packages.
user@local:~$ sudo apt-get install tor
user@local:~$ wget https://raw.githubusercontent.com/pastly/python-snippits/master/src/tor/x25519-gen.py
user@local:~$ sha256sum x25519-gen.py
92dd8b0b65b27f32506a0926b4f9b485577078936bc7837fb7ead65674da4ad1 x25519-gen.py
user@local:~$ pip3 install --user pynacl
user@local:~$ python3 x25519-gen.py | tee torclientauth
Now that we’ve installed tor and prepared our clientauth information, let’s configure our local Tor daemon to look for Client Onion Authorization keys in /var/lib/tor/onion_auth
. We’ll also prepare that directory and create a mockup version of the private file, even though we don’t know the onion address we’re going to use yet. Finally, we’ll make sure that the debian-tor
user owns the ClientOnionAuthDir
directory and then restart tor. Optionally, you can ensure that tor is running with ps afxu | grep [t]orrc
to find references to torrc
in the running process list.
Note: The value of <private_key>
is the “Private” value that was created by the previous x22519-gen.py
command.
user@local:~$ sudo nano /etc/tor/torrc
[...]
ClientOnionAuthDir /var/lib/tor/onion_auth
user@local:~$ sudo mkdir /var/lib/tor/onion_auth
user@local:~$ sudo nano /var/lib/tor/onion_auth/myonion.auth_private
ONIONADDRWEDONTKNOWYET:descriptor:x25519:<private_key>
user@local:~$ sudo chown -R debian-tor:debian-tor /var/lib/tor/onion_auth
user@local:~$ sudo systemctl restart tor
user@local:~$ ps afux | grep [t]orrc
We’ve nearly got our local tor instance completely set up, but we’ll need to come back to fill out the ONIONADDRWEDONTKNOWYET
after it’s generated.
For the sake of not cross contaminating any information about ourselves, let’s make a brand new ssh key that we’ll only use with this server. I called it onion_key
but you can call it whatever you want. Be sure to select a strong password for this key, that way even if it’s stolen, it won’t be useful by itself. Note that if someone has compromised your machine, it is likely they could also be keylogging to detect this password. It’s not a perfect defense.
Once the key is done generating, edit our local ssh config file and setup IdentitiesOnly yes
for all hosts we try to ssh to. This means that our local ssh client won’t try to send any of our ssh keys to the server unless they are explicitly called out, either on the command line via -i ~/.ssh/onion_key
or in our ssh config via IdentityFile ~/.ssh/onion_key
.
This may be annoying at first, because it requires you to specify which identity to use for every ssh connection, but if you care about your opsec then you should get used to it. If your onion is compromised (which hopefully it won’t be), an adversary could watch the sshd to see what public keys are being provided to it. The last thing you want is for the public keys that are linked to your github to be sent to your secret onion administration address, thereby linking the onion administrator to a publicly known key.
user@local:~$ ssh-keygen -t rsa -b 4096 -f ~/.ssh/onion_key
user@local:~$ nano ~/.ssh/config
Host *
IdentitiesOnly yes
Now it’s time to ssh into the remote server and begin the setup.
user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>
[ MOTD SPAM ]
root@remote:~$
Upon initial connection, let’s make sure that everything is up to date and then install the packages we’re going to need for our hidden service, namely tor
, and nginx
. After we’ve installed our packages, add a new user which we’ll use to access the server in the future. Since we’re going to be setting up ssh over tor, all logins will come from 127.0.0.1
, and if you end up wanting to share this server with your ~co-conspirators~ friends then it’s important to have accountability. If everyone is logging in as root and one of you gets compromised, either digitally or federally, you have no way to identify which user has done the bad things on your hidden service.
I like to choose generic usernames, such as user
, for systems like this. Cleverness is the enemy of opsec. Make our initial user a sudoer, copy our current authorized_keys into the new user’s authorized_keys and make sure the permissions are all set.
root@remote:~$ apt-get update && apt-get upgrade && apt-get install -y tor nginx
root@remote:~$ adduser user
root@remote:~$ usermod -aG sudo user
root@remote:~$ mkdir ~user/.ssh && cp ~/.ssh/authorized_keys ~user/.ssh/authorized_keys
root@remote:~$ chown -R user:user ~user/.ssh
root@remote:~$ chmod go-rx ~user/
Once the new user has been created, let’s try to login as the new user to the server.
user@local:~$ ssh -i ~/.ssh/onion_key user@<server_ipaddr>
[ MOTD SPAM ]
user@local:~$
Perfect (I hope). Let’s modify the remote sshd configuration to prevent root from ever logging in directly, as well as ensure that only public keys can be used for authentication.
user@remote:~$ sudo nano /etc/ssh/sshd_config
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
Since we’re already logged in as the user and we were able to use sudo to edit the sshd config, we know that we don’t need to be able to login remotely as root anymore. Let’s go ahead and restart the sshd to make sure our changes take effect.
user@remote:~$ sudo systemctl restart sshd
Just to be sure of our configuration, let’s test logging into the server as root over ssh.
user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>
root@<server_ipaddr>: Permission denied (publickey).
user@local:~$
We should receive a Permission denied (publickey).
error message. This means our security measures are doing what we wanted them to do.
Now that we’re logged in as our user and have taken basic ssh security precautions, it’s time to start configuring tor. We can append these lines to the bottom of your etc/tor/torrc
.
user@remote:~$ sudo nano /etc/tor/torrc
HiddenServiceDir /var/lib/tor/hidden_ssh/
HiddenServiceVersion 3
HiddenServicePort 22 127.0.0.1:22
This tells tor that we’re going to have a new hidden service, which will be internally referred to as hidden_ssh
and that we want it to use HiddenServiceVersion 3
. Finally, it tells tor to provide the hidden service on port 22, and to bind to 127.0.0.1:22
. This means that whatever is running on 127.0.0.1:22
will be exposed via the hidden service we’re creating.
Restart tor and you should have a new hidden service in /var/lib/tor/
called hidden_ssh
. Grab the hostname, which will be referenced throughout the rest of this guide as </var/lib/tor/hidden_ssh/hostname>
.
Note: You really don’t want to lose this onion address. If you do, you likely won’t be able to get back into your server.
user@remote:~$ sudo systemctl restart tor
user@remote:~$ sudo cat /var/lib/tor/hidden_ssh/hostname
</var/lib/tor/hidden_ssh/hostname>
Before we get too excited, let’s make sure we aren’t likely to lose this address by putting it in our ssh config on our local machine.
user@local:~$ nano ~/.ssh/config
Host *
IdentitiesOnly yes
Host myonion
HostName </var/lib/tor/hidden_ssh/hostname>
Port 22
User user
IdentityFile ~/.ssh/onion_key
ProxyCommand nc -X 5 -x localhost:9050 %h %p
Wonderful, now that we’ve got that saved, let me tell you what it does. From now on, when you want to access your onion, you can simply type ssh myonion
and it will automatically use nc
to proxy that connection to your local tor port and automatically use your specified ssh key for your onion. Just make sure tor is running before you ssh, otherwise you’ll get connection errors.
Now that that’s setup, let’s go back to our ClientOnionAuthDir
from earlier and put the onion address in our .auth_private
file.
user@local:~$ sudo nano /var/lib/tor/onion_auth/myonion.auth_private
</var/lib/tor/hidden_ssh/hostname>:descriptor:x25519:<private_key>
Our client is now ready to start doing client authorization against our onion server, but we still need to configure the hidden service to use client authorization.
Note: <public_key>
is the value generated at the beginning of this guide via the python x25519-gen.py
command. It should be saved in your local home directory in a file called torclientauth
in case you’ve lost it.
user@remote:~$ sudo mkdir /var/lib/tor/hidden_ssh/authorized_clients
user@remote:~$ sudo nano /var/lib/tor/hidden_ssh/authorized_clients/user.auth
descriptor:x25519:<public_key>
user@remote:~$ sudo chown -R debian-tor:debian-tor /var/lib/tor/authorized_clients/
user@remote:~$ sudo systemctl restart tor
We restart the tor daemon just to be sure that it knows about the authorized_clients
directory in our hidden_ssh
hidden service.
Now let’s confirm that our ssh config works and that client auth is working.
user@local:~$ ssh myonion
[ MOTD SPAM ]
user@remote:~$
Before we turn off our clearnet tor access, let’s make sure that tor comes up fine on reboot.
user@remote:~$ sudo shutdown -r now
Wait a few minutes for it to come back up and try to ssh to the onion again.
user@local:~$ ssh myonion
[ MOTD SPAM ]
user@remote:~$
Assuming you’re not locked out (and if you followed closely along, you shouldn’t be), you can now safely switch OpenSSH to only listen on localhost.
user@remote:~$ sudo nano /etc/ssh/sshd_config
[...]
ListenAddress 127.0.0.1
user@remote:~$ sudo systemctl restart sshd
If you were logged in over the ip address in your current terminal, you will have likely lost connection and will need to connect via the onion address from now on. Now you can attempt to ssh to the host again over clearnet and you’ll see that the connection is refused.
user@local:~$ ssh -i ~/.ssh/onion_key user@<server_ipaddr>
ssh: connect to host <server_ipaddr> port 22: Connection refused
We want to block all access to this server unless it comes to our hidden services. We’re only running our hidden service on this machine, and we want to prevent as much leakage as possible. So we’ll start up ufw
and start to configure it.
user@remote:~$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
user@remote:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
If we try to ssh to the ip address from the internet again, we’ll see the behavior has changed from the previous Connection Refused
message to a Connection timed out
error message. This means that the firewall is blocking the connection before it can even try to find a process listening on port 22.
user@local:~$ ssh -i ~/.ssh/onion_key root@<server_ipaddr>
ssh: connect to host <server_ipaddr> port 22: Connection timed out
This applies for all incoming connections. By default, ufw
will deny all incoming connections unless they match a specific rule. And we’re not going to set any rules to allow incoming connections, because all of our connections will come from tor, which appears as 127.0.0.1
to the host.
Now that all incoming access is refused, we can assume that any incoming connections will come from the Tor hidden service. That’s great, but let’s also make sure that our server doesn’t unintentionally leak its address via outbound connections by restricting the outbound firewall policy to only the ports necessary for tor to function.
user@remote:~$ sudo ufw allow out 9001
Rule added
Rule added (v6)
user@remote:~$ sudo ufw allow out 9030
Rule added
Rule added (v6)
user@remote:~$ sudo ufw default deny outgoing
Default outgoing policy changed to 'deny'
(be sure to update your rules accordingly)
user@remote:~$
Now if we try to curl a remote web server, we’ll see that it doesn’t work.
user@remote:~$ curl 0xda.de
curl: (6) Could not resolve host: 0xda.de
user@remote:~$ curl http://46.101.207.38
curl: (7) Failed to connect to 46.101.207.38 port 80: Connection timed out
user@remote:~$ curl https://46.101.207.38
curl: (7) Failed to connect to 46.101.207.38 port 80: Connection timed out
user@remote:~$ dig 0xda.de
; <<>> DiG 9.11.3-1ubuntu1.9-Ubuntu <<>> 0xda.de
;; global options: +cmd
;; connection timed out; no servers could be reached
NOTE: This will prevent many functions that you take for granted, such as apt-get update && apt-get upgrade
. Whenever you need to do updates, simply sudo ufw default allow outgoing
and perform the actions you need, and then sudo ufw default deny outgoing
again. This also does not work if your application needs to make regular outbound connections, via http/dns/smtp/etc. I will cover that option in another post.
With all this work that we’re doing to setup hidden services, it would really suck if we ssh’d into our machine and it just told anyone who could see our screen what the real IP was. There are a few ways to prevent this information from popping up in the ubuntu motd.
This is a minor detail and might not be that important, but it reduces the chances of someone seeing the actual IP address of the server inadvertently. Of course if someone has access to our machine once we’re logged in, they can simply run ip addr
and figure it out that way, this is just meant to prevent accidental leakage.
The system information that is run to generate the MOTD comes from a process called landscape-sysinfo
, and is called from /etc/update-motd.d/50-landscape-sysinfo
. We can modify this file to exclude the network information when landscape-sysinfo
is run.
user@remote:~$ sudo nano /etc/update-motd.d/50-landscape-sysinfo
[...]
/usr/bin/landscape-sysinfo --exclude-sysinfo-plugins=Network
Go ahead and save that file. While we’re editing the MOTD, let’s also remove the ubuntu help text, as we’ll probably never want to click on it and it just takes up a bunch of space.
user@remote:~$ sudo rm /etc/update-motd.d/10-help-text
Now go ahead and log out, then log back in, and we’ll see that the network information and help text is no longer included.
user@local:~$ ssh myonion
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-66-generic x86_64)
System information as of Tue Nov 19 06:05:11 UTC 2019
System load: 0.0 Memory usage: 16% Processes: 86
Usage of /: 4.9% of 24.06GB Swap usage: 0% Users logged in: 0
34 packages can be updated.
19 updates are security updates.
Last login: Tue Nov 19 06:01:14 2019 from 127.0.0.1
user@remote:~$
Alternatively, we can use the hushlogin
method to silence the login entirely. Simply create a file named .hushlogin
in our home directory on the remote server in order to hide the MOTD entirely. If you don’t care about seeing system load, disk usage, etc, on login then this works great.
user@remote:~$ touch ~/.hushlogin
user@remote:~$ exit
user@local:~$ ssh myonion
user@remote:~$
Now that we’ve got administrative access to our machine locked down, let’s set up our web server. Before we start changing anything, let’s just take a look at the default headers that nginx is returning.
user@remote:~$ curl -I localhost
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 19 Nov 2019 06:16:01 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Mon, 18 Nov 2019 22:54:28 GMT
Connection: keep-alive
ETag: "5dd32124-264"
Accept-Ranges: bytes
As you may see, our web server is currently serving up it’s exact version and that we’re running Ubuntu, via the Server
header. Turning this off is easy, though!
user@remote:~$ sudo nano /etc/nginx/nginx.conf
[...]
server_tokens off;
user@remote:~$ sudo systemctl restart nginx
user@remote:~$ curl -I localhost
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 19 Nov 2019 06:22:25 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Mon, 18 Nov 2019 22:54:28 GMT
Connection: keep-alive
ETag: "5dd32124-264"
Accept-Ranges: bytes
You can see now that the Server
header no longer reveals the host operating system OR the version number. We could take it one step further and provide a custom Server
header value as a means of misdirection by using the ngx_headers_more module, however that is beyond the scope of this walkthrough.
You’ll also see that the server tokens do not appear in the default nginx error pages, either:
user@remote:~$ curl localhost/notfound
<html>
<head><title>404 Not Found</title></head>
<body bgcolor="white">
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
This won’t matter as much as long as you’re not also serving up content on the broader internet, but by configuring nginx with size restrictions for its various buffers, we can help ward off DoS attacks that try to exhaust the server resources by sending very large requests. Just put these lines in the http
block of the nginx.conf
file.
user@remote:~$ sudo nano /etc/nginx/nginx.conf
[...]
client_body_buffer_size 1k;
client_header_buffer_size 1k;
client_max_body_size 1k;
large_client_header_buffers 2 1k;
While you’re in there, let’s also set some universal HTTP headers. These will make it harder to accidentally do the wrong things on your hidden service (and on any website, really). More information can be found here:
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self'";
add_header X-Frame-Options deny;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
We might be tempted, and some guides might even encourage us, to use the nginx default
config for our hidden service. This is bad and we should not do it. Even though our firewall rules deny all incoming packets, that doesn’t give us an excuse to get lazy with our configurations. Let’s delete the default that nginx ships with and create our own very basic default.
user@remote:~$ sudo rm /etc/nginx/sites-available/default
user@remote:~$ sudo mkdir /var/www/default && sudo touch /var/www/default/index.html
user@remote:~$ sudo nano /etc/nginx/sites-available/default
server {
listen 127.0.0.1:80 default_server;
root /var/www/default;
index index.html;
server_name _;
}
Now let’s test the nginx config to ensure everything is valid.
user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Now our default config is as minimal as possible, but it will safely (in that it won’t leak our hidden service content) handle any errant requests that don’t hit our onion address but still somehow make it to us.
Now it’s finally time to start setting up the Hidden Service for our website. I know, it’s been forever. You might have even dozed off along the way, or maybe forgot what our goal was in the first place. But I assure you that we’re almost there. The finish line is near.
First, we need to add another HiddenServiceDir
to our tor configuration.
user@remote:~$ sudo nano /etc/tor/torrc
[...]
HiddenServiceDir /var/lib/tor/hidden_http/
HiddenServiceVersion 3
HiddenServicePort 80 127.0.0.1:80
Now that this is done, we need to restart tor. When we do this, our ssh connection will disconnect because the tor service restarts and new circuits are generated. So make sure you didn’t have a typo in your most recent changes.
user@remote:~$ sudo systemctl restart tor
user@local:~$ ssh myonion
I hope you’re still with me. These are high stakes games we’re playing.
It’s FINALLY time to setup our onion service. For this example, we’re going to stick to a simple HTML application without a dynamic backend, however I will cover safely setting up various backends in other guides at a later date.
First, let’s prepare some content for our new super secret site. For this example, I’m just going to echo some content into the index file. You could scp
content from your local machine instead, if you’d like.
user@remote:~$ sudo mkdir /var/www/onion
user@remote:~$ sudo echo "Super Secret Hidden Service" >> /var/www/onion/index.html
And now we tell nginx about our new site, which we’ll call onion
. Once we’ve added the server block for onion
, we need to create a symlink between sites-available
and sites-enabled
so that there’s only one version of the file - the authoritative version. This method is superior to copying the file, as we can’t accidentally forget to copy the new config before restarting the server.
user@remote:~$ sudo nano /etc/nginx/sites-available/onion
server {
listen 127.0.0.1:80;
root /var/www/onion;
index index.html;
server_name </var/lib/tor/hidden_http/hostname>;
}
user@remote:~$ sudo ln -s /etc/nginx/sites-available/onion /etc/nginx/sites-enabled/onion
Let’s test the nginx config again to ensure everything is still valid.
user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
If the test isn’t successful and you got an error message like this:
user@remote:~$ sudo nginx -t
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf test failed
Then you need to edit /etc/nginx/nginx.conf
and uncomment the server_names_hash_bucket_size
line and set the value to 128
.
user@remote:~$ sudo nano /etc/nginx/nginx.conf
[...]
server_names_hash_bucket_size: 128;
Now let’s try that nginx test again.
user@remote:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
If everything is successful, it’s time to test our new configuration.
user@remote:~$ sudo systemctl restart nginx
user@remote:~$ curl -v localhost
curl -v localhost
* Rebuilt URL to: localhost/
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx
< Date: Tue, 19 Nov 2019 07:23:42 GMT
< Content-Type: text/html
< Content-Length: 0
< Last-Modified: Tue, 19 Nov 2019 07:16:21 GMT
< Connection: keep-alive
< ETag: "5dd396c5-0"
< X-Frame-Options: SAMEORIGIN
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Accept-Ranges: bytes
<
* Connection #0 to host localhost left intact
user@remote:~$
Notice that no content was returned from the call to localhost. That tells us that it’s reading from /var/www/default/index.html
which we made an empty file earlier. Any requests to the nginx server that don’t have the Host
header set to our onion address will get directed here, which won’t leak any information about the hidden service.
We can also test our onion configuration from the server:
user@remote:~$ torify curl -v </var/lib/tor/hidden_http/hostname>
Super Secret Hidden Service
Back on our local machine, let’s test our connection to the tor http service, just to be sure.
user@local:~$ torify curl </var/lib/tor/hidden_http/hostname>
Super Secret Hidden Service
Note: Great success.
At this point, you should be able to give out your http onion address without fear that someone will find the hosting server. You can also rest assured that your ssh access logs won’t be flooded with bad attempts to connect, because you’re the only one who knows the address, and it requires Tor ClientAuthorization in order to even make the connection.
I would like to write additional guides on making sure that your applications aren’t leaking your IP address, setting up your outbound email from your application to go through tor, setting up tor relays, setting up tor bridges, setting up a tor IRC network, etc. Prior to working on those, I plan to create a video companion for this guide.
I hope you’ve learned something along the way, and if you have any questions, concerns, or recommendations, please @ me or shoot me a DM on any of my social platforms.