I recently found this article about using the Chrome DevTools protocol to intercept and modify traffic. I found the article very enlighting given that the technique can allow pentesters to use complex logic when intercepting and modifying web requests. And yes, you can capture, intercept and modify traffic with tools like Burp, but you often have to rely on a heavy GUI and complex regex rules (and while I find regex very useful for my work, I don’t find it fun to work with it!), and complex if/then logic is often not possible. Almost as soon as I read the article, I started experimenting with node scripts and reading the Chrome DevTools Protocol documentation. After getting the script running I figured I would try to get a similar example working with golang instead. I think doing this in golang has a lot of potential, given that we can write multiple function that process intercepted requests and responses using goroutines. With enough research, we could do things such as:
-
Write traffic analyzers targeted for specific JS frameworks. For instance, when working with an Angular app we could intercept JavaScript files, analyze them and output a list of services and factories that communicate with APIs, as well as list each custom controller and component of the application. This kind of analysis would allow us to have a better understanding of an application when performing pentests and bug bounties.
-
Run JavaScript code in the browser that take advantage of functions loaded by the application that we are researching.
-
Keep track of resources such as specific GUIDs and IDs, and display a stream of request and responses based on specific conditions and logic.
”But you can do this with Burp anyway, no need to spend time with the Chrome DevTools Protocol, man!” Yes, you can to an extent. When it comes to writing complex logic, things start getting more complicated. So while Burp is without a doubt awesome, it is not perfect, certainly not when you want to process incoming data in multiple different specific ways.
Ok, back to the exciting stuff. Let’s get started with writting a basic traffic interceptor application that unhides all input elements from a page. We will also make it so that our interceptor application sets ng-if
values to true
. This can be helpful when you want to explore an Angular application and see what content is not rendered by Angular on page loads, as in some cases this could allow us to discover directories that are hidden to some users right away (note that ng-if
does not hide or show input; rather, it decides whether to render an element on page loads).
The first thing we need is a client library that we can use for communicating with Chrome DevTools. I chose to go with godet as it is very easy to work with and the code is relatively simple. We will also need a way to manipulate HTML content. There are a number of ways to do this, but I found the easiest was to use goquery.
We can start by creating a structure that allows us to reference state between functions :
type DebuggerOptions struct {
EnableConsole bool
Verbose bool
AlterDocument bool
AlterScript bool
}
type State struct {
Debugger *godet.RemoteDebugger
Done chan bool
Options DebuggerOptions
}
Next, in our main function we can set an initial set of options and call a function that will open Chrome in debugging mode:
func main() {
portNumber := 9222
s := State{}
s.Options = DebuggerOptions {
Verbose : false,
EnableConsole : true,
AlterDocument: true,
AlterScript: true,
}
err := OpenChrome(portNumber)
if err != nil{
log.Println("[-] Unable to start browser!")
}
The OpenChrome()
function is pretty straight forward. It simply opens Chrome by running a system command with a set of specific flags. Notice that we are passing a portNumber
that we will use to communicate with Chrome later:
func OpenChrome(portNumber int) error {
var chromeapp string
chromeapp = `open -na "/Applications/Google Chrome.app" --args
--remote-debugging-port=` + strconv.Itoa(portNumber) + `
--window-size=1200,800
--user-data-dir=/tmp/chrome-testing
--auto-open-devtools-for-tabs`
log.Println("[+] opening chrome:" + chromeapp)
err := runCommand(chromeapp)
return err
}
func runCommand(commandString string) error {
parts := args.GetArgs(commandString)
cmd := exec.Command(parts[0], parts[1:]...)
return cmd.Start()
}
Back in our main
function, we will create a debugger
reference that we will use to control and communicate with Chrome:
// Get a debugger reference
SetupDebugger(&s, portNumber)
defer s.Debugger.Close()
In order to create a Debugger
reference, we will attempt to communicate with Chrome through the provided port. Noticed that we are passing a pointer to the State
object we created earlier. Once a connection is made, we will set the Debugger
variable of our State
structure.
func SetupDebugger(s *State, portNumber int) {
// Requires an opened browser running DevTools protocol
var err error
// Keep checking for browser with [portNumber] connection
log.Println("Attempting to connect to browser on " + "localhost:"+strconv.Itoa(portNumber))
for i := 0; i < 10; i++ {
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
s.Debugger, err = godet.Connect("localhost:"+strconv.Itoa(portNumber), s.Options.Verbose)
if err == nil {
break
}
log.Println("[+] Connect", err)
}
if err != nil {
log.Fatal("[-] Unable to connect to the browser!")
}
}
Great, now that we can communicate with and control Chrome via our Debugger
, we should write some code that will maintain a connection with Chrome and handle events that could terminate it. We will do that next in our main
function:
//Create a channel to be able to signal a termination to our Chrome connection
s.Done = make(chan bool)
shouldWait := true
//Handle EventClosed events
s.Debugger.CallbackEvent(godet.EventClosed, func(params godet.Params) {
log.Println("[-] Remote Debugger connection terminated!")
s.Done <- true
})
//Keep this running
if shouldWait {
log.Println("[+] Wait for events...")
<- s.Done
}
Now we have a few choices. We can enable different sets of domains, each domain containing a specific set of events. All domains are documented in the DevTools documentation. We will only enable what is useful for our hackerish investigative purposes.
//EnableAllEvents enables enables all debugger events
func EnableAllEvents(s *State) {
log.Println("[+] Enabling all debugger events.")
s.Debugger.RuntimeEvents(true)
s.Debugger.NetworkEvents(true)
s.Debugger.PageEvents(true) //Not used at the moment, but enabling anyways
s.Debugger.DOMEvents(true) //Not used at the moment, but enabling anyways
s.Debugger.LogEvents(true)
}
We can now call our EnableAllEvents
function and register handlers for different events in our main
function. We will call a number of functions to do just that before our if shouldWait
call shown earlier:
//Enable Console methods
SetupConsoleLogging(&s)
//Set Network Request Interceptor Patterns for terminal logging
htmlRequestPattern := godet.RequestPattern{
UrlPattern: "*",
ResourceType: "Document",
InterceptionStage: "HeadersReceived",
}
jsRequestPattern := godet.RequestPattern{
UrlPattern: "*.js",
ResourceType: "Script",
InterceptionStage: "HeadersReceived",
}
//Setup intercept event behavior
SetupRequestInterception(&s, htmlRequestPattern, jsRequestPattern)
Let’s first take look at SetupConsoleLogging
. This function registers processes console events and prints them in the terminal (for funsies):
func SetupConsoleLogging(s *State) {
//Log console
log.Println("[+] Setting up console events.")
s.Debugger.CallbackEvent("Log.entryAdded", func(params godet.Params) {
entry := params.Map("entry")
log.Println("LOG", entry["type"], entry["level"], entry["text"])
})
s.Debugger.CallbackEvent("Runtime.consoleAPICalled", func(params godet.Params) {
l := []interface{}{"CONSOLE", params["type"].(string)}
for _, a := range params["args"].([]interface{}) {
arg := a.(map[string]interface{})
if arg["value"] != nil {
l = append(l, arg["value"])
} else if arg["preview"] != nil {
arg := arg["preview"].(map[string]interface{})
v := arg["description"].(string) + "{"
for i, p := range arg["properties"].([]interface{}) {
if i > 0 {
v += ", "
}
prop := p.(map[string]interface{})
if prop["name"] != nil {
v += fmt.Sprintf("%q: ", prop["name"])
}
v += fmt.Sprintf("%v", prop["value"])
}
v += "}"
l = append(l, v)
} else {
l = append(l, arg["type"].(string))
}
}
log.Println(l...)
})
}
Wow, that is a lot of code huh? All we are doing is reading and formatting different types of CONSOLE
events so they can be displayed in the terminal nicely. That is cool and all, but what we are more concerned with is intercepting and processing requests. As you saw earlier, we created the following request patterns in our main
function:
htmlRequestPattern := godet.RequestPattern{
UrlPattern: "*",
ResourceType: "Document",
InterceptionStage: "HeadersReceived",
}
jsRequestPattern := godet.RequestPattern{
UrlPattern: "*.js",
ResourceType: "Script",
InterceptionStage: "HeadersReceived",
}
The above structures will be used to tell our interceptor function what must be intercepted. In this case we are intercepting resources of type Script
, but we are only concerned about *.js
scripts. Note that if we wanted to intercept minified JavaScript files, we would use *.js*
as the UrlPattern
instead. We also want to intercept resources of type Document
, which includes html
resources. Because we want to be able to modify request and responses, we want to intercept our requests at the HeadersReceived
stage.
Now let’s take a look at our SetupRequestInterception
function (this is where the fun happens):
func SetupRequestInterception(s *State, requestPatterns ...godet.RequestPattern) {
log.Println("[+] Setting up interception.")
//Enable request interception using the specific requestPatterns
s.Debugger.SetRequestInterception(requestPatterns ...)
responses := map[string]string{}
// Register a function to process the Network.reuqestIntercepted event
s.Debugger.CallbackEvent("Network.requestIntercepted", func(params godet.Params) {
iid := params.String("interceptionId")
rtype := params.String("resourceType")
reason := responses[rtype]
log.Println("[+] Request intercepted for", iid, rtype, params.Map("request")["url"])
if reason != "" {
log.Println(" abort with reason", reason)
}
// Alter HTML in request response
if s.Options.AlterDocument && rtype == "Document" && iid != "" {
res, err := s.Debugger.GetResponseBodyForInterception(iid)
if err != nil {
log.Println("[-] Unable to get intercepted response body!")
}
rawAlteredResponse, err := AlterDocument(res)
if err != nil{
log.Println("[-] Unable to alter HTML")
}
if rawAlteredResponse != "" {
log.Println("[+] Sending modified body")
s.Debugger.ContinueInterceptedRequest(iid, godet.ErrorReason(reason), rawAlteredResponse, "", "", "", nil)
}
} else {
s.Debugger.ContinueInterceptedRequest(iid, godet.ErrorReason(reason), "", "", "", "", nil)
}
})
}
There is a lot to discus here. First, we enable request interception by calling s.Debugger.SetRequestInterception
and passing it the patterns that we defined earlier. When a request is intercepted, the debugger will fire the Network.requestIntercepted
(recall we enabled the Network
domain of events earlier). Once that is fired, we will obtain the intercepted request ID (iid
, the resourceType (rtype
) and reason
.
For now, we only want to modify HTML documents. After making sure that the intercepted request has an rtype
of Document
, and that an iid
has been captured, we get the response body of our request and store it in the res
variable:
res, err := s.Debugger.GetResponseBodyForInterception(iid)
Note that GetReponseBodyForInterception
will process the response for us, so even if it is base64 encoded, it will decoded it and return a []byte
object. Now we can do whatever we want with the response, so we call AlterDocument(res)
and pass it the response object. In this case, we will use goquery
to unhide hidden input
elements and set all ng-if
attributes to true
:
func AlterDocument(debuggerResponse []byte) (string, error) {
alteredBody, err := processHtml(debuggerResponse)
if err != nil {
return "", err
}
alteredHeader := "Date: " + fmt.Sprintf("%s", time.Now().Format(time.RFC3339)) + "\r\n" +
"Connection : close\r\n" +
"Content-Length: " + strconv.Itoa(len(alteredBody)) + "\r\n" +
"Content-Type: text/html; charset=utf-8"
rawAlteredResponse := base64.StdEncoding.EncodeToString([]byte("HTTP/1.1 200 OK" + "\r\n" + alteredHeader + "\r\n\r\n\r\n" + alteredBody))
return rawAlteredResponse, nil
}
func processHtml(body []byte) (string, error) {
bodyString := string(body[:])
r := strings.NewReader(bodyString)
doc, err := goquery.NewDocumentFromReader(r)
if err != nil {
return "", err
}
doc.Find("input").Each(func(i int, s *goquery.Selection) {
att, ex := s.Attr("type")
if ex && att == "hidden" {
s.SetAttr("type", "")
}
})
doc.Find("*").Each(func(i int, s *goquery.Selection) {
//Angular 1.X
_, ex := s.Attr("ng-if")
if ex {
s.SetAttr("ng-if", "true")
}
//Angular 2.X >
_, ex = s.Attr("*ngIf")
if ex {
s.SetAttr("*ngIf", "true")
}
})
return doc.Html()
}
Also, notice that our response includes modified headers that take in consideration the length
our our news response body. We also base64 encode the response as it is a raw response that will be rendered in Chrome. Once we have our modified response, we can let Chrome continue the intercepted request by doing the following:
if rawAlteredResponse != "" {
log.Println("[+] Sending modified body")
s.Debugger.ContinueInterceptedRequest(iid, godet.ErrorReason(reason), rawAlteredResponse, "", "", "", nil)
}
Note that we are passing it the intercepted request id (iid
) and our new, modified response (rawAlteredResponse
). Now when you navigate to a page on Chrome you will be able to see all hidden input elements. You will also be able to see all elements that would be removed from the page using ngIf
directives in Angular applications.
Now, of course this is just a PoC. Nevertheless, I plan to continue working on this and exploring pentesting uses of the Chrome DevTools Protocol. For now, you can view and clone this this Github repository.
Note: This article is only here because of the information that I learned from this article written by @jsoverson. Also, this is possible thanks to godet.