The ask:

Ensure that the front end logs from the Azure Application Proxies are flowing into the SIEM via Windows Event Forwarding (WEF).

The useful connection events were in a log WEF couldn’t subscribe to. We ended up listening to the underlying ETW provider and writing selected events into a log WEF could collect.

Azure AD Application Proxy is now Microsoft Entra application proxy. The Microsoft-AadApplicationProxy-Connector name below is the actual provider name used in this example, so it hasn’t been renamed along with the prose.

Azure Application Proxy Overview

Application Proxy gives users remote access to an internal web application through a published URL. A connector inside the network carries traffic to the application; the connector’s request logs are what we’re interested in here.

Overview of Azure App Proxy

Microsoft’s current overview is Microsoft Entra application proxy.

Azure Application Proxy Logging

The Azure Application Proxy has two main logs that are helpful for administrators and security teams:

  1. The admin log (standard .EVTX format)
  2. The session log (analytic and debug .ETL format)

Azure App Proxy Logs

The second log, “Session”, is very useful for security teams.

The events 24028, 24029, 24030 detail the actual connections being made to the App Proxy connector instance, and are very similar to what you might expect if you were monitoring a web server.

Azure App Proxy Detailed Log Example

The Challenge

The challenge with the Session log is that it is an .ETL log, rather than .EVTX like the Admin log.

The Admin Log: The Admin Log (.evtx)

The Session Log: The Session Log (.etl)

I was aware that “Analytic and Debug” .etl logs were not designed to be forwarded using Windows Event Forwarding, but because we really needed this information in our SIEM we decided to ask Microsoft to be sure:

Question: Can we use Windows Event Forwarding with “Analytic and Debug” logs?

The answer we got was no: WEF couldn’t subscribe to the Analytic and Debug channel. This isn’t a restriction to the four standard Windows logs; operational and custom event channels can also be forwarded. The subscription API supports Admin and Operational channels, as documented under EvtSubscribe.

It is possible to convert .etl logs to .evtx but it is a point in time operation. We considered whether we could do this with a script, but the downside would be that the logs would flow in chunks, rather than being forwarded at the time they arrive and decided to pursue a better fit for our requirement.

I should note that the design Microsoft arrived at here makes sense to me. That is, using the .etl format for the Session logs. In some environments the volume of information flowing through that log would be a challenge for regular event logs, and some of the content in the log might be unnecessary for most customers. Definitely not throwing shade at the choice to use .etl “Analytic and Debug” logs for the App Proxy Session log.

The Solution

The solution we arrived at was to hook only the relevant information (etw information that makes up events 24028, 24029 and 24030) from the App Proxy ETW channel. We would do this by writing a Windows Service that would leverage the Microsoft.Diagnostics.Tracing.TraceEvent package.

Said another way: we would subscribe to the relevant ETW channel and pluck out the ETW information that would later become an analytic event of type 24028, 24029 or 24030.

It was possible to learn about the event channels available to us a couple of ways:

  • logman query providers
  • wevtutil enum-publishers

The second command: wevtutil enum-publishers revealed an ETW source called Microsoft-AadApplicationProxy-Connector that contained the information we needed to work with the Microsoft.Diagnostics.Tracing.TraceEvent package.

This post by Alex Khanin was really helpful to get started with the ETW library: https://medium.com/@alexkhanin/getting-started-with-event-tracing-for-windows-in-c-8d866e8ab5f2

The key point was that we would need to add the following Nuget package to our C# project: Microsoft.Diagnostics.Tracing.TraceEvent

Console App Example

The following code shows how to hook the relevant events to achieve the outcome we would need as a basic console application. But, to do this in a way that will work across multiple servers, survive reboots etc we need to also take this code and implement it as a windows service.

To summarize the code:

  • We are defining three event types that we want to monitor for in the ETW channel for the App Proxy: Microsoft-AadApplicationProxy-Connector
  • We write a new event containing the trace text into a custom log, AppProxySession.

WEF can then collect IDs 24028, 24029 and 24030 from AppProxySession, using our source AppProxy-ETW-Event-Extractor.

Correction to the original example: System.Diagnostics.EventLog uses the classic event-log API. The App Proxy /Admin channel belongs to Microsoft’s manifest-based provider; naming it in CreateEventSource isn’t the right way to publish that provider’s events. A separate custom log gives the relayed events their own destination. Microsoft’s EventLog example also creates the source before attempting the first write.

using System;
using System.IO;
using Microsoft.Diagnostics.Tracing;
using Microsoft.Diagnostics.Tracing.Session;
using System.Diagnostics;

namespace Session_Logs_Via_ETW
{
    class Program
    {
        private static Logger _logger;
        private const string EventSourceName = "AppProxy-ETW-Event-Extractor";
        private const string EventLogName = "AppProxySession";
        static void Main(string[] args)
        {
            const string sessionName = "AppProxyLogExporter";
            const string providerName = "Microsoft-AadApplicationProxy-Connector";
            string logFilePath = "application_log.txt";
            _logger = new Logger(logFilePath);
            _logger.Log("Application started.");
            if (!EnsureEventSource(EventSourceName, EventLogName))
                return;

            using (var session = new TraceEventSession(sessionName))
            {
                Console.CancelKeyPress += (sender, e) =>
                {
                    Console.WriteLine("Stopping the session...");
                    session.Dispose();
                };

                session.Source.Dynamic.All += HandleEvent;
                session.EnableProvider(providerName);
                Console.WriteLine($"Listening to events from provider '{providerName}'. Press Ctrl+C to exit...");
                session.Source.Process();
            }

            _logger.Log("Application ended.");
        }

