Garble imports and package paths in GOPRIVATE (#116)
Finally, finally this is done. This allows import paths to be obfuscated by modifying object/archive files and garbling import paths contained within. The bulk of the code that makes parsing and writing Go object/archive files possible lives at https://github.com/Binject/debug/tree/master/goobj2, which I wrote as well. I have tested by garbling and checking for import paths via strings and grep (in order of difficulty) https://github.com/lu4p/binclude, garble itself, and https://github.com/dominikh/go-tools/tree/master/cmd/staticcheck. This only supports object/archive files produced from the Go 1.15 compiler. The object file format changed at 1.15, and 1.14 and earlier is not supported. Fixes #13.pull/122/head
parent
30df5e9bbd
commit
c8d61c772f
@ -0,0 +1,596 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Binject/debug/goobj2"
|
||||
)
|
||||
|
||||
// pkgInfo stores a parsed go archive/object file,
|
||||
// and the original path to which it was read from.
|
||||
type pkgInfo struct {
|
||||
pkg *goobj2.Package
|
||||
path string
|
||||
}
|
||||
|
||||
// dataType signifies whether the Data portion of a
|
||||
// goobj2.Sym is reflection data for an import path,
|
||||
// reflection data for a method of struct field, or
|
||||
// something else.
|
||||
type dataType uint8
|
||||
|
||||
const (
|
||||
other dataType = iota
|
||||
importPath
|
||||
namedata
|
||||
)
|
||||
|
||||
// privateImports stores package paths and names that
|
||||
// match GOPRIVATE. privateNames are elements of the
|
||||
// paths in privatePaths, separated so that the shorter
|
||||
// names don't accidently match another import, such
|
||||
// as a stdlib package
|
||||
type privateImports struct {
|
||||
privatePaths []string
|
||||
privateNames []string
|
||||
}
|
||||
|
||||
func obfuscateImports(objPath, importCfgPath string) (map[string]string, error) {
|
||||
importCfg, err := goobj2.ParseImportCfg(importCfgPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainPkg, err := goobj2.Parse(objPath, "main", importCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing main objfile: %v", err)
|
||||
}
|
||||
privatePkgs := []pkgInfo{{mainPkg, objPath}}
|
||||
|
||||
// build list of imported packages that are private
|
||||
for pkgPath, info := range importCfg {
|
||||
if isPrivate(pkgPath) {
|
||||
pkg, err := goobj2.Parse(info.Path, pkgPath, importCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing objfile %s at %s: %v", pkgPath, info.Path, err)
|
||||
}
|
||||
|
||||
privatePkgs = append(privatePkgs, pkgInfo{pkg, info.Path})
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
var buf bytes.Buffer
|
||||
garbledImports := make(map[string]string)
|
||||
for _, p := range privatePkgs {
|
||||
// log.Printf("++ Obfuscating object file for %s ++", p.pkg.ImportPath)
|
||||
for _, am := range p.pkg.ArchiveMembers {
|
||||
// log.Printf("\t## Obfuscating archive member %s ##", am.ArchiveHeader.Name)
|
||||
|
||||
// skip objects that are not used by the linker, or that do not contain
|
||||
// any Go symbol info
|
||||
if am.IsCompilerObj() || am.IsDataObj() {
|
||||
continue
|
||||
}
|
||||
|
||||
// add all private import paths to a list to garble
|
||||
var privImports privateImports
|
||||
privImports.privatePaths, privImports.privateNames = explodeImportPath(p.pkg.ImportPath)
|
||||
// the main package might not have the import path "main" due to modules,
|
||||
// so add "main" to private import paths
|
||||
if p.pkg.ImportPath == buildInfo.firstImport {
|
||||
privImports.privatePaths = append(privImports.privatePaths, "main")
|
||||
}
|
||||
|
||||
initImport := func(imp string) string {
|
||||
if !isPrivate(imp) {
|
||||
return imp
|
||||
}
|
||||
|
||||
privPaths, privNames := explodeImportPath(imp)
|
||||
privImports.privatePaths = append(privImports.privatePaths, privPaths...)
|
||||
privImports.privateNames = append(privImports.privateNames, privNames...)
|
||||
|
||||
return hashImport(imp, garbledImports)
|
||||
}
|
||||
|
||||
for i := range am.Imports {
|
||||
am.Imports[i].Pkg = initImport(am.Imports[i].Pkg)
|
||||
}
|
||||
for i := range am.Packages {
|
||||
am.Packages[i] = initImport(am.Packages[i])
|
||||
}
|
||||
|
||||
privImports.privatePaths = dedupStrings(privImports.privatePaths)
|
||||
privImports.privateNames = dedupStrings(privImports.privateNames)
|
||||
// move imports that contain another import as a substring to the front,
|
||||
// so that the shorter import will not match first and leak part of an
|
||||
// import path
|
||||
sort.Slice(privImports.privatePaths, func(i, j int) bool {
|
||||
iSlashes := strings.Count(privImports.privatePaths[i], "/")
|
||||
jSlashes := strings.Count(privImports.privatePaths[j], "/")
|
||||
// sort by number of slashes unless equal, then sort reverse alphabetically
|
||||
if iSlashes == jSlashes {
|
||||
return privImports.privatePaths[i] > privImports.privatePaths[j]
|
||||
}
|
||||
return iSlashes > jSlashes
|
||||
})
|
||||
sort.Slice(privImports.privateNames, func(i, j int) bool {
|
||||
return privImports.privateNames[i] > privImports.privateNames[j]
|
||||
})
|
||||
|
||||
// no private import paths, nothing to garble
|
||||
if len(privImports.privatePaths) == 0 {
|
||||
continue
|
||||
}
|
||||
// log.Printf("\t== Private imports: %v ==\n", privImports)
|
||||
|
||||
// garble all private import paths in all symbol names
|
||||
garbleSymbols(&am, privImports, garbledImports, &buf, &sb)
|
||||
}
|
||||
|
||||
if err := p.pkg.Write(p.path); err != nil {
|
||||
return nil, fmt.Errorf("error writing objfile %s at %s: %v", p.pkg.ImportPath, p.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// garble importcfg so the linker knows where to find garbled imports
|
||||
if err := garbleImportCfg(importCfgPath, importCfg, garbledImports); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return garbledImports, nil
|
||||
}
|
||||
|
||||
// explodeImportPath returns lists of import paths
|
||||
// and package names that could all potentially be
|
||||
// in symbol names of the package that imported 'path'.
|
||||
// ex. path=github.com/foo/bar/baz, GOPRIVATE=github.com/*
|
||||
// pkgPaths=[github.com/foo/bar, github.com/foo]
|
||||
// pkgNames=[foo, bar, baz]
|
||||
// TODO: last element returned should get same buildID
|
||||
// as full path?
|
||||
// ie github.com/foo/bar.buildID == bar.buildID
|
||||
func explodeImportPath(path string) ([]string, []string) {
|
||||
paths := strings.Split(path, "/")
|
||||
if len(paths) == 1 {
|
||||
return []string{path}, nil
|
||||
}
|
||||
|
||||
pkgPaths := make([]string, 0, len(paths)-1)
|
||||
pkgNames := make([]string, 0, len(paths)-1)
|
||||
|
||||
var restPrivate bool
|
||||
if isPrivate(paths[0]) {
|
||||
pkgPaths = append(pkgPaths, paths[0])
|
||||
restPrivate = true
|
||||
}
|
||||
|
||||
// find first private match
|
||||
privateIdx := 1
|
||||
if !restPrivate {
|
||||
newPath := paths[0]
|
||||
for i := 1; i < len(paths); i++ {
|
||||
newPath += "/" + paths[i]
|
||||
if isPrivate(newPath) {
|
||||
pkgPaths = append(pkgPaths, newPath)
|
||||
pkgNames = append(pkgNames, paths[i])
|
||||
privateIdx = i + 1
|
||||
restPrivate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !restPrivate {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
lastComboIdx := 1
|
||||
for i := privateIdx; i < len(paths); i++ {
|
||||
newPath := pkgPaths[lastComboIdx-1] + "/" + paths[i]
|
||||
pkgPaths = append(pkgPaths, newPath)
|
||||
pkgNames = append(pkgNames, paths[i])
|
||||
|
||||
lastComboIdx++
|
||||
}
|
||||
pkgNames = append(pkgNames, paths[len(paths)-1])
|
||||
|
||||
return pkgPaths, pkgNames
|
||||
}
|
||||
|
||||
func dedupStrings(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
j := 0
|
||||
for _, v := range paths {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
paths[j] = v
|
||||
j++
|
||||
}
|
||||
return paths[:j]
|
||||
}
|
||||
|
||||
// TODO: possible that package collisions can occur; for instance, if
|
||||
// 'github.com/thingy/foo' and 'bar/baz/foo/zip' were both private imports
|
||||
// of the same object, 'foo' would be added to as a private import
|
||||
// twice, due to the logic of importPathCombos(). There needs to be some
|
||||
// way to differentiate between 'foo' of 'github.com/thingy/foo' and
|
||||
// 'bar/baz/foo/zip' so the same buildID is not used, which would create
|
||||
// an identical hash.
|
||||
func hashImport(pkg string, garbledImports map[string]string) string {
|
||||
if garbledPkg, ok := garbledImports[pkg]; ok {
|
||||
return garbledPkg
|
||||
}
|
||||
|
||||
garbledPkg := hashWith(buildInfo.imports[pkg].buildID, pkg)
|
||||
garbledImports[pkg] = garbledPkg
|
||||
|
||||
return garbledPkg
|
||||
}
|
||||
|
||||
// garbleSymbols replaces all private import paths/package names in symbol names
|
||||
// and data of an archive member.
|
||||
func garbleSymbols(am *goobj2.ArchiveMember, privImports privateImports, garbledImports map[string]string, buf *bytes.Buffer, sb *strings.Builder) {
|
||||
lists := [][]*goobj2.Sym{am.SymDefs, am.NonPkgSymDefs, am.NonPkgSymRefs}
|
||||
for _, list := range lists {
|
||||
for _, s := range list {
|
||||
// skip debug symbols, and remove the debug symbol's data to save space
|
||||
if s.Kind >= goobj2.SDWARFINFO && s.Kind <= goobj2.SDWARFLINES {
|
||||
s.Data = nil
|
||||
continue
|
||||
}
|
||||
|
||||
// skip local asm symbols. For some reason garbling these breaks things
|
||||
// add the symbol name to a blacklist, so we don't garble related symbols
|
||||
// TODO: don't add duplicates
|
||||
if s.Kind == goobj2.SABIALIAS {
|
||||
if parts := strings.SplitN(s.Name, ".", 2); parts[0] == "main" {
|
||||
skipPrefixes = append(skipPrefixes, s.Name)
|
||||
skipPrefixes = append(skipPrefixes, `"".`+parts[1])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// garble read-only static data, but not strings. If import paths are in string
|
||||
// symbols, that means garbling string symbols might effect the behavior of the
|
||||
// compiled binary
|
||||
if s.Kind == goobj2.SRODATA && s.Data != nil && !strings.HasPrefix(s.Name, "go.string.") {
|
||||
var dataTyp dataType
|
||||
if strings.HasPrefix(s.Name, "type..importpath.") {
|
||||
dataTyp = importPath
|
||||
} else if strings.HasPrefix(s.Name, "type..namedata.") {
|
||||
dataTyp = namedata
|
||||
}
|
||||
s.Data = garbleSymData(s.Data, privImports, garbledImports, dataTyp, buf)
|
||||
|
||||
if s.Size != 0 {
|
||||
s.Size = uint32(len(s.Data))
|
||||
}
|
||||
}
|
||||
s.Name = garbleSymbolName(s.Name, privImports, garbledImports, sb)
|
||||
|
||||
for i := range s.Reloc {
|
||||
s.Reloc[i].Name = garbleSymbolName(s.Reloc[i].Name, privImports, garbledImports, sb)
|
||||
}
|
||||
if s.Type != nil {
|
||||
s.Type.Name = garbleSymbolName(s.Type.Name, privImports, garbledImports, sb)
|
||||
}
|
||||
if s.Func != nil {
|
||||
for i := range s.Func.FuncData {
|
||||
s.Func.FuncData[i].Sym.Name = garbleSymbolName(s.Func.FuncData[i].Sym.Name, privImports, garbledImports, sb)
|
||||
}
|
||||
for _, inl := range s.Func.InlTree {
|
||||
inl.Func.Name = garbleSymbolName(inl.Func.Name, privImports, garbledImports, sb)
|
||||
}
|
||||
|
||||
// remove unneeded debug aux symbols
|
||||
s.Func.DwarfInfo = nil
|
||||
s.Func.DwarfLoc = nil
|
||||
s.Func.DwarfRanges = nil
|
||||
s.Func.DwarfDebugLines = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range am.SymRefs {
|
||||
am.SymRefs[i].Name = garbleSymbolName(am.SymRefs[i].Name, privImports, garbledImports, sb)
|
||||
}
|
||||
|
||||
// remove dwarf file list, it isn't needed as we pass "-w, -s" to the linker
|
||||
am.DWARFFileList = nil
|
||||
}
|
||||
|
||||
// garbleSymbolName finds all private imports in a symbol name, garbles them,
|
||||
// and returns the modified symbol name.
|
||||
func garbleSymbolName(symName string, privImports privateImports, garbledImports map[string]string, sb *strings.Builder) string {
|
||||
prefix, name, skipSym := splitSymbolPrefix(symName)
|
||||
if skipSym {
|
||||
// log.Printf("\t\t? Skipped symbol: %s", symName)
|
||||
return symName
|
||||
}
|
||||
|
||||
var namedataSym bool
|
||||
if prefix == "type..namedata." {
|
||||
namedataSym = true
|
||||
}
|
||||
|
||||
var off int
|
||||
for {
|
||||
o, l := privateImportIndex(name[off:], privImports, namedataSym)
|
||||
if o == -1 {
|
||||
if sb.Len() != 0 {
|
||||
sb.WriteString(name[off:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
sb.WriteString(name[off : off+o])
|
||||
sb.WriteString(hashImport(name[off+o:off+o+l], garbledImports))
|
||||
off += o + l
|
||||
}
|
||||
|
||||
if sb.Len() == 0 {
|
||||
// log.Printf("\t\t? Skipped symbol: %s", symName)
|
||||
return symName
|
||||
}
|
||||
defer sb.Reset()
|
||||
|
||||
return prefix + sb.String()
|
||||
}
|
||||
|
||||
// if symbols have one of these prefixes, skip
|
||||
// garbling
|
||||
// TODO: skip compiler generated/builtin symbols
|
||||
var skipPrefixes = []string{
|
||||
// these symbols never contain import paths
|
||||
"gclocals.",
|
||||
"gclocals·",
|
||||
|
||||
// string names be what they be
|
||||
"go.string.",
|
||||
|
||||
// skip debug symbols
|
||||
"go.info.",
|
||||
|
||||
// skip entrypoint symbols
|
||||
"main.init.",
|
||||
"main..stmp",
|
||||
}
|
||||
|
||||
// symbols that are related to the entrypoint
|
||||
// that cannot be garbled
|
||||
var entrypointSyms = [...]string{
|
||||
"main.main",
|
||||
"main..inittask",
|
||||
}
|
||||
|
||||
// if any of these strings are found in a
|
||||
// symbol name, it should not be garbled
|
||||
var skipSubstrs = [...]string{
|
||||
// skip test symbols
|
||||
"_test.",
|
||||
}
|
||||
|
||||
// prefixes of symbols that we will garble,
|
||||
// but we split the symbol name by one of
|
||||
// these prefixes so that we do not
|
||||
// accidentally garble an essential prefix
|
||||
var symPrefixes = [...]string{
|
||||
"go.builtin.",
|
||||
"go.itab.",
|
||||
"go.itablink.",
|
||||
"go.interface.",
|
||||
"go.map.",
|
||||
"gofile..",
|
||||
"type..eq.",
|
||||
"type..eqfunc.",
|
||||
"type..hash.",
|
||||
"type..importpath.",
|
||||
"type..namedata.",
|
||||
"type.",
|
||||
}
|
||||
|
||||
// splitSymbolPrefix returns the symbol name prefix Go uses
|
||||
// to help designate the type of the symbol, and the rest of
|
||||
// the symbol name. Additionally, a bool is returned that
|
||||
// signifies whether garbling the symbol name should be skipped.
|
||||
func splitSymbolPrefix(symName string) (string, string, bool) {
|
||||
if symName == "" {
|
||||
return "", "", true
|
||||
}
|
||||
|
||||
for _, prefix := range skipPrefixes {
|
||||
if strings.HasPrefix(symName, prefix) {
|
||||
return "", "", true
|
||||
}
|
||||
}
|
||||
|
||||
for _, entrySym := range entrypointSyms {
|
||||
if symName == entrySym {
|
||||
return "", "", true
|
||||
}
|
||||
}
|
||||
|
||||
for _, substr := range skipSubstrs {
|
||||
if strings.Contains(symName, substr) {
|
||||
return "", "", true
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range symPrefixes {
|
||||
if strings.HasPrefix(symName, prefix) {
|
||||
return symName[:len(prefix)], symName[len(prefix):], false
|
||||
}
|
||||
}
|
||||
|
||||
return "", symName, false
|
||||
}
|
||||
|
||||
// privateImportIndex returns the offset and length of a private import
|
||||
// in symName. If no private imports from privImports are present in
|
||||
// symName, -1, 0 is returned.
|
||||
func privateImportIndex(symName string, privImports privateImports, nameDataSym bool) (int, int) {
|
||||
matchPkg := func(pkg string) int {
|
||||
off := strings.Index(symName, pkg)
|
||||
if off == -1 {
|
||||
return -1
|
||||
// check that we didn't match inside an import path. If the
|
||||
// byte before the start of the match is not a small set of
|
||||
// symbols that can make up a symbol name, we must have matched
|
||||
// inside of an ident name as a substring. Or, if the byte
|
||||
// before the start of the match is a forward slash, we are
|
||||
// definitely inside of an input path.
|
||||
} else if off != 0 && (!isSymbol(symName[off-1]) || symName[off-1] == '/') {
|
||||
return -1
|
||||
}
|
||||
|
||||
return off
|
||||
}
|
||||
|
||||
firstOff, l := -1, 0
|
||||
for _, privatePkg := range privImports.privatePaths {
|
||||
off := matchPkg(privatePkg)
|
||||
if off == -1 {
|
||||
continue
|
||||
} else if off < firstOff || firstOff == -1 {
|
||||
firstOff = off
|
||||
l = len(privatePkg)
|
||||
}
|
||||
}
|
||||
|
||||
if nameDataSym {
|
||||
for _, privateName := range privImports.privateNames {
|
||||
// search for the package name plus a period, to
|
||||
// minimize the likelihood that the package isn't
|
||||
// matched as a substring of another ident name.
|
||||
// ex: pkgName = main, symName = "domainname"
|
||||
off := matchPkg(privateName + ".")
|
||||
if off == -1 {
|
||||
continue
|
||||
} else if off < firstOff || firstOff == -1 {
|
||||
firstOff = off
|
||||
l = len(privateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstOff, l
|
||||
}
|
||||
|
||||
func isSymbol(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '(', ')', '*', ',', '[', ']', '_', '{', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// garbleSymData finds all private imports in a symbol's data blob,
|
||||
// garbles them, and returns the modified symbol data.
|
||||
func garbleSymData(data []byte, privImports privateImports, garbledImports map[string]string, dataTyp dataType, buf *bytes.Buffer) []byte {
|
||||
var symData []byte
|
||||
switch dataTyp {
|
||||
case importPath:
|
||||
symData = data[3:]
|
||||
case namedata:
|
||||
oldNameLen := int(uint16(data[1])<<8 | uint16(data[2]))
|
||||
symData = data[3 : 3+oldNameLen]
|
||||
default:
|
||||
symData = data
|
||||
}
|
||||
|
||||
var off int
|
||||
for {
|
||||
o, l := privateImportIndex(string(symData[off:]), privImports, dataTyp == namedata)
|
||||
if o == -1 {
|
||||
if buf.Len() != 0 {
|
||||
buf.Write(symData[off:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// there is only one import path in the symbol's data, garble it and return
|
||||
if dataTyp == importPath {
|
||||
return createImportPathData(hashImport(string(symData[o:o+l]), garbledImports))
|
||||
}
|
||||
|
||||
buf.Write(symData[off : off+o])
|
||||
buf.WriteString(hashImport(string(symData[off+o:off+o+l]), garbledImports))
|
||||
off += o + l
|
||||
}
|
||||
|
||||
if buf.Len() == 0 {
|
||||
return data
|
||||
}
|
||||
defer buf.Reset()
|
||||
|
||||
if dataTyp == namedata {
|
||||
return patchReflectData(buf.Bytes(), data)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
//createImportPathData creates reflection data for an
|
||||
// import path
|
||||
func createImportPathData(importPath string) []byte {
|
||||
l := 3 + len(importPath)
|
||||
b := make([]byte, l)
|
||||
b[0] = 0
|
||||
b[1] = uint8(len(importPath) >> 8)
|
||||
b[2] = uint8(len(importPath))
|
||||
copy(b[3:], importPath)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// patchReflectData replaces the name of a struct field or
|
||||
// method in reflection namedata
|
||||
func patchReflectData(newName []byte, data []byte) []byte {
|
||||
oldNameLen := int(uint16(data[1])<<8 | uint16(data[2]))
|
||||
|
||||
data[1] = uint8(len(newName) >> 8)
|
||||
data[2] = uint8(len(newName))
|
||||
|
||||
return append(data[:3], append(newName, data[3+oldNameLen:]...)...)
|
||||
}
|
||||
|
||||
// garbleImportCfg writes a new importcfg with private import paths garbled.
|
||||
func garbleImportCfg(path string, importCfg goobj2.ImportCfg, garbledImports map[string]string) error {
|
||||
newCfg, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating importcfg: %v", err)
|
||||
}
|
||||
defer newCfg.Close()
|
||||
newCfgWr := bufio.NewWriter(newCfg)
|
||||
|
||||
for pkgPath, info := range importCfg {
|
||||
if isPrivate(pkgPath) {
|
||||
pkgPath = hashImport(pkgPath, garbledImports)
|
||||
}
|
||||
if info.IsSharedLib {
|
||||
newCfgWr.WriteString("packageshlib")
|
||||
} else {
|
||||
newCfgWr.WriteString("packagefile")
|
||||
}
|
||||
|
||||
newCfgWr.WriteRune(' ')
|
||||
newCfgWr.WriteString(pkgPath)
|
||||
newCfgWr.WriteRune('=')
|
||||
newCfgWr.WriteString(info.Path)
|
||||
newCfgWr.WriteRune('\n')
|
||||
}
|
||||
|
||||
if err := newCfgWr.Flush(); err != nil {
|
||||
return fmt.Errorf("error writing importcfg: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue