|
|||||||||||||||||||||
|
|||||||||||||||||||||
|
Announcements
Want a new Job?
Chapters
Services
Feature Zones
|
IntroductionWhile VNC is a great, cross-platform remote desktop protocol, it is inherently insecure, and relies on the system administrator that installs it to tunnel it through another secure communication protocol if the VNC server is to be publicly accessible. The two most common ways to do this are to tunnel VNC data through an SSL or an SSH connection. There's a cross-platform GUI application called ssvnc that handles setting up an SSL or SSH tunnel automatically prior to launching the VNC viewer process but, frankly, it has a number of shortcomings that led me to develop my own secure VNC viewer application. First, ssvnc is kind of clunky, and lacks a professional polish; it spawns several other windows to handle the secure connection process, and doesn't provide a unified interface. Secondly, it's not implementation agnostic: it contains inputs for custom parameters for several of the major VNC implementations, but a better approach would be to allow the user to simply specify the viewer application that they wish to run and the command line parameters to use with that application. So, these frustrations led me to develop my own secure VNC viewer application. BackgroundYou might be wondering, why bother with VNC? RDP (Remote Desktop Protocol, used by Microsoft in Terminal Services) comes standard with Windows, right? Well, not really: if you're still running Windows 2000 Professional, like me, then VNC is really the only choice you have for remote desktop management since Terminal Server isn't supported on Windows 2000 Professional. There are pros and cons to both protocols and, I'll be honest, I definitely prefer RDP when possible, but I'm forced to use VNC for my home machine. VNC is, at its core, an extremely simple protocol: it's basically a remote frame buffer, so when something changes on the remote desktop, a rectangle of image data containing the changed area is sent to the client which updates its own display. This means that the protocol is inherently platform-agnostic: all you need to do is be able to take snapshots of parts of the desktop and send them to a remote client. However, this simplicity also means that the protocol is not very robust: there is no support for encryption (several implementations, such as UltraVNC, bolt this functionality onto the side, but I have been less than impressed with the results), and things like remote sounds, printer sharing, port sharing, etc. have to be carried out by separate applications. With regards to security, it's generally accepted that the best approach is to "tunnel" VNC data over other ubiquitous, secure protocols such as SSH or SSL and, thanks to the Cygwin platform, this is easily done in Windows. Using SSH as an example, here's how it works: I have an SSH server running on port 22 on my home machine and, in order to tunnel data over it, I establish a connection to it from a remote machine. However, I also specify several command line options for the SSH client process to enable local port forwarding: this means that the SSH client process will start listening on a local port and, when it receives data, it will forward it over the encrypted SSH connection and establish a connection to a local port on the remote server where it delivers the data. So, once this tunnel is set up, you instruct your VNC client to connect to the local port that the SSH client is listening on, which will receive the data, encrypt it, transmit it over the SSH connection, decrypt it, and deliver it to the port being listened to by the VNC server process on the remote machine. While this approach is great because we effectively get security for VNC for free, it also means we also have to invoke another program when trying to establish a VNC connection. Automating this process is where my application comes in. Implementation detailsConfigurationOne of my favorite improvements in .NET 2.0 is the dramatically improved configuration classes: <?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="profiles"
type="SecureVNCViewer.ProfileInformation, SecureVNCViewer"/>
</configSections>
<profiles>
<profile name="Home" remoteHost="somewhere.somehost.com"
viewerProgram="C:\Internet Applications\TightVNC\vncviewer.exe"
arguments="-compresslevel 9 -quality 6 {H}::{P}"
connectionMode="SSH">
<parameters>
<parameter name="privatekeyfile"
value="C:\Cygwin\home\furiousgeorge\.ssh\id_rsa.ppk" />
<parameter name="username" value="furiousgeorge" />
</parameters>
</profile>
</profiles>
</configuration>
All of the data contained therein is referenced through classes that inherit from public class ProfileInformation : ConfigurationSection
{
/// <summary>
/// Collection of profiles that have been saved.
/// </summary>
[ConfigurationProperty("", IsDefaultCollection = true)]
public ProfileCollection Profiles
{
get
{
return (ProfileCollection)this[""];
}
set
{
this[""] = value;
}
}
}
By flagging the SSH tunnelingThis application can set up two types of tunnels, SSH or SSL, both of which are established in very different ways. The first tunnel type is SSH, where we rely on an excellent C# library for SSH connections called SharpSSH. The code that establishes the SSH connection is contained within the // Set up the SSH tunnel
if (sshRadioButton.Checked)
{
// Find an unused port on the local machine that
// we can setup the local listener on
connectionPort = FindUnusedLocalPort();
connectionHost = "localhost";
string username = "";
// If the user specified a username in the SSH options, provide that
if (parameters.ContainsKey("username"))
username = parameters["username"];
// Otherwise, show a prompt window allowing the user to enter their username
else
{
UsernamePrompt usernamePrompt = new UsernamePrompt();
DialogResult result = usernamePrompt.ShowDialog();
if (result == DialogResult.Cancel)
{
usernamePrompt.Dispose();
return;
}
username = usernamePrompt.Username;
usernamePrompt.Dispose();
}
// Create the SSH object
JSch ssh = new JSch();
// Add trusted hosts from the config file so that
// the user isn't prompted to accept them
HostKeyRepository knownHosts = ssh.getHostKeyRepository();
foreach (KnownHost knownHost in knownHostInformation.KnownHosts)
knownHosts.add(knownHost.Host, Convert.FromBase64String(knownHost.Key),
this);
ssh.setHostKeyRepository(knownHosts);
// Add the private key file to the connection if it was specified by the user
if (parameters.ContainsKey("privatekeyfile"))
ssh.addIdentity(parameters["privatekeyfile"]);
sshSession = ssh.getSession(username, remoteHost);
sshSession.setUserInfo(this);
// Update the status bar to show that we're connecting to the remote host
statusLabel.Text = "Connecting to remote host...";
Application.DoEvents();
// Establish the connection
try
{
sshSession.connect();
}
catch (JSchException exception)
{
// Set the error message variable appropriately
// if we can extract it from the exception
Match match = Regex.Match(exception.Message,
"^(?<source>[^\\:]+): (?<message>[^\r\n]+)\r\n");
if (match.Success)
errorMessage = match.Groups["message"].Value;
}
connected = sshSession.isConnected();
// Setup port forwarding
if (connected)
{
// Run the initial command, if one was specified
if (parameters["initialcommand"] != null)
{
// Open a shell channel on the current connection
ChannelShell shell = (ChannelShell)sshSession.openChannel("shell");
shell.setOutputStream(null);
// I have no idea what MENSA candidate coded JSCH this way, but
// apparently shell.getOutputStream() is the stream that actually
// supplies input to the shell
StreamWriter shellInputWriter = new StreamWriter(shell.getOutputStream(),
Encoding.ASCII);
// Write the command to the shell's input stream
shell.connect();
shellInputWriter.NewLine = "\n";
shellInputWriter.AutoFlush = true;
shellInputWriter.WriteLine(parameters["initialcommand"] +
"; exit");
// Wait for the command to execute and then close the channel
while (!shell.isClosed())
Thread.Sleep(500);
shell.disconnect();
}
sshSession.setPortForwardingL(connectionPort, "127.0.0.1", remotePort);
}
}
The first thing that we do is find an unused local port to act as the local endpoint in our SSH tunnel for the VNC data. This is accomplished by simply starting a protected static int FindUnusedLocalPort()
{
TcpListener portChooser = new TcpListener(IPAddress.Loopback, 0);
int connectionPort;
portChooser.Start();
connectionPort = ((IPEndPoint)portChooser.LocalEndpoint).Port;
portChooser.Stop();
return connectionPort;
}
We then set various properties of the SSH object needed to establish the connection, such as the username (collected via a prompt, or set to the value specified in the SSH Options dialog) and the private key file, if one was specified in the Options dialog. Also, set are the various trusted hosts: when an SSH server is connected to for the first time, the user is presented with a prompt displaying the hostname of the server to which they are connecting and a fingerprint of its key. If they choose to trust this combination, then it is stored in the config file so that the next time the user connects to that host, provided that it's still using the same key, they will not receive a trust prompt. Finally, we call the SSL tunnelingIf the user elects to tunnel their VNC data through SSL, then all of the connection and port-forwarding logic is handled in our code instead of relying on external executables. To do this, we rely on the .NET built-in class, // Set up the SSL tunnel
else if (sslRadioButton.Checked)
{
// Set up a connection to the remote SSL server and set up a local listener
// whose job it is to forward data it receives to the SSL connection and
// vice versa
encryptedClient = new TcpClient(remoteHost, remotePort);
encryptedStream =
new SslStream(encryptedClient.GetStream(), false,
new RemoteCertificateValidationCallback(ValidateServerCertificate),
new LocalCertificateSelectionCallback(SelectClientCertificate));
unencryptedListener = new TcpListener(IPAddress.Loopback, 0);
X509CertificateCollection clientCertificates =
new X509CertificateCollection();
// Add a client certificate to the authentication request
// if the user has specified one
if (parameters.ContainsKey("clientCertificateFingerprint"))
clientCertificates.Add(
GetCertificate(parameters["clientCertificateFingerprint"]));
// Try to establish a connection to the remote SSL server
try
{
encryptedStream.AuthenticateAsClient(remoteHost, clientCertificates,
SslProtocols.Ssl3, true);
unencryptedListener.Start();
unencryptedListener.BeginAcceptTcpClient(
new AsyncCallback(TunnelSSLData), this);
connectionPort = ((IPEndPoint)unencryptedListener.LocalEndpoint).Port;
connectionHost = "localhost";
connected = true;
}
// Catch and display IOExceptions that occur, such as not being able to
// connect to the remote system
catch (IOException exception)
{
errorMessage = (exception.InnerException != null ?
exception.InnerException.Message : exception.Message);
}
// Catch an AuthenticationException if it's thrown; if its message
// indicates that the remote certificate didn't pass muster, then don't
// display anything else since we already displayed the certificate's
// details and asked the user if they wanted to trust it (any other
// messages are fair game and should be displayed)
catch (AuthenticationException exception)
{
if (exception.Message != "The remote certificate is invalid according "
+ "to the validation procedure.")
errorMessage = (exception.InnerException != null ?
exception.InnerException.Message : exception.Message);
encryptedClient.Close();
}
}
The first thing that we do is establish a generic private void TunnelSSLData(IAsyncResult result)
{
TcpClient unencryptedClient =
unencryptedListener.EndAcceptTcpClient(result);
// Kick off the initial read operations on the streams
unencryptedStream = unencryptedClient.GetStream();
unencryptedStream.BeginRead(unencryptedBuffer, 0, unencryptedBuffer.Length,
new AsyncCallback(ForwardUnencryptedData),
this);
encryptedStream.BeginRead(encryptedBuffer, 0, encryptedBuffer.Length,
new AsyncCallback(ForwardEncryptedData), this);
}
As you can see, it's very simple: it gets the stream for the newly-established client connection, and then kicks off asynchronous, self-sustaining read operations on both the unencrypted stream (the new client stream) and the encrypted stream (the previously-established SSL stream). I say self-sustaining because the callback functions for both of these operations kick off another asynchronous read operation once they complete the current operation. The code is pretty much identical for both functions, just with the names of the read and write streams reversed: private void ForwardEncryptedData(IAsyncResult result)
{
int bytesRead = encryptedStream.EndRead(result);
// Only proceed if we actually read data from the stream
if (bytesRead > 0)
{
// Forward the data to the local listener
unencryptedStream.Write(encryptedBuffer, 0, bytesRead);
// Begin the next asynchronous read operation; we wrap this in a
// try/catch block for ObjectDisposedException because that's what will
// be thrown if the VNC client exits and we kill the local listener and
// remote SSL client (a perfectly legitimate operation)
try
{
encryptedStream.BeginRead(encryptedBuffer, 0, encryptedBuffer.Length,
new AsyncCallback(ForwardEncryptedData),
this);
}
catch (ObjectDisposedException exception)
{
string warningTrap = exception.Message;
}
}
}
So, it just takes the data that was read, and forwards it to the opposite stream, and then starts another asynchronous read. It traps the new asynchronous read call in a Running the viewer applicationOnce the SSH or SSL tunnel has been established, actually running the viewer application is simple: // We're connected, so start up the VNC viewer process
if (connected)
{
// Update the status bar to indicate that we're connected
// to the remote host
statusLabel.Text = "Connected";
Application.DoEvents();
// Start the viewer process, replacing {H} with the host we're supposed to
// connect to (localhost for tunneling situations, the remote host
// otherwise) and {P} with the port we're supposed to connect to (a random
// local port for tunneling situations, the remote VNC port otherwise) in
// the program arguments specified
ProcessStartInfo vncViewerStartInfo =
new ProcessStartInfo(viewerProgramTextBox.Text,
argumentsTextBox.Text.Replace("{H}", connectionHost).Replace("{P}",
connectionPort.ToString()));
vncViewerStartInfo.UseShellExecute = false;
Process vncViewerProcess = Process.Start(vncViewerStartInfo);
// Hide the program window and wait for the viewer process to exit
Hide();
vncViewerProcess.WaitForExit();
}
We just start the application at the path that the user specified, and substitute strings in the command-line arguments that they provided: we substitute Server configuration and usageSo, these new security options for VNC sound great, but you aren't sure how to configure your server for them? Well, thankfully, it's pretty easy. First, you have to decide which protocol you want to tunnel your VNC data through. Personally, I prefer SSH because you get authentication for free on a Windows machine because of the fact that, when providing a username and password, it hooks into the NT authentication framework. This means that you can tie VNC authentication to NT and turn off the VNC password prompt. To use SSH, you can download and set up the SSH server that's part of the Cygwin package. There's an excellent guide on Cygwin sshd setup here. If you choose to go the SSL route, the best option for SSL tunneling is to use the aptly-named Stunnel. Once you have Stunnel installed, you can add an entry to the stunnel.conf file like this: [svnc]
accept = 5900
connect = 5901
This tells Stunnel to listen on port 5900 (the :0 display port for VNC) and forward data it receives to port 5901. You'll then have to configure the VNC server to listen on port 5901. Once the tunnel configuration is done, all that remains is to set a few configuration options for the VNC server. These instructions are for TightVNC, which is the implementation that I use, but they should be fairly similar for other implementations. First, double-click the VNC icon in your system tray, and then click the "Advanced" button. Check the "Allow loopback connections" checkbox and the "Allow only loopback connections" checkbox. This ensures that VNC can now only be accessed by a process running on the local machine (i.e., the tunneling process). If you're going with SSH, you can also set the VNC password to nothing, which will disable the VNC password prompt. Once this configuration is done, open the necessary ports in your firewall and give it a shot! When specifying arguments for the viewer application, make sure that History
| ||||||||||||||||||||