Skip to content

Instantly share code, notes, and snippets.

@hkraw
Last active April 20, 2022 15:29
Show Gist options
  • Save hkraw/5bc0a4cf4615da4e3bb3a846ecb4fe19 to your computer and use it in GitHub Desktop.
Save hkraw/5bc0a4cf4615da4e3bb3a846ecb4fe19 to your computer and use it in GitHub Desktop.
add proper chain handling
//"use script";
const color_red = "";
const color_green = "";
const color_yellow = "";
const color_blue = "";
const color_mag = "";
const color_cyan = "";
const color_default = "";
const DefaultNumberOfInstructions = 3;
const DefaultMaxStringLength = 15;
const DefaultNumberOfLines = 10;
String.prototype.ljust = function(length, char) {
var fill = [];
while(fill.length + this.length < length) {
fill[fill.length] = char;
}
return fill.join('') + this;
}
String.prototype.rjust = function(length, char) {
var fill = [];
while(fill.length + this.length < length) {
fill[fill.length] = char;
}
return this + fill.join('');
}
String.prototype.format = function() {
str = this;
for(k in arguments) {
str = str.replace("{" + k + "}", arguments[k]);
}
return str;
}
const log = host.diagnostics.debugLog;
let logln = function ( e ) { host.diagnostics.debugLog(e); }
let region_Stack = function( addr, end ) { logln( color_red + addr + color_default ); }
let region_Heap = function( addr, end ) { logln( color_cyan + addr + color_default); }
let region_Image = function( addr, end ) { login ( color_yellow + addr + color_default ); }
function ReadU64(Addr) {
let Value = null;
try {
Value = host.memory.readMemoryValues(
Addr, 1, 8
)[0];
} catch(e) {
}
return Value;
}
function ReadU32(Addr) {
let Value = null;
try {
Value = host.memory.readMemoryValues(
Addr, 1, 4
)[0];
} catch(e) {
}
return Value;
}
function ReadU16(Addr) {
let Value = null;
try {
Value = host.memory.readMemoryValues(
Addr, 1, 2
)[0];
} catch(e) {
}
return Value;
}
function ReadString(Addr, MaxLength) {
let Value = null;
try {
Value = host.memory.readString(Addr);
} catch(e) {
return null;
}
if(Value.length > MaxLength) {
return Value.substr(0, MaxLength);
}
return Value;
}
function ReadWideString(Addr) {
let Value = null;
try {
Value = host.memory.readWideString(Addr);
} catch(e) {
}
return Value;
}
function Disassemble(Addr) {
const Code = host.namespace.Debugger.Utility.Code;
const Disassembler = Code.CreateDisassembler(
PointerSize == 8 ? 'X64' : 'X86'
);
const Instrs = Array.from(Disassembler.DisassembleInstructions(Addr).Take(
DefaultNumberOfInstructions
));
return Instrs.map(
//
// Clean up the assembly.
// Turn the below:
// 'mov rbx,qword ptr [00007FF8D3525660h] ; test rbx,rbx ; je 00007FF8D34FC2EB'
// Into:
// 'mov rbx,qword ptr [00007FF8D3525660h] ; test rbx,rbx ; je 00007FF8D34FC2EB'
//
p => p.toString().replace(/[ ]+/g, ' ')
).join(' ; ');
}
function FormatU64(Addr) {
return '0x' + Addr.toString(16).padStart(16, '0');
}
function FormatU32(Addr) {
return '0x' + Addr.toString(16).padStart(8, '0');
}
function FormatString(Str) {
if(Str.length > DefaultMaxStringLength) {
return Str.substr(0, DefaultMaxStringLength) + '...'
}
return Str;
}
function BitSet(Value, Bit) {
return Value.bitwiseAnd(Bit).compareTo(0) != 0;
}
//
// Initialization / global stuff.
//
let Initialized = false;
let ReadPtr = null;
let PointerSize = null;
let FormatPtr = null;
let IsTTD = false;
let IsUser = false;
let IsKernel = false;
let VaSpace = [];
function *SectionHeaders(BaseAddress) {
if(IsKernel && ReadU32(BaseAddress) == null) {
//
// If we can't read the module, then..bail :(.
// XXX: Fix this? Session space? Paged out?
//
logln('Cannot read ' + BaseAddress.toString(16) + ', skipping.');
return;
}
// 0:000> dt _IMAGE_DOS_HEADER e_lfanew
// +0x03c e_lfanew : Int4B
const NtHeaders = BaseAddress.add(ReadU32(BaseAddress.add(0x3c)));
// 0:000> dt _IMAGE_NT_HEADERS64 FileHeader
// +0x004 FileHeader : _IMAGE_FILE_HEADER
// 0:000> dt _IMAGE_FILE_HEADER NumberOfSections SizeOfOptionalHeader
// +0x002 NumberOfSections : Uint2B
// +0x010 SizeOfOptionalHeader : Uint2B
const NumberOfSections = ReadU16(NtHeaders.add(0x4 + 0x2));
const SizeOfOptionalHeader = ReadU16(NtHeaders.add(0x4 + 0x10));
// 0:000> dt _IMAGE_NT_HEADERS64 OptionalHeader
// +0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER64
const OptionalHeader = NtHeaders.add(0x18);
const SectionHeaders = OptionalHeader.add(SizeOfOptionalHeader);
// 0:000> ?? sizeof(_IMAGE_SECTION_HEADER)
// unsigned int64 0x28
const SizeofSectionHeader = 0x28;
for(let Idx = 0; Idx < NumberOfSections; Idx++) {
const SectionHeader = SectionHeaders.add(
Idx.multiply(SizeofSectionHeader)
);
// 0:000> dt _IMAGE_SECTION_HEADER Name
// +0x000 Name : [8] UChar
const Name = ReadString(SectionHeader, 8);
// 0:000> dt _IMAGE_SECTION_HEADER VirtualAddress
// +0x00c VirtualAddress : Uint4B
const Address = BaseAddress.add(
ReadU32(SectionHeader.add(0xc))
);
// 0:000> dt _IMAGE_SECTION_HEADER SizeOfRawData
// +0x08 Misc : Uint4B
// XXX: Take care of alignment?
const VirtualSize = ReadU32(SectionHeader.add(0x08));
// 0:000> dt _IMAGE_SECTION_HEADER Characteristics
// +0x024 Characteristics : Uint4B
const Characteristics = ReadU32(SectionHeader.add(0x24));
const Properties = [
'-',
'-',
'-'
];
// The section can be read.
const IMAGE_SCN_MEM_READ = host.Int64(0x40000000);
if(BitSet(Characteristics, IMAGE_SCN_MEM_READ)) {
Properties[0] = 'r';
}
if(IsKernel) {
const IMAGE_SCN_MEM_DISCARDABLE = host.Int64(0x2000000);
if(BitSet(Characteristics, IMAGE_SCN_MEM_DISCARDABLE)) {
Properties[0] = '-';
}
}
// The section can be written to.
const IMAGE_SCN_MEM_WRITE = host.Int64(0x80000000);
if(Characteristics.bitwiseAnd(IMAGE_SCN_MEM_WRITE).compareTo(0) != 0) {
Properties[1] = 'w';
}
// The section can be executed as code.
const IMAGE_SCN_MEM_EXECUTE = host.Int64(0x20000000);
if(Characteristics.bitwiseAnd(IMAGE_SCN_MEM_EXECUTE).compareTo(0) != 0) {
Properties[2] = 'x';
}
yield new _Region(
Address,
VirtualSize,
Name,
Properties.join('')
);
}
}
function HandleTTD() {
const CurrentSession = host.currentSession;
//
// Grab addressable chunks.
//
logln('Populating the VA space with TTD.Data.Heap..');
const CurrentThread = host.currentThread;
const Position = CurrentThread.TTD.Position;
const Chunks = CurrentSession.TTD.Data.Heap().Where(
p => p.TimeStart.compareTo(Position) < 0 &&
p.Action == 'Alloc'
);
for(const Chunk of Chunks) {
VaSpace.push(new _Region(
Chunk.Address,
Chunk.Size,
'Heap',
'rw-'
));
}
//
// Grab virtual allocated memory regions.
//
logln('Populating the VA space with VirtualAllocated regions..');
const VirtualAllocs = CurrentSession.TTD.Calls(
'kernelbase!VirtualAlloc'
).Where(
p => p.TimeStart.compareTo(Position) < 0
);
for(const VirtualAlloc of VirtualAllocs) {
VaSpace.push(new _Region(
VirtualAlloc.ReturnValue,
VirtualAlloc.Parameters[1],
'VirtualAlloced',
// XXX: parse access
'rw-'
));
}
//
// Grab mapped view regions.
//
logln('Populating the VA space with MappedViewOfFile regions..');
const MapViewOfFiles = CurrentSession.TTD.Calls(
'kernelbase!MapViewOfFile'
).Where(
p => p.TimeStart.compareTo(Position) < 0
);
for(const MapViewOfFile of MapViewOfFiles) {
VaSpace.push(new _Region(
MapViewOfFile.ReturnValue,
0x1000,
'MappedView',
// XXX: parse access
'rw-'
));
}
}
function HandleUser() {
//
// Enumerate the modules.
//
// logln('Populating the VA space with modules..');
const CurrentProcess = host.currentProcess;
for(const Module of CurrentProcess.Modules) {
//
// Iterate over the section headers of the module.
//
for(const Section of SectionHeaders(Module.BaseAddress)) {
VaSpace.push(new _Region(
Section.BaseAddress,
Section.Size,
'Image ' + Module.Name + ' (' + Section.Name + ')',
Section.Properties
));
}
//
// Add a catch all in case a pointer points inside the PE but not
// inside any sections (example of this is the PE header).
//
VaSpace.push(new _Region(
Module.BaseAddress,
Module.Size,
'Image ' + Module.Name,
'r--'
));
}
//
// Enumerates the TEBs and the stacks.
//
//logln('Populating the VA space with TEBs & thread stacks..');
for(const Thread of CurrentProcess.Threads) {
const Teb = Thread.Environment.EnvironmentBlock;
//
// TEB!
//
// In the case where you have broken `ntdll` symbols, you might not have
// the definition of the `_TEB` structure. In this case, the structured
// `Teb` object above is undefined (like in issues #2). So we try to be resilient
// against that in the below.
//
if(Teb == undefined) {
const General = host.namespace.Debugger.State.PseudoRegisters.General;
VaSpace.push(new _Region(
General.teb.address,
0x100,
'Teb of ' + Thread.Id.toString(16),
'rw-'
));
continue;
}
VaSpace.push(new _Region(
Teb.address,
Teb.targetType.size,
'Teb of ' + Thread.Id.toString(16),
'rw-'
));
//
// Stacks!
//
const StackBase = Teb.NtTib.StackBase.address;
const StackLimit = Teb.NtTib.StackLimit.address;
VaSpace.push(new _Region(
StackLimit,
StackBase.subtract(StackLimit),
'Stack',
'rw-'
));
}
//
// Get the PEB. Keep in mind we can run into the same symbol problem with the
// PEB - so account for that.
//
//logln('Populating the VA space with the PEB..');
const Peb = CurrentProcess.Environment.EnvironmentBlock;
if(Peb == undefined) {
const General = host.namespace.Debugger.State.PseudoRegisters.General;
VaSpace.push(new _Region(
General.peb.address,
0x1000,
'Peb',
'rw-'
));
logln(`/!\\ Several regions have been skipped because nt!_TEB / nt!_PEB aren't available in your symbols.`);
} else {
VaSpace.push(new _Region(
Peb.address,
Peb.targetType.size,
'Peb',
'rw-'
));
}
}
function HandleKernel() {
//
// Enumerate the kernel modules.
//
logln('Populating the VA space with kernel modules..');
const CurrentSession = host.currentSession;
const SystemProcess = CurrentSession.Processes.First(
p => p.Name == 'System'
);
const MmUserProbeAddress = ReadPtr(
host.getModuleSymbolAddress('nt', 'MmUserProbeAddress')
);
const KernelModules = SystemProcess.Modules.Where(
p => p.BaseAddress.compareTo(MmUserProbeAddress) > 0
);
for(const Module of KernelModules) {
//
// Iterate over the section headers of the module.
//
for(const Section of SectionHeaders(Module.BaseAddress)) {
VaSpace.push(new _Region(
Section.BaseAddress,
Section.Size,
'Driver ' + Module.Name + ' (' + Section.Name + ')',
Section.Properties
));
}
//
// Add a catch all in case a pointer points inside the PE but not
// inside any sections (example of this is the PE header).
//
VaSpace.push(new _Region(
Module.BaseAddress,
Module.Size,
'Driver ' + Module.Name,
'r--'
));
}
}
function InitializeVASpace() {
if(IsUser) {
HandleUser();
}
if(IsTTD) {
//
// If we have a TTD target, let's do some more work.
//
HandleTTD();
}
if(IsKernel) {
HandleKernel();
}
}
function InitializeWrapper() {
if(!Initialized) {
const CurrentSession = host.currentSession;
//
// Initialize the ReadPtr function according to the PointerSize.
//
PointerSize = CurrentSession.Attributes.Machine.PointerSize;
ReadPtr = PointerSize.compareTo(8) == 0 ? ReadU64 : ReadU32;
FormatPtr = PointerSize.compareTo(8) == 0 ? FormatU64 : FormatU32;
const TargetAttributes = CurrentSession.Attributes.Target;
IsTTD = TargetAttributes.IsTTDTarget;
IsUser = TargetAttributes.IsUserTarget;
IsKernel = TargetAttributes.IsKernelTarget;
//
// One time initialization!
//
Initialized = true;
}
}
//
// The meat!
//
class _Region {
constructor(BaseAddress, Size, Name, Properties) {
this.Name = Name;
this.BaseAddress = BaseAddress;
this.EndAddress = this.BaseAddress.add(Size);
this.Size = Size;
this.Properties = Properties;
this.Executable = false;
this.Readable = false;
this.Writeable = false;
if(Properties.indexOf('r') != -1) {
this.Readable = true;
}
if(Properties.indexOf('w') != -1) {
this.Writeable = true;
}
if(Properties.indexOf('x') != -1) {
this.Executable = true;
}
}
In(Addr) {
const InBounds = Addr.compareTo(this.BaseAddress) >= 0 &&
Addr.compareTo(this.EndAddress) < 0;
return InBounds;
}
toString() {
const Prop = [
this.Readable ? 'r' : '-',
this.Writeable ? 'w' : '-',
this.Executable ? 'x' : '-'
];
return this.Name + ' ' + Prop.join('');
}
}
function AddressToRegion(Addr) {
//
// Map the address space with VA regions.
//
const Hits = VaSpace.filter(
p => p.In(Addr)
);
//
// Now, let's get the most precise region information by ordering
// the hits by size.
//
const OrderedHits = Hits.sort(
p => p.Size
);
//
// Return the most precise information we have!
//
return OrderedHits[0];
}
class _ChainEntry {
constructor(Addr, Value) {
this.Addr = Addr;
this.Value = Value;
this.AddrRegion = AddressToRegion(this.Addr);
this.ValueRegion = AddressToRegion(this.Value);
if(this.ValueRegion == undefined) {
this.Name = 'Unknown';
} else {
//
// Just keep the file name and strips off the path.
//
this.Name = this.ValueRegion.Name;
this.Name = this.Name.substring(this.Name.lastIndexOf('\\') + 1);
}
this.Last = false;
}
Equals(Entry) {
return this.Addr.compareTo(Entry.Addr) == 0;
}
toString() {
let color = color_default;
if(this.Name[0] == 'S') {
color = color_red;
} else if (this.Name.search(".dll") != -1) {
color = color_cyan;
} else if(this.Name.search(".exe") != -1) {
color = color_cyan;
}
const S = color + FormatPtr(this.Value) + ' (' + this.Name + ')' + color_default;
if(!this.Last) {
return S;
}
//
// We only provide disassembly if we know that the code is executeable.
// And in order to know that, we need to have a valid `AddrRegion`.
//
if(this.AddrRegion != undefined && this.AddrRegion.Executable) {
return Disassemble(this.Addr);
}
//
// If we have a string stored in a heap allocation what happens is the following:
// - The extension does not know about heap, so `AddrRegion` for such a pointer
// would be `undefined`.
// - Even though it is undefined, we would like to display a string if there is any,
// instead of just the first qword.
// So to enable the scenario to work, we allow to enter the below block with an `AddrRegion`
// that is undefined.
//
if(this.AddrRegion == undefined || this.AddrRegion.Readable) {
const IsPrintable = p => {
return p != null &&
// XXX: ugly AF.
p.match(/^[a-z0-9!"#$%&'()*+,/\\.:;<=>?@\[\] ^_`{|}~-]+$/i) != null &&
p.length > 5
};
//
// Maybe it points on a unicode / ascii string?
//
const Ansi = ReadString(this.Addr);
if(IsPrintable(Ansi)) {
return `${FormatPtr(this.Addr)} (Ascii(${FormatString(Ansi)}))`;
}
const Wide = ReadWideString(this.Addr);
if(IsPrintable(Wide)) {
return `${FormatPtr(this.Addr)} (Unicode(${FormatString(Wide)}))`;
}
}
//
// If we didn't find something better, fallback to the regular
// output.
//
return S;
}
}
class _Chain {
constructor(Addr) {
this.__Entries = [];
this.__HasCycle = false;
this.__Addr = Addr;
while(this.FollowPtr()) { };
this.__Length = this.__Entries.length;
//
// Tag the last entry as 'last'.
//
if(this.__Length >= 1) {
this.__Entries[this.__Length - 1].Last = true;
}
}
FollowPtr() {
//
// Attempt to follow the pointer.
//
const Value = ReadPtr(this.__Addr);
if(Value == null) {
//
// We are done following pointers now!
//
return false;
}
//
// Let's build an entry and evaluate what we want to do with it.
//
const Entry = new _ChainEntry(this.__Addr, Value);
const DoesEntryExist = this.__Entries.find(
p => p.Equals(Entry)
);
if(DoesEntryExist) {
//
// If we have seen this Entry before, it means there's a cycle
// and we will stop there.
//
this.__HasCycle = true;
return false;
}
//
// This Entry is of interest, so let's add it in our list.
//
this.__Entries.push(Entry);
this.__Addr = Value;
return true;
}
toString() {
if(this.__Entries.length == 0) {
return '';
}
//
// Iterate over the chain.
//
let S = this.__Entries.join(' -> ');
//
// Add a little something if we have a cycle so that the user knows.
//
if(this.__HasCycle) {
S += ' [...]';
}
return S;
}
*[Symbol.iterator]() {
for(const Entry of this.__Entries) {
yield Entry;
}
}
}
let xi = function( addr ) {
const Code = host.namespace.Debugger.Utility.Code;
const Disassembler = Code.CreateDisassembler( 'X64' );
const Instrs = Array.from(Disassembler.DisassembleInstructions( addr ).Take(3));
return Instrs.map(
p => p.toString().replace(/[ ]+/g,' ')
).join('; ');
}
let color_print = function( addr ) {
let AddrRegion = AddressToRegion(addr);
let Name = "";
if(AddrRegion == undefined) {
return color_default;
} else {
Name = AddrRegion.Name;
Name = Name.substring(Name.lastIndexOf('\\') + 1);
}
if(Name[0] == 'S') {
return color_red;
} else if (Name.search(".dll") != -1) {
return color_cyan;
} else if(Name.search(".exe") != -1) {
return color_cyan;
}
}
let read_mem = function( addr , size, n ) {
return host.memory.readMemoryValues(addr, n, size);
}
let getUserRegisters = function(print_registers) {
let Regs = host.currentThread.Registers.User;
let registers = {
"rax": Regs.rax, "rbx": Regs.rbx, "rcx":Regs.rcx, "rdx":Regs.rdx,
"rsi":Regs.rsi, "rdi":Regs.rdi, "rip":Regs.rip, "rsp":Regs.rsp,
"rbp":Regs.rbp, "r8 ":Regs.r8, "r9 ":Regs.r9, "r10":Regs.r10,
"r11":Regs.r11, "r12":Regs.r12, "r13":Regs.r13, "r14":Regs.r14,
"r15":Regs.r15
};
let chain = 0;
let Header = 0;
let color = color_default;
InitializeWrapper();
InitializeVASpace();
if (print_registers) {
for (let [key, value] of Object.entries(registers)) {
logln(`${color_mag}${key}${color_default}: `);
chain = new _Chain(value, [value]);
color = color_print(value);
let Header = color + FormatPtr(value) + color_default;
if(chain.toString().split('->').length > 1) {
Header += ' -> ';
} else if(chain.toString().split('->')[0].search('Unknown') != -1) {
Header += ' -> ';
} else if(chain.toString().split('->')[0].search(';') != -1) {
Header += ' -> ';
}
logln(Header + chain.toString() + '\n');
}
}
return registers;
};
let step = function(aaa, n) {
var Control = host.namespace.Debugger.Utility.Control;
var dbg = host.namespace.Debugger;
for(var j = 1; j <= n; j++) { Control.ExecuteCommand(aaa); }
logln(`${color_cyan}------------------------------Registers---------------------------------------${color_default}\n`);
getUserRegisters(true);
logln(`${color_cyan}------------------------------Disassembly-------------------------------------${color_default}\n`);
var i = 0;
for(let Line of Control.ExecuteCommand('u @rip')) {
if(i == 1) {
logln(`${color_red} ==>${color_default} ${color_green}${Line}\n${color_default}`)
}
else {
logln(`${color_yellow} ${Line}\n${color_default}`);
}
i += 1;
}
logln(`${color_cyan}-------------------------------Stack------------------------------------------${color_default}\n\n`);
for(let Line of Control.ExecuteCommand("!telescope @rsp")) { logln(Line + '\n'); }
}
let si = function( n = 1) {
step('t', n)
}
let ni = function( n = 1) {
step('p', n);
}
let printHex = function( size, bytes ) {
let hexStr = bytes.toString(16);
hexStr = hexStr.ljust(size, '0');
logln(`0x${hexStr} `);
}
let x = function(n, addr, size) {
if(addr == undefined) {
logln("[+] x/xg `@addr` `n`\n");
} else {
if(n % 2 != 0) { n += 1; }
var data = read_mem(addr, size, n);
for(let i = 0; i < data.length; i += 2) {
logln(`0x${(addr + i * 8).toString(16)}: `)
printHex(size * 2, data[i]);
printHex(size * 2, data[i + 1]);
logln('\n');
}
}
}
let x_xg = function(addr, n = 28) {
x(n, addr, 8);
}
let x_xdw = function(addr, n = 28) {
x(n, addr, 4);
}
let x_xw = function(addr, n = 28) {
x(n, addr, 2);
}
let x_xb = function(addr) {
var data = read_mem(addr, 1)[0];
logln(`0x${data.toString(16)}\n`);
}
function Telescope(Addr, n) {
if(!Initialized) {
let CurrentSession = host.currentSession;
//
// Initialize the ReadPtr function according to the PointerSize.
//
PointerSize = CurrentSession.Attributes.Machine.PointerSize;
ReadPtr = PointerSize.compareTo(8) == 0 ? ReadU64 : ReadU32;
FormatPtr = PointerSize.compareTo(8) == 0 ? FormatU64 : FormatU32;
const TargetAttributes = CurrentSession.Attributes.Target;
IsTTD = TargetAttributes.IsTTDTarget;
IsUser = TargetAttributes.IsUserTarget;
IsKernel = TargetAttributes.IsKernelTarget;
//
// One time initialization!
//
Initialized = true;
}
if(Addr == undefined) {
logln('!telescope <addr>');
return;
}
//
// Initialize the VA space.
//
InitializeVASpace();
var CurrentSession = host.currentSession;
var Lines = n == undefined ? DefaultNumberOfLines : n;
var PointerSize = CurrentSession.Attributes.Machine.PointerSize;
var FormatOffset = p => '0x' + p.toString(16).padStart(4, '0');
for(let Idx = 0; Idx < Lines; Idx++) {
const Offset = PointerSize.multiply(Idx);
const CurAddr = Addr.add(Offset);
const Chain = new _Chain(CurAddr);
const Header = color_red + FormatPtr(CurAddr) + color_default + '|+' + FormatOffset(Offset);
logln(Header + ': ' + Chain + '\n');
}
VaSpace = [];
}
function initializeScript() {
return [
new host.apiVersionSupport(1, 3),
new host.functionAlias(si, 'si'),
new host.functionAlias(ni, 'ni'),
new host.functionAlias(x_xg, 'xg'),
new host.functionAlias(x_xdw, 'xdw'),
new host.functionAlias(x_xw, 'xw'),
new host.functionAlias(x_xb, 'xb'),
new host.functionAlias(xi, 'xi'),
new host.functionAlias(Telescope, 'telescope') /* https://github.com/0vercl0k/windbg-scripts/tree/master/telescope */
];
}
/* I've taken the code to make chains and find regions from https://github.com/0vercl0k/windbg-scripts/tree/master/telescope */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment