| |
E-mail
Sign In
For this project I’m doing I needed to write custom authentication within the SMTP protocol. I needed to write my own AUTH sink. The goal of the sink is to authenticate to a different user-base and to give the authenticated users the right to relay through this server.
This seems quite simple if you have ever written a protocol sink for IIS SMTP or Exchange, but nothing seemed more true. You cannot simply create a protocol sink and bind it to the AUTH keyword, though that is the first step.
For clarification of the process I will now first show the steps in the SMTP protocol for basic authentication as implemented through the AUTH LOGIN command.
S: 220 mymail.local Microsoft ESMTP MAIL ServiceC: EHLO mydomain.comS: 250-mydomain.com Hello [127.0.0.1]S: 250-AUTH=LOGINS: 250-AUTH LOGINS: 250-…S: 250 OKC: AUTH LOGINS: 334 VXNlcm5hbWU6C: dGVzdA==S: 334 UGFzc3dvcmQ6C: cGFzcw==S: 235 2.7.0 Authentication successfulC: ...
As you can see in the example above, the authorization is split-up into three separate steps. For every step you need to be in control over the input and the output. But as you can only bind your sink to the AUTH command, you will not receive the remaining two input lines containing the credentials.
To overcome this problem, you have to setup your sink to receive all the input in that SMTP session, starting from the input after the AUTH LOGIN command until the credentials are passed. This has to be done from within the sink at session level and cannot be done by binding the sink another way.
Remark: From this point on there is absolutely no documentation at Microsoft that can help you find your way. Some information in this post was scraped together from the internet (references posted at the end) and the rest is from trying over and over again (with the help from an escalation engineer at Microsoft, as I’m in the lucky position to have a Microsoft Premium Support account).
To set the sink to listen to all coming input you have to use the SetCallback method of the context parameter. As parameter you have to pass an instance of an ISmtpInCallbackSink implementation.
The example below shows the first part:
private const string EOL = " [sink]\r\n"; private string blobText = null; private string credUser = null; private string credPass = null; void ISmtpInCommandSink.OnSmtpInCommand(object _server, object _session, MailMsg _msg, ISmtpInCommandContext _context) { { MailMsgPropertyBag session = null; SmtpInCommandContext context = null; try { //get parameter wrappers session = new MailMsgPropertyBag(_session); context = new SmtpInCommandContext(_context); //check for which command this event was fired string command = context.CommandKeyword.ToUpper(); if (command.Equals("RCPT")) { ... } else if (command.Equals("AUTH")) { if (context.Command.ToUpper().Equals("AUTH LOGIN")) { //clear buffers blobText = ""; credUser = ""; credPass = ""; //set to receive next line context.CommandStatus = (uint)ProtocolEventConstants.EXPE_BLOB_READY; context.SetCallback(this); context.Response = "334 VXNlcm5hbWU6" + EOL; context.SmtpStatusCode = 334; context.ProtocolErrorFlag = false; //to return a value other than S_OK, throw a COMException. throw new COMException("Event is consumed", ProtocolEventConstants.EXPE_S_CONSUMED); } } } finally { //release COM objects if (_server != null) Marshal.ReleaseComObject(_server); if (_session != null) Marshal.ReleaseComObject(_session); if (_msg != null) Marshal.ReleaseComObject(_msg); if (_context != null) Marshal.ReleaseComObject(_context); } }
As you can see in the code above, at least four actions have to be taken in order to make it work.1. Response has to be formed2. CommandStatus has to be set3. Callback has to be set4. The event has to be consumed
The first step is quite easy , just fill in the parameters of the context. This has to be done in order to let the client know that you have accepted the AUTH LOGIN command.The second step is necessary to let the SMTP server know that you will be expecting the input stream.In the third step you’ll set the callback for when input has been received. The callback is called when new input arrives at the server.And finally in the fourth step you will consume the entire event. The AUTH LOGIN event that is, not the next lines with the credentials. If you do not consume the event, the event is eventually passed to the normal AUTH handler which will take over the controls again leaving you with empty hands. So, simply consume it. This however comes with a penalty that I’ll discuss later on.
The implementation of the ISmtpInCallbackSink is pretty straight forward as it takes the same parameters as the other protocol sinks.
void ISmtpInCallbackSink.OnSmtpInCallback(object _server, object _session, MailMsg _msg, ISmtpInCallbackContext _context) { //define ref-pointer for the blob IntPtr blobPtrPtr = IntPtr.Zero; try { //allocate memory for the ref-pointer blobPtrPtr = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(blobPtrPtr, IntPtr.Zero); //get the ref-pointer uint blobSize = 0; _context.QueryBlob(blobPtrPtr, ref blobSize); //de-reference the ref-pointer to get the blob-pointer IntPtr blobPtr = Marshal.ReadIntPtr(blobPtrPtr); //read the text from the blob string text = Marshal.PtrToStringAnsi(blobPtr, (int)blobSize); //add text to buffer blobText += text; //check end-of-input if (blobText.EndsWith("\r\n")) { //remove CRLF blobText = blobText.Substring(0, blobText.Length-2); //decode base64 string decoded = ""; try { decoded = Encoding.ASCII.GetString(Convert.FromBase64String(blobText)); } catch (Exception excpt) { //clear buffers blobText = ""; credUser = ""; credPass = ""; //respond to user string response = "501 invalid input" + EOL; _context.SetResponse(response, (uint)response.Length); _context.SetSmtpStatusCode(501); _context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_DONE | (uint)ProtocolEventConstants.EXPE_COMPLETE_FAILURE); return; } //clear blob buffer blobText = ""; //check if username was already passed if (string.IsNullOrEmpty(credUser)) { //store username credUser = decoded; //respond to user string response = "334 UGFzc3dvcmQ6" + EOL; _context.SetResponse(response, (uint)response.Length); _context.SetSmtpStatusCode(334); _context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_READY); } else { //store password credPass = decoded; //check credentials If (credUser.Equals(“test”) && credPass.Equals(“pass”)) { //respond to user string response = "235 2.7.0 Authentication successful" + EOL; _context.SetResponse(response, (uint)response.Length); _context.SetSmtpStatusCode(235); _context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_DONE); } else { //clear buffers blobText = ""; credUser = ""; credPass = ""; //tarpit the response Thread.Sleep(10 * 1000); //sleep 10 seconds //respond to user string response = "535 5.7.8 Authentication credentials invalid" + EOL; _context.SetResponse(response, (uint)response.Length); _context.SetSmtpStatusCode(535); _context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_DONE | (uint)ProtocolEventConstants.EXPE_COMPLETE_FAILURE); } } } else { _context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_READY); } } finally { //free the allocated blob space if (!blobPtrPtr.Equals(IntPtr.Zero)) Marshal.FreeHGlobal(blobPtrPtr); //release COM objects if (_server != null) Marshal.ReleaseComObject(_server); if (_session != null) Marshal.ReleaseComObject(_session); if (_msg != null) Marshal.ReleaseComObject(_msg); if (_context != null) Marshal.ReleaseComObject(_context); } }
Ok, so what’s happening here?The first thing that is done, is to get access to the input. The input comes in blobs. You have to query to get a pointer to such a blob. The blob contains the actual bytes passed by the client to the server. There is no need to allocate memory for the blob itself as the pointer provided points directly in to allocated memory by IIS. When you’re done and moving on to the next line of input, IIS frees the memory itself.
When you look closely, you’ll notice that the blobs can contain parts of the whole input and not necessarily the complete line. This is due to the client who might send the line in parts to the server. A good example being the Windows telnet clients, who sends the line byte by byte.
Once that all credential lines are passed, you can check the authorization. If the credentials passed are bad or the client has not enough rights, you’ll have to respond to the client with an permanent failure. Otherwise you can respond to the client with a success response. Either way, you’ll also have to signal the SMTP server that you’re ready receiving the input and that the control is passed back to the server.
This is done by simply OR-ing the CommandStatus. This is also not documented but the CommandStatus field is a bit-field, where you can multiplex different values.
_context.SetCommandStatus((uint)ProtocolEventConstants.EXPE_BLOB_DONE | (uint)ProtocolEventConstants.EXPE_COMPLETE_FAILURE);
Why is there no multiplexed CommandStatus for the success scenario? This is because the EXPE_SUCCESS is an uint with a value of 0. As you could OR it for the sake of read-ability of your code, it won’t actually do anything.
The list of statuses and their values that I know about are these:EXPE_SUCCESS = 0x00000000EXPE_NOT_PIPELINED = 0x00000000EXPE_PIPELINED = 0x00000001EXPE_REPEAT_COMMAND = 0x00000002EXPE_BLOB_READY = 0x00000004EXPE_BLOB_DONE = 0x00000008EXPE_DROP_SESSION = 0x00010000EXPE_CHANGE_STATE = 0x00020000EXPE_TRANSIENT_FAILURE = 0x00040000EXPE_COMPLETE_FAILURE = 0x00080000
Also note that when a failure occurs the response is tarpitted. Tarpitting is a form of protection against harvesting of your credentials. When a malicious user writes a script that tries to “guess” thousands of credentials within seconds, you can now slow him down to one credential per 10 seconds. This would take him up to 2.78 hours for only 1000 credentials.Please also see my post on Registry settings for IIS SMTP and Exchange, because there is also a general tarpitting setting.http://blog.rednael.com/2008/08/08/IISSMTPAndExchangeRegistrySettings.aspx
Well, here you have your custom AUTH sink. You’re done, the work is finished, time for a break. But as you’re heading for the coffee-machine, you here your test application making all these Error noises. Because, as you will soon find out, your success response on the AUTH sequence is not understood by the SMTP server and you are still unable to relay any message.
As stated in the beginning of this document, the goal is to make a custom authentication sink, which enables me to relay messages for those who have logged on successfully. You know the setting “Allow all computers which successfully authenticate to relay, regardless of the list above”...
What to do? Well, this is where I got stuck too. I’ve tried dumping all server and session properties to screen in order to find that one property that says “I AM AUTHENTICATED”, but no such luck.But, as normally, the answer is often a lot simpler than you first think.If you’re unable to tell the server who may relay, tell the server who may NOT relay. In other words, turn it around. Set the server to relay for everyone and use an RCPT sink to disable relaying for those who are not authenticated!
Sounds simple and it relatively is. There’s only one catch, although you can bind the same sink to multiple events (different filters like MAIL, RCPT, etc.), the instance of your sink is not the same for those separate events. Not even within the same session. So, this disables you to create a shared property within your class which you can use to pass information from one event to another one.
This problem can be overcome by using the session’s property bag. You can set numbered properties with various types (bool, string, etc…).
private const uint SESSION_PROPIDX_AUTHSUCCESS = 7; //set custom session property MailMsgPropertyBag session = new MailMsgPropertyBag(_session); session.PutBool(SESSION_PROPIDX_AUTHSUCCESS, true); //get custom session property bool ses_authenticated = false; try{ ses_authenticated = session.GetBool(SESSION_PROPIDX_AUTHSUCCESS); }catch (Exception excpt){;}
What you’re seeing here is first how to set the property and second hot to read it. Setting the property is done at the during the AUTH event. Reading the property is done at handling the RCPT event.Note that when reading a property that has not been set, an exception is thrown. Hence the try-catch.Please also note the constant that is defined to a value of 7. Why 7 do you ask? I don’t know actually. When dumping session properties to screen I found that property 7 was never set. This doesn’t mean that it’s never set. It may be set to another value in some special cases or in other versions of the SMTP server. Again there is no information available saying which property is what.So, if 7 doesn’t work for you, try a higher one. I think the max is 20.000, so you would expect there to be at least one free property.
Below you’ll find an example of the RCPT event part
private static Regex rexAddress = new Regex(@"[a-z0-9]{1,}(?:(?:\.|-|_)[a-z0-9]{1,}){0,}@[a-z0-9]{1,}(?:(?:\.|-|_)[a-z0-9]{1,}){0,}\.[a-z0-9]{1,}", RegexOptions.IgnoreCase); ... //check for which command this event was fired string command = context.CommandKeyword.ToUpper(); if (command.Equals("RCPT")) { //check input Match rexMatch = rexAddress.Match(context.Command); if (rexMatch.Success) { if (rexMatch.Value.EndsWith(“@mydomain.com”)) { //vmb found, address supported context.Response = "250 2.1.5 " + rexMatch.Value + EOL; context.SmtpStatusCode = 250; context.CommandStatus = (uint)ProtocolEventConstants.EXPE_SUCCESS; // is 0 context.ProtocolErrorFlag = false; } else if (ses_authenticated) { //vmb notfound, relay-address supported context.Response = "250 2.1.5 relaying to " + rexMatch.Value + EOL; context.SmtpStatusCode = 250; context.CommandStatus = (uint)ProtocolEventConstants.EXPE_SUCCESS; // is 0 context.ProtocolErrorFlag = false; } else { //vbm not found, address unsupported context.Response = "550 5.7.1 Unable to relay for " + rexMatch.Value + EOL; context.SmtpStatusCode = 550; context.CommandStatus = (uint)ProtocolEventConstants.EXPE_COMPLETE_FAILURE; context.ProtocolErrorFlag = true; //to return a value other than S_OK, throw a COMException. throw new COMException("Event is consumed", ProtocolEventConstants.EXPE_S_CONSUMED); } } else { //no address found in command context.Response = "501 5.5.4 Invalid Address" + EOL; context.SmtpStatusCode = 501; context.CommandStatus = (uint)ProtocolEventConstants.EXPE_COMPLETE_FAILURE; context.ProtocolErrorFlag = true; //to return a value other than S_OK, throw a COMException. throw new COMException("Event is consumed", ProtocolEventConstants.EXPE_S_CONSUMED); } } else if (command.Equals("AUTH")) { ...
So, and this is the time where you can go and relax now. Let your test application do its work while you’re enjoying a well deserved cup-a-coffee at your well deserved coffee-break.
References:
RFC of the AUTH scheme:http://tools.ietf.org/html/rfc4954
RFC of SMTP:http://www.rfc.net/rfc2821.html
A question and some great findings by Jason S. Clary:http://www.tech-archive.net/Archive/Exchange/microsoft.public.exchange.development/2006-05/msg00111.html
Files:MailMsgPropertyBagWrapper.cs.txt (13.23 KB)Message.cs.txt (38.35 KB)SmtpInCommandContextWrapper.cs.txt (7.65 KB)SmtpOutCommandContextWrapper.cs.txt (4.45 KB)SmtpServerResponseContextWrapper.cs.txt (5.46 KB)Wrappers.zip (58.14 KB)
Remember Me
i, u