        public class Logger
        {
            private readonly string _logFilePath;
            public Logger(string logFilePath)
            {
                _logFilePath = logFilePath;
            }

            public void Log(string message)
            {
                string logMessage = $"{DateTime.Now}: {message}";
                File.AppendAllText(_logFilePath, logMessage + Environment.NewLine);
            }
        }

        private static void HandleEvent(TraceEvent traceEvent)
        {
            const int EventId1 = 24028; // Microsoft AAD Application Proxy Connector received a frontend request.
            const int EventId2 = 24029; // Microsoft AAD Application Proxy Connector sent a request to backend application.
            const int EventId3 = 24030; // Microsoft AAD Application Proxy Connector received a response from backend.

            if (traceEvent.ID == (TraceEventID)EventId1)
            {
                Console.WriteLine($"Event {EventId1} received at: {traceEvent.TimeStamp}");
                EventLogEntryType entryType = EventLogEntryType.Information;
                int eventId = (int)traceEvent.ID;
                string input_message = $"{traceEvent}";
                string formated_message = input_message.Replace("&lt;", "<").Replace("&gt;", ">");
                string prefix = "Microsoft AAD Application Proxy Connector received a frontend request." + Environment.NewLine;
                formated_message = prefix + formated_message;
                WriteToEventLog(EventSourceName, EventLogName, entryType, eventId, formated_message);
            }

            if (traceEvent.ID == (TraceEventID)EventId2)
            {
                Console.WriteLine($"Event {EventId2} received at: {traceEvent.TimeStamp}");
                EventLogEntryType entryType = EventLogEntryType.Information;
                int eventId = (int)traceEvent.ID;
                string input_message = $"{traceEvent}";
                string formated_message = input_message.Replace("&lt;", "<").Replace("&gt;", ">");
                string prefix = "Microsoft AAD Application Proxy Connector sent a request to backend application." + Environment.NewLine;
                formated_message = prefix + formated_message;
                WriteToEventLog(EventSourceName, EventLogName, entryType, eventId, formated_message);
            }

            if (traceEvent.ID == (TraceEventID)EventId3)
            {
                Console.WriteLine($"Event {EventId3} received at: {traceEvent.TimeStamp}");
                EventLogEntryType entryType = EventLogEntryType.Information;
                int eventId = (int)traceEvent.ID;
                string input_message = $"{traceEvent}";
                string formated_message = input_message.Replace("&lt;", "<").Replace("&gt;", ">");
                string prefix = "Microsoft AAD Application Proxy Connector received a response from backend." + Environment.NewLine;
                formated_message = prefix + formated_message;
                WriteToEventLog(EventSourceName, EventLogName, entryType, eventId, formated_message);
            }
        }


        public static bool EnsureEventSource(string source, string log)
        {
            if (!EventLog.SourceExists(source))
            {
                EventLog.CreateEventSource(source, log);
                Console.WriteLine("Event source created. Run the program again after registration completes.");
                return false;
            }
            if (!string.Equals(EventLog.LogNameFromSourceName(source, "."), log,
                StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("The event source is registered to a different log.");
            return true;
        }

        public static void WriteToEventLog(string source, string log, EventLogEntryType entryType, int eventId, string message)
        {
            using (EventLog eventLog = new EventLog(log))
            {
                eventLog.Source = source;
                eventLog.WriteEntry(message, entryType, eventId);
            }
        }

    }
}

The corrected console example needs an elevated setup run to register the source, followed by another run to listen. For a service, put source registration in the installer. If the old sample already registered this source against another log, resolve that registration first; the check above deliberately stops instead of silently writing somewhere else.

Despite the title, this isn’t an ETL file converter. It sees events emitted while the session is running and creates new event-log records. The new records have the exporter’s provider identity and write time; the original trace is stored as message text, not reconstructed as the original event’s structured fields. WEF queries and SIEM parsers need to account for that.

Before calling this done on a connector, send a known request and follow it through all three places: the Session trace, AppProxySession, and the collector. Then stop the exporter and repeat. That second test makes the collection gap obvious: restarting this real-time session doesn’t backfill the events it missed. The handler also writes synchronously, so sustained load, write failures and ETW event loss need attention before deploying it widely.

Windows Service

Converting the code above to a Windows Service is relatively straightforward. I’d recommend you use this Microsoft article:

https://learn.microsoft.com/en-us/dotnet/framework/windows-services/walkthrough-creating-a-windows-service-application-in-the-component-designer.

The tutorial covers the service lifecycle. The ETW processing loop is blocking, so it belongs on a worker that the service can stop cleanly, not directly in OnStart. Starting automatically after a reboot also doesn’t remove the need to detect and report failures during collection.

Here’s the original service example. It predates the custom-log correction above, so don’t assume the linked repository includes that change: https://github.com/chadduffey/etw-to-evtx/blob/main/AppProxy-ETW-to-EVTX/AppProxyLogConverter.cs

Thanks!

References